From aaf917e09d567ca001a7278e20db8fc1b8a64038 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:52:44 +0000 Subject: [PATCH 1/3] fix(app): restore summary status indicator --- .../e2e/regression/session-summary.spec.ts | 1 + .../app/src/session/summary/indicator.test.ts | 62 +++++++++++++++++++ packages/app/src/session/summary/indicator.ts | 24 +++++++ .../app/src/session/summary/server-panel.tsx | 18 +++++- packages/app/src/session/summary/summary.css | 18 ++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/session/summary/indicator.test.ts create mode 100644 packages/app/src/session/summary/indicator.ts diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index c8eaf19b5ca6..9d5a50baaab1 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -76,6 +76,7 @@ for (const layout of ["horizontal", "vertical"] as const) { const summary = page.getByRole("dialog", { name: "Session details", exact: true }) const project = summary.getByRole("button", { name: fixture.project.name, exact: true }) const server = summary.getByRole("button", { name: "Extensions", exact: true }) + await expect(server.locator('[data-slot="status-indicator"]')).toBeVisible() await expect(project).toHaveAttribute("aria-expanded", "true") await expect(server).toHaveAttribute("aria-expanded", "true") for (const heading of [project, server]) { diff --git a/packages/app/src/session/summary/indicator.test.ts b/packages/app/src/session/summary/indicator.test.ts new file mode 100644 index 000000000000..fe0afe9b2149 --- /dev/null +++ b/packages/app/src/session/summary/indicator.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator" + +describe("serverStatusDotClass", () => { + test("uses the success token while the server and services are healthy", () => { + expect( + serverStatusDotClass({ ready: true, serverHealth: true, attention: false, issue: false, connecting: false }), + ).toBe("bg-icon-success-base") + }) + + test("uses the attention token when a service needs attention", () => { + expect( + serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true, connecting: false }), + ).toBe("bg-v2-background-bg-accent") + }) + + test("uses the warning token for non-blocking issues", () => { + expect( + serverStatusDotClass({ ready: true, serverHealth: true, attention: false, issue: true, connecting: false }), + ).toBe("bg-icon-warning-base") + }) + + test("uses the critical token when the server is down", () => { + expect( + serverStatusDotClass({ ready: true, serverHealth: false, attention: false, issue: false, connecting: false }), + ).toBe("bg-icon-critical-base") + expect( + serverStatusDotClass({ ready: true, serverHealth: false, attention: false, issue: true, connecting: true }), + ).toBe("bg-icon-critical-base") + }) + + test("pulses the neutral dot while reconnecting", () => { + expect( + serverStatusDotClass({ ready: true, serverHealth: true, attention: false, issue: false, connecting: true }), + ).toBe("bg-border-weak-base animate-pulse") + }) + + test("stays neutral before status is ready", () => { + expect( + serverStatusDotClass({ ready: false, serverHealth: true, attention: false, issue: false, connecting: false }), + ).toBe("bg-border-weak-base") + expect( + serverStatusDotClass({ + ready: false, + serverHealth: undefined, + attention: false, + issue: false, + connecting: false, + }), + ).toBe("bg-border-weak-base") + }) +}) + +describe("service status", () => { + test("detects MCP failures and authentication needs", () => { + expect(hasNonBlockingServiceIssue(["failed"])).toBe(true) + expect(hasNonBlockingServiceIssue(["needs_auth"])).toBe(true) + expect(hasNonBlockingServiceIssue(["connected", "pending", "disabled"])).toBe(false) + expect(hasServiceNeedingAttention(["needs_auth"])).toBe(true) + expect(hasServiceNeedingAttention(["failed", "connected", "pending", "disabled"])).toBe(false) + }) +}) diff --git a/packages/app/src/session/summary/indicator.ts b/packages/app/src/session/summary/indicator.ts new file mode 100644 index 000000000000..fe1928887f15 --- /dev/null +++ b/packages/app/src/session/summary/indicator.ts @@ -0,0 +1,24 @@ +import type { McpServer } from "@opencode/client/promise" + +export function hasServiceNeedingAttention(statuses: Array) { + return statuses.some((status) => status === "needs_auth") +} + +export function hasNonBlockingServiceIssue(statuses: Array) { + return statuses.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") +} + +export function serverStatusDotClass(input: { + ready: boolean + serverHealth: boolean | undefined + attention: boolean + issue: boolean + connecting: boolean +}) { + if (input.serverHealth === false) return "bg-icon-critical-base" + if (input.connecting) return "bg-border-weak-base animate-pulse" + if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" + if (input.attention) return "bg-v2-background-bg-accent" + if (input.issue) return "bg-icon-warning-base" + return "bg-icon-success-base" +} diff --git a/packages/app/src/session/summary/server-panel.tsx b/packages/app/src/session/summary/server-panel.tsx index 6e8e1e4626c0..d83b596beae1 100644 --- a/packages/app/src/session/summary/server-panel.tsx +++ b/packages/app/src/session/summary/server-panel.tsx @@ -27,6 +27,7 @@ import { showToast } from "@/shell/notifications/toast" import { pluginLabel } from "@/providers/catalog/plugin" import { useMcpToggle, type McpControls } from "@/providers/connect/mcp" import { configuredLsps } from "./configured-lsp" +import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator" const services = [ { type: "mcp", icon: "mcp", label: "session.summary.mcp" }, @@ -50,10 +51,22 @@ type ServiceMenuProps = { export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) { const language = useLanguage() const server = useServer() + const data = useData() const global = useGlobal() const settings = useSettings() const contentID = createUniqueId() const expanded = settings.sessionSummary.serverExpanded + const mcp = () => data.location.mcp.server.list({ directory: props.directory }) + const status = createMemo(() => { + const statuses = (mcp() ?? []).map((item) => item.status.status) + return serverStatusDotClass({ + ready: server.health?.healthy === false || mcp() !== undefined, + serverHealth: server.health?.healthy, + attention: hasServiceNeedingAttention(statuses), + issue: hasNonBlockingServiceIssue(statuses), + connecting: server.ctx.sdk.connection.status() !== "connected", + }) + }) const name = createMemo(() => { const servers = global.servers.list() if (servers.length < 2) return language.t("session.summary.server") @@ -71,7 +84,10 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m aria-controls={contentID} onClick={() => settings.sessionSummary.setServerExpanded(!expanded())} > - + + + {name()} diff --git a/packages/app/src/session/summary/summary.css b/packages/app/src/session/summary/summary.css index f116aa61f8be..fdea4eddd691 100644 --- a/packages/app/src/session/summary/summary.css +++ b/packages/app/src/session/summary/summary.css @@ -108,6 +108,24 @@ flex-shrink: 0; color: var(--v2-icon-icon-muted); } +.session-summary-server-icon { + position: relative; + width: 16px; + height: 16px; + flex-shrink: 0; +} +.session-summary-server-status { + position: absolute; + inset-block-start: -4px; + inset-inline-end: -4px; + width: 8px; + height: 8px; + border: 1px solid var(--v2-background-bg-base); + border-radius: 50%; +} +[data-color-scheme="dark"] .session-summary-server-status { + border-color: var(--v2-background-bg-layer-01); +} .session-summary-heading[aria-expanded="false"] .session-summary-disclosure { transform: rotate(-90deg); } From a7342c004f7b33eead96c363aa39b5a77f31ad7f Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:12:58 +0000 Subject: [PATCH 2/3] fix(app): surface summary status alerts --- .../regression/session-summary-mcp.spec.ts | 8 +++-- .../e2e/regression/session-summary.spec.ts | 4 +++ packages/app/src/new-session/view.tsx | 6 +++- .../app/src/session/summary/indicator.test.ts | 28 +++++++++++++++- packages/app/src/session/summary/indicator.ts | 26 +++++++++++++++ packages/app/src/session/summary/popover.tsx | 17 ++++++++-- .../app/src/session/summary/server-panel.tsx | 32 ++++++++++--------- packages/app/src/session/summary/status.ts | 23 +++++++++++++ packages/app/src/session/summary/summary.css | 13 ++++++-- .../src/session/timeline/message-timeline.tsx | 7 +++- 10 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/session/summary/status.ts diff --git a/packages/app/e2e/regression/session-summary-mcp.spec.ts b/packages/app/e2e/regression/session-summary-mcp.spec.ts index b474fcbe775b..91f378d623dd 100644 --- a/packages/app/e2e/regression/session-summary-mcp.spec.ts +++ b/packages/app/e2e/regression/session-summary-mcp.spec.ts @@ -32,8 +32,12 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a }) }) await page.goto(stressSessionHref(fixture.targetID)) - await page.getByRole("button", { name: "Session details", exact: true }).click() - await page.getByRole("button", { name: "MCP", exact: true }).click() + const trigger = page.getByRole("button", { name: "Session details", exact: true }) + await expect(trigger.locator('[data-slot="status-indicator"]')).toBeVisible() + await trigger.click() + const mcp = page.getByRole("button", { name: "MCP", exact: true }) + await expect(mcp.locator(".session-summary-service-status")).toBeVisible() + await mcp.click() const submenu = page.getByRole("dialog", { name: "MCP", exact: true }) const toggle = submenu.getByRole("switch", { name: "figma", exact: true }) const row = submenu diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 9d5a50baaab1..7452916ec410 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -71,12 +71,16 @@ for (const layout of ["horizontal", "vertical"] as const) { await page.goto(stressSessionHref(fixture.targetID)) const trigger = page.getByRole("button", { name: "Session details", exact: true }) await expect(trigger).toBeEnabled() + await expect(trigger.locator('[data-slot="status-indicator"]')).toHaveCount(0) await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0) await trigger.click() const summary = page.getByRole("dialog", { name: "Session details", exact: true }) const project = summary.getByRole("button", { name: fixture.project.name, exact: true }) const server = summary.getByRole("button", { name: "Extensions", exact: true }) await expect(server.locator('[data-slot="status-indicator"]')).toBeVisible() + await expect( + summary.getByRole("button", { name: "MCP", exact: true }).locator(".session-summary-service-status"), + ).toHaveCount(0) await expect(project).toHaveAttribute("aria-expanded", "true") await expect(server).toHaveAttribute("aria-expanded", "true") for (const heading of [project, server]) { diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index b5df344e53ce..3c8902ed0bb9 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -73,7 +73,11 @@ export function NewSessionView(props: { data-slot="new-session-summary" class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3" > - setStore("summary", open)}> + setStore("summary", open)} + > { test("uses the success token while the server and services are healthy", () => { @@ -59,4 +65,24 @@ describe("service status", () => { expect(hasServiceNeedingAttention(["needs_auth"])).toBe(true) expect(hasServiceNeedingAttention(["failed", "connected", "pending", "disabled"])).toBe(false) }) + + test("shows a dot only for noteworthy MCP states", () => { + expect(serviceStatusDotClass(["needs_auth"])).toBe("bg-v2-background-bg-accent") + expect(serviceStatusDotClass(["failed"])).toBe("bg-icon-warning-base") + expect(serviceStatusDotClass(["connected", "pending", "disabled"])).toBeUndefined() + }) + + test("marks the summary trigger only for errors and attention", () => { + expect(summaryStatus({ ready: true, serverHealth: true, mcp: ["connected"], connecting: false }).noteworthy).toBe( + false, + ) + expect(summaryStatus({ ready: true, serverHealth: true, mcp: ["needs_auth"], connecting: false })).toMatchObject({ + noteworthy: true, + mcp: "bg-v2-background-bg-accent", + }) + expect(summaryStatus({ ready: true, serverHealth: false, mcp: [], connecting: false })).toMatchObject({ + noteworthy: true, + server: "bg-icon-critical-base", + }) + }) }) diff --git a/packages/app/src/session/summary/indicator.ts b/packages/app/src/session/summary/indicator.ts index fe1928887f15..b160130f4a6c 100644 --- a/packages/app/src/session/summary/indicator.ts +++ b/packages/app/src/session/summary/indicator.ts @@ -8,6 +8,11 @@ export function hasNonBlockingServiceIssue(statuses: Array status !== "connected" && status !== "pending" && status !== "disabled") } +export function serviceStatusDotClass(statuses: Array) { + if (hasServiceNeedingAttention(statuses)) return "bg-v2-background-bg-accent" + if (hasNonBlockingServiceIssue(statuses)) return "bg-icon-warning-base" +} + export function serverStatusDotClass(input: { ready: boolean serverHealth: boolean | undefined @@ -22,3 +27,24 @@ export function serverStatusDotClass(input: { if (input.issue) return "bg-icon-warning-base" return "bg-icon-success-base" } + +export function summaryStatus(input: { + ready: boolean + serverHealth: boolean | undefined + mcp: Array + connecting: boolean +}) { + const attention = hasServiceNeedingAttention(input.mcp) + const issue = hasNonBlockingServiceIssue(input.mcp) + return { + server: serverStatusDotClass({ + ready: input.ready, + serverHealth: input.serverHealth, + attention, + issue, + connecting: input.connecting, + }), + mcp: serviceStatusDotClass(input.mcp), + noteworthy: input.serverHealth === false || attention || issue, + } +} diff --git a/packages/app/src/session/summary/popover.tsx b/packages/app/src/session/summary/popover.tsx index a49eaede070c..e5c5c28755cd 100644 --- a/packages/app/src/session/summary/popover.tsx +++ b/packages/app/src/session/summary/popover.tsx @@ -6,13 +6,15 @@ import { Tooltip } from "@opencode/ui/tooltip" import { Show, type ParentProps } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useCommand } from "@/shell/commands/command" +import { useSummaryStatus } from "./status" import "./summary.css" export function SummaryPopover( - props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>, + props: ParentProps<{ active?: boolean; directory?: string; open: boolean; onOpenChange: (open: boolean) => void }>, ) { const language = useLanguage() const command = useCommand() + const status = useSummaryStatus(() => props.directory) // Cached timelines remain mounted; only the visible summary owns the command. command.register(() => props.active === false @@ -45,7 +47,18 @@ export function SummaryPopover( > } + icon={ + + + + + + } variant="ghost-muted" size="large" state={props.open ? "pressed" : undefined} diff --git a/packages/app/src/session/summary/server-panel.tsx b/packages/app/src/session/summary/server-panel.tsx index d83b596beae1..7b13aa2b9fa3 100644 --- a/packages/app/src/session/summary/server-panel.tsx +++ b/packages/app/src/session/summary/server-panel.tsx @@ -27,7 +27,8 @@ import { showToast } from "@/shell/notifications/toast" import { pluginLabel } from "@/providers/catalog/plugin" import { useMcpToggle, type McpControls } from "@/providers/connect/mcp" import { configuredLsps } from "./configured-lsp" -import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator" +import { serviceStatusDotClass } from "./indicator" +import { useSummaryStatus } from "./status" const services = [ { type: "mcp", icon: "mcp", label: "session.summary.mcp" }, @@ -51,22 +52,11 @@ type ServiceMenuProps = { export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) { const language = useLanguage() const server = useServer() - const data = useData() const global = useGlobal() const settings = useSettings() const contentID = createUniqueId() const expanded = settings.sessionSummary.serverExpanded - const mcp = () => data.location.mcp.server.list({ directory: props.directory }) - const status = createMemo(() => { - const statuses = (mcp() ?? []).map((item) => item.status.status) - return serverStatusDotClass({ - ready: server.health?.healthy === false || mcp() !== undefined, - serverHealth: server.health?.healthy, - attention: hasServiceNeedingAttention(statuses), - issue: hasNonBlockingServiceIssue(statuses), - connecting: server.ctx.sdk.connection.status() !== "connected", - }) - }) + const status = useSummaryStatus(() => props.directory) const name = createMemo(() => { const servers = global.servers.list() if (servers.length < 2) return language.t("session.summary.server") @@ -86,7 +76,11 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m > -