Skip to content

Commit b5e0902

Browse files
authored
fix(app): clarify status indicator severity (#36031)
1 parent 9a51765 commit b5e0902

3 files changed

Lines changed: 80 additions & 43 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
3+
4+
describe("serverStatusDotClass", () => {
5+
test("uses the success token while the server and services are healthy", () => {
6+
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
7+
})
8+
9+
test("uses the warning token for non-blocking issues while the server is online", () => {
10+
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
11+
})
12+
13+
test("uses the critical token only after the server connection drops", () => {
14+
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
15+
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
16+
})
17+
18+
test("stays neutral before status is ready", () => {
19+
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
20+
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
21+
})
22+
})
23+
24+
describe("hasNonBlockingServiceIssue", () => {
25+
test("detects MCP failures that do not block chatting", () => {
26+
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
27+
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
28+
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
29+
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
30+
})
31+
32+
test("detects LSP failures that do not block chatting", () => {
33+
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
34+
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
35+
})
36+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
2+
3+
export function hasNonBlockingServiceIssue(input: {
4+
mcp: Array<McpStatus["status"]>
5+
lsp: Array<LspStatus["status"]>
6+
}) {
7+
return (
8+
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
9+
input.lsp.some((status) => status === "error")
10+
)
11+
}
12+
13+
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
14+
if (input.serverHealth === false) return "bg-icon-critical-base"
15+
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
16+
if (input.issue) return "bg-icon-warning-base"
17+
if (input.serverHealth === true) return "bg-icon-success-base"
18+
return "bg-border-weak-base"
19+
}

packages/app/src/components/status-popover.tsx

Lines changed: 25 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
99
import { useServerSDK } from "@/context/server-sdk"
1010
import { useSync } from "@/context/sync"
1111
import { useGlobal } from "@/context/global"
12+
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
1213

1314
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
1415
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +20,14 @@ export function StatusPopover() {
1920
const global = useGlobal()
2021
const sync = useSync()
2122
const [shown, setShown] = createSignal(false)
22-
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
23-
const mcpIssue = createMemo(() => {
24-
const mcp = Object.values(sync().data.mcp ?? {})
25-
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
26-
const warn = mcp.some((item) => item.status === "needs_auth")
27-
if (failed) return "critical" as const
28-
if (warn) return "warning" as const
29-
})
30-
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
31-
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
23+
const serverHealth = () => global.servers.health[server.key]?.healthy
24+
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
25+
const issue = createMemo(() =>
26+
hasNonBlockingServiceIssue({
27+
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
28+
lsp: (sync().data.lsp ?? []).map((item) => item.status),
29+
}),
30+
)
3231

3332
return (
3433
<Popover
@@ -47,13 +46,11 @@ export function StatusPopover() {
4746
<Icon name={shown() ? "status-active" : "status"} size="small" />
4847
</div>
4948
<div
50-
classList={{
51-
"absolute -top-px -right-px size-1.5 rounded-full": true,
52-
"bg-icon-success-base": ready() && healthy(),
53-
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
54-
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
55-
"bg-border-weak-base": serverHealthy() || !ready(),
56-
}}
49+
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
50+
ready: ready(),
51+
serverHealth: serverHealth(),
52+
issue: issue(),
53+
})}`}
5754
/>
5855
</div>
5956
}
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
8784
const sync = useSync()
8885
const [shown, setShown] = createSignal(false)
8986
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
90-
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
91-
const mcpIssue = createMemo(() => {
92-
const mcp = Object.values(sync().data.mcp ?? {})
93-
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
94-
const warn = mcp.some((item) => item.status === "needs_auth")
95-
if (failed) return "critical" as const
96-
if (warn) return "warning" as const
97-
})
98-
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
87+
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
88+
const issue = createMemo(() =>
89+
hasNonBlockingServiceIssue({
90+
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
91+
lsp: (sync().data.lsp ?? []).map((item) => item.status),
92+
}),
93+
)
9994
const state = createMemo<StatusPopoverState>(() => ({
10095
shown: shown(),
10196
ready: ready(),
102-
healthy: healthy(),
10397
serverHealth: serverHealth(),
104-
issue: mcpIssue(),
98+
issue: issue(),
10599
label: language.t("status.popover.trigger"),
106100
onOpenChange: setShown,
107101
body: () => (
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
123117
const state = createMemo<StatusPopoverState>(() => ({
124118
shown: shown(),
125119
ready: serverHealth() !== undefined,
126-
healthy: serverHealth() === true,
127120
serverHealth: serverHealth(),
121+
issue: false,
128122
label: language.t("status.popover.trigger"),
129123
onOpenChange: setShown,
130124
body: () => (
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
140134
type StatusPopoverState = {
141135
shown: boolean
142136
ready: boolean
143-
healthy: boolean
144137
serverHealth: boolean | undefined
145-
issue?: "critical" | "warning"
138+
issue: boolean
146139
label: string
147140
onOpenChange: (value: boolean) => void
148141
body: () => JSX.Element
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
161154
}
162155

163156
function StatusPopoverView(props: { state: StatusPopoverState }) {
164-
const statusDotClass = () => ({
165-
"absolute rounded-full": true,
166-
"bg-icon-success-base": props.state.ready && props.state.healthy,
167-
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
168-
"bg-icon-critical-base":
169-
props.state.serverHealth === false ||
170-
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
171-
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
172-
})
173-
174157
const popoverProps = {
175158
class:
176159
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
195178
<div class="relative size-4">
196179
<IconV2 name={props.state.shown ? "status-active" : "status"} />
197180
<div
198-
classList={statusDotClass()}
199-
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
181+
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
200182
/>
201183
</div>
202184
}

0 commit comments

Comments
 (0)