Ver Fonte

fix(app): clarify status indicator severity (#36031)

Luke Parker há 1 mês atrás
pai
commit
b5e09024d5

+ 36 - 0
packages/app/src/components/status-popover-indicator.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
+
+describe("serverStatusDotClass", () => {
+  test("uses the success token while the server and services are healthy", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
+  })
+
+  test("uses the warning token for non-blocking issues while the server is online", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
+  })
+
+  test("uses the critical token only after the server connection drops", () => {
+    expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
+    expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
+  })
+
+  test("stays neutral before status is ready", () => {
+    expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
+    expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
+  })
+})
+
+describe("hasNonBlockingServiceIssue", () => {
+  test("detects MCP failures that do not block chatting", () => {
+    expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
+  })
+
+  test("detects LSP failures that do not block chatting", () => {
+    expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
+    expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
+  })
+})

+ 19 - 0
packages/app/src/components/status-popover-indicator.ts

@@ -0,0 +1,19 @@
+import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
+
+export function hasNonBlockingServiceIssue(input: {
+  mcp: Array<McpStatus["status"]>
+  lsp: Array<LspStatus["status"]>
+}) {
+  return (
+    input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
+    input.lsp.some((status) => status === "error")
+  )
+}
+
+export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
+  if (input.serverHealth === false) return "bg-icon-critical-base"
+  if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
+  if (input.issue) return "bg-icon-warning-base"
+  if (input.serverHealth === true) return "bg-icon-success-base"
+  return "bg-border-weak-base"
+}

+ 25 - 43
packages/app/src/components/status-popover.tsx

@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
 import { useServerSDK } from "@/context/server-sdk"
 import { useSync } from "@/context/sync"
 import { useGlobal } from "@/context/global"
+import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
 
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -19,16 +20,14 @@ export function StatusPopover() {
   const global = useGlobal()
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
-  const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
-  const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync().data.mcp ?? {})
-    const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
-    const warn = mcp.some((item) => item.status === "needs_auth")
-    if (failed) return "critical" as const
-    if (warn) return "warning" as const
-  })
-  const serverHealthy = () => global.servers.health[server.key]?.healthy === true
-  const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
+  const serverHealth = () => global.servers.health[server.key]?.healthy
+  const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const issue = createMemo(() =>
+    hasNonBlockingServiceIssue({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+      lsp: (sync().data.lsp ?? []).map((item) => item.status),
+    }),
+  )
 
   return (
     <Popover
@@ -47,13 +46,11 @@ export function StatusPopover() {
             <Icon name={shown() ? "status-active" : "status"} size="small" />
           </div>
           <div
-            classList={{
-              "absolute -top-px -right-px size-1.5 rounded-full": true,
-              "bg-icon-success-base": ready() && healthy(),
-              "bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
-              "bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
-              "bg-border-weak-base": serverHealthy() || !ready(),
-            }}
+            class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
+              ready: ready(),
+              serverHealth: serverHealth(),
+              issue: issue(),
+            })}`}
           />
         </div>
       }
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
   const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
-  const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
-  const mcpIssue = createMemo(() => {
-    const mcp = Object.values(sync().data.mcp ?? {})
-    const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
-    const warn = mcp.some((item) => item.status === "needs_auth")
-    if (failed) return "critical" as const
-    if (warn) return "warning" as const
-  })
-  const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
+  const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
+  const issue = createMemo(() =>
+    hasNonBlockingServiceIssue({
+      mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
+      lsp: (sync().data.lsp ?? []).map((item) => item.status),
+    }),
+  )
   const state = createMemo<StatusPopoverState>(() => ({
     shown: shown(),
     ready: ready(),
-    healthy: healthy(),
     serverHealth: serverHealth(),
-    issue: mcpIssue(),
+    issue: issue(),
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
     body: () => (
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
   const state = createMemo<StatusPopoverState>(() => ({
     shown: shown(),
     ready: serverHealth() !== undefined,
-    healthy: serverHealth() === true,
     serverHealth: serverHealth(),
+    issue: false,
     label: language.t("status.popover.trigger"),
     onOpenChange: setShown,
     body: () => (
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
 type StatusPopoverState = {
   shown: boolean
   ready: boolean
-  healthy: boolean
   serverHealth: boolean | undefined
-  issue?: "critical" | "warning"
+  issue: boolean
   label: string
   onOpenChange: (value: boolean) => void
   body: () => JSX.Element
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
 }
 
 function StatusPopoverView(props: { state: StatusPopoverState }) {
-  const statusDotClass = () => ({
-    "absolute rounded-full": true,
-    "bg-icon-success-base": props.state.ready && props.state.healthy,
-    "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
-    "bg-icon-critical-base":
-      props.state.serverHealth === false ||
-      (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
-    "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
-  })
-
   const popoverProps = {
     class:
       "[&_[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 }) {
         <div class="relative size-4">
           <IconV2 name={props.state.shown ? "status-active" : "status"} />
           <div
-            classList={statusDotClass()}
-            class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
+            class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
           />
         </div>
       }