feat(app): add server connection indicator
This commit is contained in:
parent
014696db63
commit
11d2eb3ec8
7 changed files with 207 additions and 2 deletions
72
packages/app/src/components/server-status-icon.css
Normal file
72
packages/app/src/components/server-status-icon.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/app/src/components/server-status-icon.stories.tsx
Normal file
33
packages/app/src/components/server-status-icon.stories.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
class="!w-9 shrink-0"
|
||||||
|
aria-label={props.label}
|
||||||
|
icon={<ServerStatusIcon state={props.state} />}
|
||||||
|
/>
|
||||||
|
<span class="text-[13px] text-v2-text-text-muted">{props.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Reconnecting = {
|
||||||
|
render: () => <Preview state="reconnecting" label="Retrying automatically..." />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Disconnected = {
|
||||||
|
render: () => <Preview state="disconnected" label="Server disconnected" />,
|
||||||
|
}
|
||||||
17
packages/app/src/components/server-status-icon.test.ts
Normal file
17
packages/app/src/components/server-status-icon.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
35
packages/app/src/components/server-status-icon.tsx
Normal file
35
packages/app/src/components/server-status-icon.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<svg
|
||||||
|
data-component="server-status-icon"
|
||||||
|
data-state={props.state}
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13.3141 6.00001V14.4572H2.68555V1.54285H9.5M13.3141 14.4572V9.54285H2.68555V14.4572"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
<path data-slot="server-status-left" d="M8.5 11.75H8V12.25H8.5Z" />
|
||||||
|
<path data-slot="server-status-right" d="M11 11.75H10.5V12.25H11Z" />
|
||||||
|
<path
|
||||||
|
data-slot="server-status-dot"
|
||||||
|
d="M13 5C14.3807 5 15.5 3.88071 15.5 2.5C15.5 1.11929 14.3807 0 13 0C11.6193 0 10.5 1.11929 10.5 2.5C10.5 3.88071 11.6193 5 13 5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,9 @@ import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
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 { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||||
import { useTitlebarRightMount } from "../titlebar"
|
import { useTitlebarRightMount } from "../titlebar"
|
||||||
|
import { useSettingsDialog } from "../settings-dialog"
|
||||||
|
import { resolveServerStatus, ServerStatusIcon, type ServerStatusIconState } from "../server-status-icon"
|
||||||
|
|
||||||
const OPEN_APPS = [
|
const OPEN_APPS = [
|
||||||
"vscode",
|
"vscode",
|
||||||
|
|
@ -145,6 +149,8 @@ export function SessionHeader() {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
@ -166,6 +172,7 @@ export function SessionHeader() {
|
||||||
const search = settings.visibility.search
|
const search = settings.visibility.search
|
||||||
const status = settings.visibility.status
|
const status = settings.visibility.status
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
const openServerSettings = useSettingsDialog("servers")
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
finder: true,
|
finder: true,
|
||||||
|
|
@ -235,7 +242,23 @@ export function SessionHeader() {
|
||||||
const tint = createMemo(() =>
|
const tint = createMemo(() =>
|
||||||
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
|
||||||
)
|
)
|
||||||
|
const serverStatus = createMemo<ServerStatusIconState | undefined>(() => {
|
||||||
|
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<SessionHeaderV2ActionsState>(() => ({
|
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
|
||||||
|
serverStatus: isDesktop() ? serverStatus() : undefined,
|
||||||
|
serverStatusLabel: serverStatusLabel(),
|
||||||
|
onServerStatusClick: openServerSettings,
|
||||||
toolsVisible: isDesktop() && status(),
|
toolsVisible: isDesktop() && status(),
|
||||||
statusLabel: language.t("status.popover.tools.trigger"),
|
statusLabel: language.t("status.popover.tools.trigger"),
|
||||||
reviewLabel: language.t("command.review.toggle"),
|
reviewLabel: language.t("command.review.toggle"),
|
||||||
|
|
@ -518,6 +541,9 @@ export function SessionHeader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionHeaderV2ActionsState = {
|
type SessionHeaderV2ActionsState = {
|
||||||
|
serverStatus?: ServerStatusIconState
|
||||||
|
serverStatusLabel: string
|
||||||
|
onServerStatusClick: () => void
|
||||||
toolsVisible: boolean
|
toolsVisible: boolean
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
reviewLabel: string
|
reviewLabel: string
|
||||||
|
|
@ -532,6 +558,21 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-[6px]">
|
<div class="flex items-center gap-[6px]">
|
||||||
|
<Show when={props.state.serverStatus}>
|
||||||
|
{(status) => (
|
||||||
|
<TooltipV2 placement="bottom" value={props.state.serverStatusLabel}>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
class="!w-9 shrink-0"
|
||||||
|
onClick={props.state.onServerStatusClick}
|
||||||
|
aria-label={props.state.serverStatusLabel}
|
||||||
|
icon={<ServerStatusIcon state={status()} />}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={props.state.toolsVisible}>
|
<Show when={props.state.toolsVisible}>
|
||||||
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
|
<TooltipV2 placement="bottom" value={props.state.statusLabel}>
|
||||||
<StatusPopoverV2 />
|
<StatusPopoverV2 />
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||||
const warning = () => props.state.serverHealth === false || props.state.issue !== undefined
|
const warning = () => props.state.issue !== undefined
|
||||||
|
|
||||||
const popoverProps = {
|
const popoverProps = {
|
||||||
class:
|
class:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
@ -142,6 +143,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
let run: Promise<void> | undefined
|
let run: Promise<void> | undefined
|
||||||
let started = false
|
let started = false
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
const [connection, setConnection] = createStore({
|
||||||
|
status: "connecting" as "connecting" | "connected" | "reconnecting",
|
||||||
|
})
|
||||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||||
let lastEventAt = Date.now()
|
let lastEventAt = Date.now()
|
||||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
@ -187,6 +191,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
setConnection("status", "connected")
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
for await (const event of events.stream) {
|
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
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
setConnection("status", "reconnecting")
|
||||||
await wait(RECONNECT_DELAY_MS)
|
await wait(RECONNECT_DELAY_MS)
|
||||||
}
|
}
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
|
|
@ -269,6 +275,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
start,
|
start,
|
||||||
},
|
},
|
||||||
|
connection,
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
return createSdkForServer({
|
return createSdkForServer({
|
||||||
server: server.http,
|
server: server.http,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue