Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/app/e2e/regression/session-summary-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/app/e2e/regression/session-summary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/new-session/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
63 changes: 63 additions & 0 deletions packages/app/src/session/summary/indicator.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
44 changes: 44 additions & 0 deletions packages/app/src/session/summary/indicator.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
33 changes: 30 additions & 3 deletions packages/app/src/session/summary/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
22 changes: 20 additions & 2 deletions packages/app/src/session/summary/server-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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")
Expand All @@ -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>
Expand Down Expand Up @@ -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) =>
Expand All @@ -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 &&
Expand Down Expand Up @@ -378,6 +390,7 @@ function ServicePopover(
error: unknown
retry: () => unknown
children: JSX.Element
status?: string
},
) {
const language = useLanguage()
Expand All @@ -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>
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/session/summary/status.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
}
25 changes: 25 additions & 0 deletions packages/app/src/session/summary/summary.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
Loading