diff --git a/packages/app/e2e/regression/session-summary-mcp.spec.ts b/packages/app/e2e/regression/session-summary-mcp.spec.ts index b474fcbe775b..42583fa677de 100644 --- a/packages/app/e2e/regression/session-summary-mcp.spec.ts +++ b/packages/app/e2e/regression/session-summary-mcp.spec.ts @@ -32,8 +32,18 @@ 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"]')).toHaveClass(/bg-v2-background-bg-accent/) + await trigger.click() + await expect( + page + .getByRole("dialog", { name: "Session details", exact: true }) + .getByRole("button", { name: "Extensions", exact: true }) + .locator('[data-slot="status-indicator"]'), + ).toHaveClass(/bg-icon-success-base/) + const mcp = page.getByRole("button", { name: "MCP", exact: true }) + await expect(mcp.locator(".session-summary-service-status")).toHaveClass(/bg-v2-background-bg-accent/) + 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 c8eaf19b5ca6..7452916ec410 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -71,11 +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 is healthy", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, connecting: false })).toBe("bg-icon-success-base") + }) + + test("uses the critical token when the server is down", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: false, connecting: false })).toBe("bg-icon-critical-base") + expect(serverStatusDotClass({ ready: true, serverHealth: false, connecting: true })).toBe("bg-icon-critical-base") + }) + + test("pulses the neutral dot while reconnecting", () => { + expect(serverStatusDotClass({ ready: true, serverHealth: true, connecting: true })).toBe( + "bg-border-weak-base animate-pulse", + ) + }) + + test("stays neutral before status is ready", () => { + expect(serverStatusDotClass({ ready: false, serverHealth: true, connecting: false })).toBe("bg-border-weak-base") + expect(serverStatusDotClass({ ready: false, serverHealth: undefined, 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) + }) + + 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 }).trigger).toBe( + undefined, + ) + expect(summaryStatus({ ready: true, serverHealth: true, mcp: ["needs_auth"], connecting: false })).toMatchObject({ + server: "bg-icon-success-base", + mcp: "bg-v2-background-bg-accent", + trigger: "bg-v2-background-bg-accent", + }) + expect(summaryStatus({ ready: true, serverHealth: false, mcp: [], connecting: false })).toMatchObject({ + server: "bg-icon-critical-base", + trigger: "bg-icon-critical-base", + }) + }) +}) diff --git a/packages/app/src/session/summary/indicator.ts b/packages/app/src/session/summary/indicator.ts new file mode 100644 index 000000000000..8b4b9fb5de1a --- /dev/null +++ b/packages/app/src/session/summary/indicator.ts @@ -0,0 +1,44 @@ +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 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 + 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" + return "bg-icon-success-base" +} + +export function summaryStatus(input: { + ready: boolean + serverHealth: boolean | undefined + mcp: Array + connecting: boolean +}) { + const mcp = serviceStatusDotClass(input.mcp) + const server = serverStatusDotClass({ + ready: input.ready, + serverHealth: input.serverHealth, + connecting: input.connecting, + }) + return { + server, + mcp, + trigger: input.serverHealth === false ? server : mcp, + } +} diff --git a/packages/app/src/session/summary/popover.tsx b/packages/app/src/session/summary/popover.tsx index a49eaede070c..fccb4744d4dd 100644 --- a/packages/app/src/session/summary/popover.tsx +++ b/packages/app/src/session/summary/popover.tsx @@ -3,16 +3,30 @@ import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Keybind } from "@opencode/ui/keybind" import { Tooltip } from "@opencode/ui/tooltip" -import { Show, type ParentProps } from "solid-js" +import { createResource, Show, type ParentProps } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" +import { useData, useServer } from "@/runtime/server/current" 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 data = useData() + const server = useServer() + const status = useSummaryStatus(() => props.directory) + createResource( + () => { + const directory = props.directory + if (props.active === false || !directory || server.ctx.sdk.connection.status() !== "connected") return + if (data.location.mcp.server.list({ directory }) !== undefined) return + return directory + }, + (directory) => data.location.mcp.server.sync({ directory }), + ) // Cached timelines remain mounted; only the visible summary owns the command. command.register(() => props.active === false @@ -45,7 +59,20 @@ export function SummaryPopover( > } + icon={ + + + + {(trigger) => ( + + + } 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 6e8e1e4626c0..7b13aa2b9fa3 100644 --- a/packages/app/src/session/summary/server-panel.tsx +++ b/packages/app/src/session/summary/server-panel.tsx @@ -27,6 +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 { serviceStatusDotClass } from "./indicator" +import { useSummaryStatus } from "./status" const services = [ { type: "mcp", icon: "mcp", label: "session.summary.mcp" }, @@ -54,6 +56,7 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m const settings = useSettings() const contentID = createUniqueId() const expanded = settings.sessionSummary.serverExpanded + const status = useSummaryStatus(() => props.directory) const name = createMemo(() => { const servers = global.servers.list() if (servers.length < 2) return language.t("session.summary.server") @@ -71,7 +74,14 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m aria-controls={contentID} onClick={() => settings.sessionSummary.setServerExpanded(!expanded())} > - + + + {name()} @@ -173,6 +183,7 @@ function McpMenu(props: ServiceMenuProps) { a.name.localeCompare(b.name), ), ) + const status = createMemo(() => serviceStatusDotClass(servers().map((server) => server.status.status))) const defaults = createMemo(() => Object.fromEntries( (data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) => @@ -186,6 +197,7 @@ function McpMenu(props: ServiceMenuProps) { return ( unknown children: JSX.Element + status?: string }, ) { const language = useLanguage() @@ -397,7 +410,12 @@ function ServicePopover( modal={false} > - + + + + {(status) => + {language.t(props.service.label)} diff --git a/packages/app/src/session/summary/status.ts b/packages/app/src/session/summary/status.ts new file mode 100644 index 000000000000..a953b1723120 --- /dev/null +++ b/packages/app/src/session/summary/status.ts @@ -0,0 +1,23 @@ +import { createMemo, type Accessor } from "solid-js" +import { useData, useServer } from "@/runtime/server/current" +import { summaryStatus } from "./indicator" + +export function useSummaryStatus(directory: Accessor) { + const data = useData() + const server = useServer() + const mcp = () => { + const value = directory() + if (!value) return + return data.location.mcp.server.list({ directory: value }) + } + return createMemo(() => { + const health = server.health?.healthy + const servers = mcp() + return summaryStatus({ + ready: health === false || servers !== undefined, + serverHealth: health, + mcp: (servers ?? []).map((item) => item.status.status), + connecting: server.ctx.sdk.connection.status() !== "connected", + }) + }) +} diff --git a/packages/app/src/session/summary/summary.css b/packages/app/src/session/summary/summary.css index f116aa61f8be..f60bdfb136cb 100644 --- a/packages/app/src/session/summary/summary.css +++ b/packages/app/src/session/summary/summary.css @@ -108,6 +108,31 @@ flex-shrink: 0; color: var(--v2-icon-icon-muted); } +.session-summary-trigger-icon, +.session-summary-server-icon, +.session-summary-service-icon { + position: relative; + width: 16px; + height: 16px; + flex-shrink: 0; +} +.session-summary-trigger-status, +.session-summary-server-status, +.session-summary-service-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%; +} +.session-summary-trigger-status { + border-color: var(--v2-background-bg-deep); +} +[data-color-scheme="dark"] :is(.session-summary-server-status, .session-summary-service-status) { + border-color: var(--v2-background-bg-layer-01); +} .session-summary-heading[aria-expanded="false"] .session-summary-disclosure { transform: rotate(-90deg); } diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index be777019cc3b..77c4f3a515cb 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -546,7 +546,12 @@ function MessageTimelineView( {(project) => ( - +