Compare commits

...
10 changed files with 233 additions and 9 deletions
@@ -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
@@ -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]) {
+5 -1
View File
@@ -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"
>
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
<SummaryPopover
directory={props.project.selected() ? props.mcp.directory() : undefined}
open={store.summary}
onOpenChange={(open) => setStore("summary", open)}
>
<Suspense>
<NewSessionSummary
project={props.project.selected()}
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
serviceStatusDotClass,
summaryStatus,
} from "./indicator"
describe("serverStatusDotClass", () => {
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",
})
})
})
@@ -0,0 +1,44 @@
import type { McpServer } from "@opencode/client/promise"
export function hasServiceNeedingAttention(statuses: Array<McpServer["status"]["status"]>) {
return statuses.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(statuses: Array<McpServer["status"]["status"]>) {
return statuses.some((status) => status !== "connected" && status !== "pending" && status !== "disabled")
}
export function serviceStatusDotClass(statuses: Array<McpServer["status"]["status"]>) {
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<McpServer["status"]["status"]>
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,
}
}
+30 -3
View File
@@ -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(
>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
icon={
<span class="session-summary-trigger-icon">
<Icon name="window-analytics" />
<Show when={status().trigger}>
{(trigger) => (
<span
data-slot="status-indicator"
class={`session-summary-trigger-status ${trigger()}`}
aria-hidden="true"
/>
)}
</Show>
</span>
}
variant="ghost-muted"
size="large"
state={props.open ? "pressed" : undefined}
@@ -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())}
>
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-server-icon">
<Icon name="server" class="text-v2-icon-icon-muted" />
<span
data-slot="status-indicator"
class={`session-summary-server-status ${status().server}`}
aria-hidden="true"
/>
</span>
<span dir="auto" class="session-summary-label">
{name()}
</span>
@@ -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 (
<ServicePopover
{...props}
status={status()}
loading={load.loading}
ready={
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
@@ -378,6 +390,7 @@ function ServicePopover(
error: unknown
retry: () => unknown
children: JSX.Element
status?: string
},
) {
const language = useLanguage()
@@ -397,7 +410,12 @@ function ServicePopover(
modal={false}
>
<Popover.Trigger as="button" type="button" class="session-summary-row">
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-service-icon">
<Icon name={props.service.icon} class="text-v2-icon-icon-muted" />
<Show when={props.status}>
{(status) => <span class={`session-summary-service-status ${status()}`} aria-hidden="true" />}
</Show>
</span>
<span class="session-summary-label">{language.t(props.service.label)}</span>
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
@@ -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<string | undefined>) {
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",
})
})
}
@@ -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);
}
@@ -546,7 +546,12 @@ function MessageTimelineView(
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<SummaryPopover active={props.active} open={summaryOpen()} onOpenChange={setSummary}>
<SummaryPopover
active={props.active}
directory={sessionDirectory()}
open={summaryOpen()}
onOpenChange={setSummary}
>
<Suspense>
<SessionSummaryPanel
shown={summaryOpen()}