Compare commits

...
3 Commits
33 changed files with 1467 additions and 920 deletions
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await page.keyboard.press("ControlOrMeta+;")
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
const toggle = dialog.getByRole("switch")
await expect(toggle).not.toBeChecked()
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
const requests: { path: string; directory: string }[] = []
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
})
await mockOpenCodeServer(page, {
directory,
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
const panel =
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
if (surface === "popover") {
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
const toggle = panel.getByRole("switch")
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await expect(toggle).toBeChecked()
await panel.getByText("figma-desktop", { exact: true }).click()
}
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
requests.length = 0
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
.getByRole("listitem", { includeHidden: true })
.filter({ has: page.getByText("Request failed", { exact: true }) })
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
await expect(toast).toBeHidden()
state.fail = false
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
if (surface === "popover") {
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page.getByRole("button", { name: "MCP", exact: true }).click()
}
if (surface === "dialog") await page.keyboard.press("Control+;")
await expect(toggle).toBeEnabled()
if (surface === "popover") {
await panel.getByText("figma-desktop", { exact: true }).click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
}
await panel.locator('[data-slot="switch-control"]').click()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
await expect(composer).toBeHidden()
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
await more.click()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
const details = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await details.getByRole("button", { name: "Close", exact: true }).click()
await expect(details).toBeHidden()
await expect(more).toBeFocused()
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
const more = page
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(drawer).not.toHaveAttribute("data-transitioning")
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
import { mockOpenCodeServer } from "../utils/mock-server"
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(review).toBeVisible()
await expect(details).toBeVisible()
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
await expect(status).toBeVisible()
await expect(status).toHaveCount(0)
const titleBounds = await header.getByRole("heading").boundingBox()
expect(titleBounds).not.toBeNull()
for (const editing of [false, true]) {
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
.toBe(true)
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await status.click()
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
await expect(mcp).toHaveAttribute("aria-selected", "true")
await plugins.click()
await expect(plugins).toHaveAttribute("aria-selected", "true")
await details.click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
await expect(mcp).toBeVisible()
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
await page.keyboard.press("Escape")
await expect(mcp).toBeHidden()
})
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const theme of ["light", "dark"] as const) {
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 800, height: 600 })
await mockStressTimeline(page)
await page.addInitScript((theme) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", theme)
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
}, theme)
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
new URL(route.request().url()).pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: Array.from({ length: 30 }, (_, index) => ({
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
status: { status: "connected" },
})),
},
})
})
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
const summary = page.locator('[data-component="session-summary-panel"]')
await summary.getByRole("button", { name: "MCP", exact: true }).click()
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
await expect(menu.getByRole("switch")).toHaveCount(30)
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const bounds = await menu.boundingBox()
return (
!!bounds &&
bounds.x >= 15 &&
bounds.y >= 15 &&
bounds.x + bounds.width <= 785 &&
bounds.y + bounds.height <= 585
)
})
.toBe(true)
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
})
}
@@ -0,0 +1,168 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
if (route.request().method() === "POST") {
expect(directory).toBe(fixture.directory)
writes.push(url.pathname)
state.enabled = url.pathname.endsWith("/connect")
return route.fulfill({ status: 204 })
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
{ name: "waiting", status: { status: "pending" } },
],
},
})
})
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 submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
const row = submenu
.locator('[data-component="switch"]')
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
await expect(toggle).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
"Sign in required",
)
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
const enabled = index % 2 !== 0
await expect(toggle).toBeEnabled()
if (target === "label") await row.getByText("figma", { exact: true }).click()
if (target === "dot") await row.locator(".session-service-dot").click()
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
if (target === "keyboard") await toggle.press("Space")
await expect(toggle).toBeChecked({ checked: enabled })
await expect(toggle).toBeEnabled()
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
await mockStressTimeline(page)
const state = { status: "disabled" }
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
state.status = "needs_auth"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
return route.fulfill({
json: {
location: { directory: fixture.directory },
data:
url.pathname === "/api/mcp/resource"
? { resources: [], templates: [] }
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
},
})
})
await page.route("**/api/integration/**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
if (route.request().method() === "POST") {
attempts.push(route.request().url())
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
})
}
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: {
id: "linear-oauth",
methods: [{ id: "oauth", type: "oauth" }],
},
},
})
})
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 submenu = page.getByRole("dialog", { name: "MCP", exact: true })
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
await expect(toggle).toBeEnabled()
const refresh = page.waitForRequest(
(request) =>
state.status === "needs_auth" &&
new URL(request.url()).pathname === "/api/mcp/resource" &&
request.method() === "GET",
)
try {
const popup = page.waitForEvent("popup")
await submenu.getByText("linear", { exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
await refresh
await expect(toggle).toBeChecked()
await expect(toggle).toHaveAccessibleDescription("Sign in required")
} finally {
resources.resolve()
}
await expect(toggle).toBeEnabled()
expect(attempts).toHaveLength(1)
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
})
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
await page.addInitScript(
({ directory, server }) => {
const current = { type: "http", http: { url: server }, displayName: "Design server" }
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
projects: { local: [{ worktree: directory, expanded: true }] },
hidden: {},
lastProject: {},
recentlyClosed: {},
}),
)
},
{
directory: fixture.directory,
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
},
)
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
})
@@ -0,0 +1,238 @@
import { expect, test } from "@playwright/test"
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
for (const layout of ["horizontal", "vertical"] as const) {
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.addInitScript((layout) => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({
...settings,
appearance: { ...settings.appearance, tabLayout: layout },
general: { ...settings.general, showStatus: true },
}),
)
}, layout)
await page.goto(stressSessionHref(fixture.targetID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
await expect(trigger).toBeEnabled()
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: "Server", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
await project.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
await expect(server).toHaveAttribute("aria-expanded", "true")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
.toEqual({ projectExpanded: false, serverExpanded: false })
await page.goto(stressSessionHref(fixture.sourceID))
await trigger.click()
await expect(project).toHaveAttribute("aria-expanded", "false")
await expect(server).toHaveAttribute("aria-expanded", "false")
await server.click()
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await expect(project).toHaveAttribute("aria-expanded", "false")
await page.keyboard.press("Escape")
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
await mockStressTimeline(page)
await page.goto(stressSessionHref(fixture.targetID))
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
if (direction === "rtl") {
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
}
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
await mcp.hover()
await expect(submenu).toHaveCount(0)
await mcp.click()
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const row = await mcp.boundingBox()
const menu = await submenu.boundingBox()
if (!row || !menu) return false
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
})
.toBe(true)
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
await expect(submenu).toBeHidden()
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
await mcp.click()
await expect(submenu).toBeHidden()
for (const [name, text] of [
["Plugins", "No plugins configured yet"],
["Skills", "No skills configured yet"],
["LSP", "No LSP servers explicitly configured"],
]) {
await summary.getByRole("button", { name, exact: true }).click()
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
await expect(submenu).toBeHidden()
}
await summary.getByRole("button", { name: "Server", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
for (const reviewOpen of [false, true]) {
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await page.getByRole("button", { name: "Session details", exact: true }).click()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
const header = await page.locator("[data-session-title]").boundingBox()
const panel = await summary.boundingBox()
if (!header || !panel) return Infinity
return direction === "ltr"
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
: Math.abs(panel.x - header.x - 12)
})
.toBeLessThanOrEqual(1)
await page.keyboard.press("Escape")
}
})
}
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
page,
}, testInfo) => {
await mockStressTimeline(page)
const state = { fail: true, extra: false }
const requests: string[] = []
await page.route("**/api/plugin**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
{
id: "supermemory",
source: { type: "package", target: "opencode-supermemory" },
features: { server: true },
state: { status: "active" },
},
{
id: "broken-plugin",
source: { type: "local", path: "/broken.ts" },
features: { server: true },
state: { status: "failed", error: "Plugin failed to activate" },
},
...(state.extra
? [
{
id: "daytona",
source: { type: "package", target: "opencode-daytona" },
features: {},
state: { status: "active" },
},
]
: []),
],
},
})
})
await page.route("**/api/skill**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
location: "/skills/review/SKILL.md",
content: "Review animations",
},
],
},
})
})
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
],
})
})
await page.goto(stressSessionHref(fixture.targetID))
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(plugins.getByRole("alert")).toContainText("Request failed")
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
await page.keyboard.press("Escape")
state.extra = true
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Skills", exact: true }).click()
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
await expect(plugins).toBeHidden()
await summary.getByRole("button", { name: "LSP", exact: true }).click()
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
})
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
).toBeVisible()
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
const status = sidebar.getByRole("button", { name: "Status", exact: true })
await expect(status).toBeVisible()
await expect
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
})
.toBe(true)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
await expect
.poll(async () => {
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(status).toHaveText("Status")
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 360 })
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
await expect(status).toBeInViewport({ ratio: 1 })
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
const content = Math.max(
0,
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
)
return element.getBoundingClientRect().height - content
}),
)
.toBe(0)
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
await scroll.evaluate((element) => element.scrollTo(0, 0))
await expect(scroll).toHaveJSProperty("scrollTop", 0)
const pinnedStatus = await status.boundingBox()
await scroll.hover()
await page.mouse.wheel(0, 200)
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await testInfo.attach(`vertical-tabs-status-${width}`, {
await expect(status).toHaveCount(0)
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
})
.toBe(true)
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
await expect(status).toHaveCount(0)
await expect(settings).toHaveCount(0)
}
await status.click()
await expect(status).toHaveAttribute("aria-expanded", "true")
await status.press("Escape")
await expect(status).toHaveAttribute("aria-expanded", "false")
})
}
+1 -4
View File
@@ -1,19 +1,17 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, untrack } from "solid-js"
import { createComposerModel } from "@/composer/model"
import { useComposerCommands } from "@/composer/commands"
import { createNewSessionComposerAdapter } from "./composer-adapter"
import { NewSessionStatus, NewSessionView } from "./view"
import { NewSessionView } from "./view"
import { createNewSessionWorkspaceController } from "./workspace/controller"
import { useNewSessionCommands } from "./commands"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
@@ -72,7 +70,6 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
-15
View File
@@ -13,8 +13,6 @@ import {
PromptProjectSelector,
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -115,19 +113,6 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
const language = useLanguage()
const dialog = useDialog()
+18 -12
View File
@@ -17,31 +17,37 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
}
return useMutation(() => ({
mutationFn: async (name: string) => {
mutationFn: async (input: string | { name: string; enabled: boolean }) => {
const name = typeof input === "string" ? input : input.name
const ref = location()
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
if (!server || server.status.status === "pending") return
if (server.status.status === "connected") {
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
if (!enabled) {
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
}
if (enabled && server.status.status !== "needs_auth") {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
await data.location.mcp.server.sync(ref)
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === name)
if (enabled && current?.status.status === "needs_auth" && current.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: current.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth") throw new Error(language.t("mcp.auth.interactiveForm", { name }))
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
integrationID: current.integrationID,
methodID: method.id,
location: ref,
})
platform.openExternal(attempt.data.url)
} else {
await serverSDK.api.mcp.connect({ server: name, location: ref })
}
data.location.mcp.server.invalidate(ref)
// Resource discovery can wait on unrelated MCPs, so start sign-in before refreshing it.
data.location.mcp.resource.invalidate(ref)
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
await Promise.all([data.location.mcp.resource.sync(ref), onSuccess?.()])
// A successful HTTP response can still leave the MCP connection in a failed state.
const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status
const status = current?.status
if (status?.status === "failed") throw new Error(`${name}: ${status.error}`)
},
onError: (error) =>
+21
View File
@@ -1385,6 +1385,27 @@ export const dict = {
"session.summary.title": "Session details",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"session.summary.server": "Server",
"session.summary.mcp": "MCP",
"session.summary.plugins": "Plugins",
"session.summary.skills": "Skills",
"session.summary.lsp": "LSP",
"session.summary.failed": "Failed",
"session.summary.retry": "Retry",
"session.summary.connecting": "Connecting…",
"session.summary.needsAuth": "Sign in required",
"session.summary.mcp.empty": "No MCP servers configured yet",
"session.summary.mcp.add": "Add servers in opencode.json",
"session.summary.plugins.manage": "Manage plugins in opencode.json",
"session.summary.plugins.empty": "No plugins configured yet",
"session.summary.plugins.add": "Add plugins in opencode.json",
"session.summary.skills.manage": "Manage skills in opencode.json",
"session.summary.skills.empty": "No skills configured yet",
"session.summary.skills.add": "Add skills in opencode.json",
"session.summary.lsp.configured": "Configured LSPs",
"session.summary.lsp.empty": "No LSP servers explicitly configured",
"session.summary.lsp.manage": "Manage LSP in opencode.json",
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create worktree",
@@ -1,30 +1,12 @@
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode/ui/tooltip"
export function SessionHeader() {
const language = useLanguage()
const settings = useSettings()
const isDesktop = createMediaQuery("(min-width: 768px)")
return (
<>
<TitlebarRight>
<Show when={isDesktop() && settings.visibility.status()}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
{/* Keep the fixed toggle's slot mounted throughout panel motion. */}
<Show when={isDesktop()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
</>
<Show when={isDesktop()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
)
}
+3 -24
View File
@@ -15,11 +15,6 @@ import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
return { default: StatusDrawer }
})
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
return { default: MobilePanelDrawer }
@@ -34,11 +29,9 @@ export function SessionMobileViewTabs(props: {
const language = useLanguage()
const [store, setStore] = createStore({
menu: false,
status: false,
statusLoaded: false,
details: false,
detailsLoaded: false,
pending: undefined as "status" | "details" | undefined,
pending: false,
})
createEffect(() => props.onDetailsOpenChange?.(store.details))
onCleanup(() => props.onDetailsOpenChange?.(false))
@@ -95,32 +88,18 @@ export function SessionMobileViewTabs(props: {
onCloseAutoFocus={(event) => {
if (!store.pending) return
event.preventDefault()
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
setStore("pending", undefined)
setStore({ details: true, detailsLoaded: true, pending: false })
}}
>
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
<Show when={props.details}>
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
<Menu.Item onSelect={() => setStore({ pending: true, menu: false })}>
{language.t("session.summary.title")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
{language.t("status.popover.trigger")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={store.statusLoaded}>
<Suspense>
<StatusDrawer
open={store.status}
onOpenChange={(open) => setStore("status", open)}
returnFocus={() => trigger}
/>
</Suspense>
</Show>
<Show when={store.detailsLoaded}>
<Suspense>
<MobilePanelDrawer
+6 -1
View File
@@ -12,7 +12,7 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode/ui/resize-handle"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
import { ComposerDropzone } from "@/composer/dropzone"
@@ -40,6 +40,11 @@ const SessionMobileFiles = lazy(async () => {
return { default: SessionMobileFiles }
})
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("./summary/panel")
return { default: SessionSummaryPanel }
})
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const server = useServer()
@@ -0,0 +1,89 @@
import { Popover } from "@kobalte/core/popover"
import { useData } from "@opencode/session-ui/context"
import { Icon } from "@opencode/ui/icon"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { createEffect, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useLanguage } from "@/runtime/i18n/language"
export type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [store, setStore] = createStore({ open: false })
createEffect(() => {
if (props.tasks.length > 0) return
setStore("open", false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={store.open}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(open) => setStore("open", open)}
>
<Show when={props.tasks.length > 0}>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="session-summary-row"
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
>
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", props.tasks.length)}
active
class="session-summary-label"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="session-service-menu"
aria-label={language.plural("session.background.tasksRunning", props.tasks.length)}
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="session-service-row"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setStore("open", false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0">{taskType(task)}</span>
<span class="session-summary-label text-v2-text-text-faint">{task.label}</span>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
import { configuredLsps } from "./configured-lsp"
test("lists configured LSP names with project overrides and no inferred built-ins", () => {
expect(
configuredLsps([
{
type: "document",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"] },
rust: { command: ["rust-analyzer"] },
},
},
},
{
type: "document",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["vscode-eslint-language-server", "--stdio"] },
},
},
},
]),
).toEqual(["eslint", "rust"])
expect(configuredLsps([{ type: "document", info: { lsp: true } }])).toEqual([])
})
test("a later whole-LSP setting clears earlier names", () => {
expect(
configuredLsps([
{ type: "document", info: { lsp: { rust: { command: ["rust-analyzer"] } } } },
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual(["custom"])
})
@@ -0,0 +1,18 @@
import type { ConfigEntry } from "@opencode/client"
// Config documents are ordered from lowest to highest priority.
export function configuredLsps(entries: readonly ConfigEntry[]) {
return entries
.reduce<string[]>((names, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return names
const lsp = entry.info.lsp
if (typeof lsp === "boolean") return []
return [
...names.filter((name) => !Object.hasOwn(lsp, name)),
...Object.entries(lsp)
.filter(([, server]) => !server.disabled)
.map(([name]) => name),
]
}, [])
.toSorted((a, b) => a.localeCompare(b))
}
+154
View File
@@ -0,0 +1,154 @@
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { getFilename } from "@opencode/util/path"
import { createUniqueId, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import type { Project } from "@/runtime/server/types"
import { useSettings } from "@/settings/model"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
import { SessionServerPanel } from "./server-panel"
import "./summary.css"
export function SessionSummaryPanel(props: {
shown?: boolean
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.projectExpanded
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
return (
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
<div>
<section class="session-summary-card" data-section="project">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-label={displayName(props.project)}
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span dir="auto" class="min-w-0 truncate">
{displayName(props.project)}
</span>
<Icon name="fill-triangle-down" size="small" class="session-summary-disclosure" />
</button>
<Show when={expanded()}>
<div id={contentID} class="session-summary-rows">
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={4}
class="session-summary-row"
>
<Icon name={props.local ? "monitor" : "outline-worktree"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="session-summary-label">
{location()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{props.branch}
</span>
</Show>
</div>
<button type="button" class="session-summary-row" onClick={props.onReview}>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</div>
</Show>
</section>
<Show when={expanded() && props.local && props.diffs?.length && props.moveEligible && !props.moveDismissed}>
<div class="session-summary-move">
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={4}
class="session-summary-row"
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class="session-summary-dismiss"
aria-label={language.t("common.dismiss")}
onClick={props.onMoveDismiss}
>
<Icon name="xmark-small" />
</button>
</div>
</Show>
</div>
<SessionServerPanel directory={props.directory} shown={props.shown !== false} mobile={props.mobile} />
</div>
)
}
@@ -0,0 +1,325 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import {
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Index,
on,
onCleanup,
Show,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { useSettings } from "@/settings/model"
import { pluginLabel } from "@/providers/catalog/plugin"
import { useMcpToggle } from "@/providers/connect/mcp"
import { configuredLsps } from "./configured-lsp"
const services = [
{ type: "mcp", icon: "mcp", label: "session.summary.mcp" },
{ type: "plugins", icon: "cube", label: "session.summary.plugins" },
{ type: "skills", icon: "post-skill", label: "session.summary.skills" },
{ type: "lsp", icon: "code", label: "session.summary.lsp" },
] as const
type Service = (typeof services)[number]["type"]
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean }) {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const contentID = createUniqueId()
const expanded = settings.sessionSummary.serverExpanded
const name = createMemo(() => {
const servers = global.servers.list()
if (servers.length < 2) return language.t("session.summary.server")
return serverName(servers.find((connection) => ServerConnection.key(connection) === server.key) ?? server.conn)
})
const [store, setStore] = createStore<{ submenu?: Service }>({})
createEffect(on([() => props.directory, () => props.shown, expanded], () => setStore("submenu", undefined)))
return (
<section class="session-summary-card" data-section="server">
<button
type="button"
class="session-summary-row session-summary-heading"
aria-expanded={expanded()}
aria-controls={contentID}
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
>
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="min-w-0 truncate">
{name()}
</span>
<Icon name="fill-triangle-down" size="small" class="session-summary-disclosure" />
</button>
<Show when={expanded()}>
<div id={contentID} class="session-summary-rows">
<For each={services}>
{(service) => (
<Popover
open={store.submenu === service.type}
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={4}
overflowPadding={16}
modal={false}
>
<Popover.Trigger as="button" type="button" class="session-summary-row">
<Icon name={service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t(service.label)}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="session-service-menu"
data-service={service.type}
aria-label={language.t(service.label)}
>
<Show when={store.submenu === service.type}>
<ServiceMenu kind={service.type} directory={props.directory} />
</Show>
</Popover.Content>
</Popover.Portal>
</Popover>
)}
</For>
</div>
</Show>
</section>
)
}
function ServiceMenu(props: { kind: Service; directory: string }) {
if (props.kind === "mcp") return <McpMenu directory={props.directory} />
if (props.kind === "lsp") return <LspMenu directory={props.directory} />
return <ServiceCatalog kind={props.kind} directory={props.directory} />
}
function LspMenu(props: { directory: string }) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [load, { refetch }] = createResource(
() => props.directory,
(directory) => {
data.location.config.invalidate({ directory })
return data.location.config.sync({ directory })
},
)
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
createEffect(() => {
onCleanup(sdk.event.location(props.directory).on("config.updated", () => void refetch()))
})
return (
<ServiceContent loading={load.loading} error={load.error} retry={refetch}>
<Show
when={names().length}
fallback={<div class="session-service-message">{language.t("session.summary.lsp.empty")}</div>}
>
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
<For each={names()}>
{(name) => (
<div class="session-service-row">
<span dir="auto" class="session-summary-label">
{name}
</span>
</div>
)}
</For>
</Show>
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
</ServiceContent>
)
}
function McpMenu(props: { directory: string }) {
const data = useData()
const language = useLanguage()
const toggle = useMcpToggle(() => props.directory)
const [load, { refetch }] = createResource(
() => props.directory,
(directory) => {
data.location.mcp.server.invalidate({ directory })
return data.location.mcp.server.sync({ directory })
},
)
const servers = createMemo(() =>
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
return (
<ServiceContent loading={load.loading} error={load.error} retry={refetch}>
<Show
when={servers().length}
fallback={
<div class="session-service-message">
<p>{language.t("session.summary.mcp.empty")}</p>
<p>{language.t("session.summary.mcp.add")}</p>
</div>
}
>
<Index each={servers()}>
{(server) => {
const enabled = () => server().status.status !== "disabled"
const pending = () => toggle.isPending || server().status.status === "pending"
const error = () => {
const status = server().status
return status.status === "failed" ? status.error : undefined
}
const label = () => {
const status = server().status.status
if (status === "failed") return language.t("session.summary.failed")
if (status === "pending") return language.t("session.summary.connecting")
if (status === "needs_auth") return language.t("session.summary.needsAuth")
return undefined
}
const change = (value: boolean) => {
if (pending()) return
toggle.mutate({ name: server().name, enabled: value })
}
return (
<Switch
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
description={label()}
checked={enabled()}
// Readonly preserves keyboard focus while the server confirms the toggle.
readOnly={pending()}
aria-disabled={pending()}
aria-busy={toggle.isPending}
onChange={change}
onClick={(event: MouseEvent) => {
// Label and control clicks already toggle; handle the row's padding here.
if (event.target === event.currentTarget) change(!enabled())
}}
title={error() ?? server().name}
>
<span class="session-service-dot" data-status={server().status.status} aria-hidden="true" />
<span dir="auto" class="session-summary-label">
{server().name}
</span>
<Show when={label()}>
{(status) => (
<span class="session-service-status" aria-hidden="true">
{status()}
</span>
)}
</Show>
</Switch>
)
}}
</Index>
</Show>
</ServiceContent>
)
}
function ServiceCatalog(props: { kind: "plugins" | "skills"; directory: string }) {
const data = useData()
const sdk = useServerSDK()
const language = useLanguage()
const [items, { refetch }] = createResource(
() => props.directory,
async (directory) => {
if (props.kind === "plugins") {
const result = await sdk.api.plugin.list({ location: { directory } })
return result.data
.filter((plugin) => plugin.source.type !== "builtin")
.map((plugin) => ({
name: pluginLabel(plugin),
status: plugin.state.status,
error: plugin.state.status === "failed" ? plugin.state.error : undefined,
}))
}
data.location.skill.invalidate({ directory })
await data.location.skill.sync({ directory })
return (data.location.skill.list({ directory }) ?? []).map((skill) => ({
name: skill.name,
status: "active",
error: undefined,
}))
},
)
const list = createMemo(() =>
(items.error ? [] : (items.latest ?? [])).toSorted((a, b) => a.name.localeCompare(b.name)),
)
createEffect(() => {
onCleanup(
sdk.event
.location(props.directory)
.on(props.kind === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
)
})
return (
<ServiceContent loading={items.loading} error={items.error} retry={refetch}>
<Show
when={list().length}
fallback={
<div class="session-service-message">
<p>
{language.t(props.kind === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty")}
</p>
<p>{language.t(props.kind === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add")}</p>
</div>
}
>
<div class="session-service-message">
{language.t(props.kind === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage")}
</div>
<For each={list()}>
{(item) => (
<div class="session-service-row" title={item.error ?? item.name}>
<span class="session-service-dot" data-status={item.status} aria-hidden="true" />
<span dir="auto" class="session-summary-label">
{item.name}
</span>
<Show when={item.status === "failed"}>
<span class="session-service-status">{language.t("session.summary.failed")}</span>
</Show>
</div>
)}
</For>
</Show>
</ServiceContent>
)
}
function ServiceContent(props: { loading: boolean; error: unknown; retry: () => unknown; children: JSX.Element }) {
const language = useLanguage()
return (
<Show
when={!props.loading}
fallback={
<div class="session-service-message" role="status">
{language.t("common.loading")}
</div>
}
>
<Show
when={!props.error}
fallback={
<div class="session-service-message" role="alert">
<p>{language.t("common.requestFailed")}</p>
<button type="button" class="session-summary-row" onClick={() => props.retry()}>
{language.t("session.summary.retry")}
</button>
</div>
}
>
{props.children}
</Show>
</Show>
)
}
@@ -0,0 +1,184 @@
[data-component="session-summary-panel"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
max-width: calc(100vw - 32px);
&[data-mobile] {
width: 100%;
max-width: none;
}
}
.session-summary-card {
position: relative;
z-index: 1;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
}
.session-summary-rows {
display: flex;
flex-direction: column;
}
.session-summary-row,
.session-service-row,
[data-component="switch"].session-mcp-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 12px;
border-radius: 4px;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
text-align: start;
}
.session-summary-row:focus-visible,
.session-mcp-row:focus-within {
outline: none;
background: var(--v2-overlay-simple-overlay-hover);
}
@media (hover: hover) {
button.session-summary-row:hover,
.session-mcp-row:not([data-disabled]):hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
.session-summary-row[data-expanded] {
background: var(--v2-overlay-simple-overlay-pressed);
}
.session-summary-heading {
color: var(--v2-text-text-muted);
font-weight: 530;
}
.session-summary-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.session-summary-disclosure {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
[aria-expanded="false"] > .session-summary-disclosure {
transform: rotate(-90deg);
}
[aria-expanded="false"]:dir(rtl) > .session-summary-disclosure {
transform: rotate(90deg);
}
.session-service-menu {
z-index: 60;
width: 280px;
max-width: calc(100vw - 32px);
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px;
border-radius: 6px;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
outline: none;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-base);
}
.session-service-message {
padding: 6px 12px;
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.session-service-message p + p {
margin-top: 4px;
}
.session-service-dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--v2-icon-icon-faint);
}
.session-service-dot[data-status="connected"],
.session-service-dot[data-status="active"] {
background: var(--icon-success-base);
}
.session-service-dot[data-status="failed"] {
background: var(--icon-critical-base);
}
.session-service-dot[data-status="needs_auth"] {
background: var(--icon-warning-base);
}
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
height: auto;
align-self: stretch;
color: inherit;
font: inherit;
letter-spacing: inherit;
}
.session-service-status {
flex-shrink: 0;
font-size: 11px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
.session-mcp-row[aria-disabled="true"] [data-slot="switch-control"] {
opacity: 0.5;
}
.session-summary-move {
position: relative;
padding-top: 6px;
margin-top: -6px;
border-radius: 0 0 6px 6px;
background: var(--v2-background-bg-layer-02);
}
.session-summary-move > .session-summary-row {
padding-inline-end: 36px;
}
.session-summary-dismiss {
position: absolute;
inset-inline-end: 8px;
bottom: 6px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
border-radius: 4px;
}
.session-summary-dismiss:hover,
.session-summary-dismiss:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
@media (max-width: 767px) {
[data-component="session-summary-panel"] .session-summary-row,
.session-service-menu .session-service-row,
.session-service-menu .session-mcp-row {
min-height: 44px;
}
}
@@ -1,5 +1,7 @@
import { DataProvider } from "@opencode/session-ui/context"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
import { BackgroundMoveHint } from "./message-timeline"
import { BackgroundWorkSummary } from "../summary/background"
import "../summary/summary.css"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
@@ -1,11 +1,19 @@
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import {
createEffect,
createMemo,
createSignal,
lazy,
on,
onCleanup,
Show,
Suspense,
type Accessor,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode/session-ui/actions"
import { useData } from "@opencode/session-ui/context"
import { Button } from "@opencode/ui/button"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { InlineInput } from "@opencode/ui/inline-input"
@@ -13,20 +21,17 @@ import { Keybind } from "@opencode/ui/keybind"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { ProjectAvatar } from "@opencode/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
import { getFilename } from "@opencode/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection"
import { Timeline } from "@opencode/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row"
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
@@ -34,13 +39,12 @@ import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
import type { BackgroundTask } from "../summary/background"
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
const SessionSummaryPanel = lazy(async () => {
const { SessionSummaryPanel } = await import("../summary/panel")
return { default: SessionSummaryPanel }
})
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
@@ -70,283 +74,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
const language = useLanguage()
const data = useData()
const [open, setOpen] = createSignal(false)
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
const presence = createAnimatedPresence(
() => (props.tasks.length > 0 ? true : undefined),
() => triggerRef() ?? null,
)
createEffect(() => {
if (props.tasks.length > 0) return
setOpen(false)
})
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={(value) => setOpen(value && props.tasks.length > 0)}
>
<Show when={presence.present()}>
<Popover.Trigger
ref={setTriggerRef}
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
classList={{
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
</Show>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
if (task.type !== "subagent" || !data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
setOpen(false)
data.navigateToSession(task.id)
}}
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</Dynamic>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
mobile?: boolean
eligible: boolean
sessionID: string
project: Project
directory: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ms-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
hidden: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={
props.mobile
? "top-end"
: inline()
? "bottom-end"
: language.direction() === "rtl"
? "right-start"
: "left-start"
}
gutter={props.mobile || inline() ? 4 : -22}
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "end-0 top-1/2"
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<Icon name="xmark-small" />
</button>
</div>
)
}
export function SessionSummaryPanel(props: {
mobile?: boolean
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs?: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
if (props.local) return language.t("session.new.workspace.local")
const workspace = workspaceDirectories(props.project).find((item) => containsDirectory(item, props.directory))
return getFilename(workspace ?? props.directory)
}
const branch = () => props.branch ?? props.baseBranch
const row =
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
{displayName(props.project)}
</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={props.mobile ? 4 : -22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span dir="auto" class="min-w-0 truncate">
{branch()}
</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<Icon name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs} fallback={<span>{language.t("session.review.loadingChanges")}</span>}>
{(diffs) => (
<Show when={diffs().length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</button>
<div
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
classList={{
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
}}
>
<div class="min-h-0 overflow-hidden">
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</div>
</div>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
mobile={props.mobile}
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
type MessageTimelineProps = {
hideHeader?: boolean
session: TimelineSessionSource
@@ -810,7 +537,17 @@ function MessageTimelineView(
<SessionContextUsage placement="bottom" />
<Show when={!parentID() && project()}>
{(project) => (
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
<Popover
open={summaryOpen()}
placement="bottom-end"
gutter={6}
overflowPadding={16}
onOpenChange={setSummary}
>
<Popover.Anchor
class="pointer-events-none absolute end-3 top-0 h-12 w-0"
aria-hidden="true"
/>
<Popover.Trigger
as={IconButton}
icon={<Icon name="window-analytics" />}
@@ -821,25 +558,31 @@ function MessageTimelineView(
aria-expanded={summaryOpen()}
/>
<Popover.Portal>
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
<Popover.Content
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
aria-label={language.t("session.summary.title")}
>
<Suspense>
<SessionSummaryPanel
shown={summaryOpen()}
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Suspense>
</Popover.Content>
</Popover.Portal>
</Popover>
@@ -382,18 +382,6 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
+12 -1
View File
@@ -34,6 +34,17 @@ describe("settings timeline detail migration", () => {
})
describe("settings schema", () => {
test("restores summary expansion and discards the retired status preference", () => {
const settings = decode({
general: { showStatus: true, showSearch: true },
sessionSummary: { projectExpanded: false, serverExpanded: true },
})
expect(settings.general.showSearch).toBe(true)
expect(settings.general).not.toHaveProperty("showStatus")
expect(settings.sessionSummary).toEqual({ projectExpanded: false, serverExpanded: true })
expect(decode(encode(settings)).sessionSummary).toEqual(settings.sessionSummary)
})
test("uses the supplied initial values independently of the current schema", () => {
const initial = {
...defaultSettings,
@@ -58,7 +69,6 @@ describe("settings schema", () => {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: timelinePresets[2].value,
@@ -69,6 +79,7 @@ describe("settings schema", () => {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: {
fontSize: 14,
mono: "",
+18 -8
View File
@@ -79,7 +79,6 @@ const generalSchema = Persistence.struct({
showFileTree: Schema.Boolean,
showNavigation: Schema.Boolean,
showSearch: Schema.Boolean,
showStatus: Schema.Boolean,
showProjectIcon: Schema.Boolean,
showTerminal: Schema.Boolean,
timelineDetail: Persistence.struct({
@@ -135,6 +134,7 @@ const soundsSchema = Persistence.struct({
export const settingsSchema = Persistence.struct({
general: generalSchema,
sessionSummary: Persistence.struct({ projectExpanded: Schema.Boolean, serverExpanded: Schema.Boolean }),
appearance: appearanceSchema,
keybinds: Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))),
permissions: permissionsSchema,
@@ -243,7 +243,6 @@ export const defaultSettings: Settings = {
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
timelineDetail: { ...timelinePresets[2].value },
@@ -254,6 +253,7 @@ export const defaultSettings: Settings = {
followUpBehavior: "steer",
experimentalBrowser: false,
},
sessionSummary: { projectExpanded: true, serverExpanded: true },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
permissions: { autoApprove: false },
@@ -280,7 +280,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
const showCustomAgents = withFallback(
() => store.general?.showCustomAgents,
defaultSettings.general.showCustomAgents,
@@ -318,10 +317,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus,
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
setShowProjectIcon(value: boolean) {
setStore("general", "showProjectIcon", value)
@@ -368,10 +363,25 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "experimentalBrowser", value)
},
},
sessionSummary: {
projectExpanded: withFallback(
() => store.sessionSummary?.projectExpanded,
defaultSettings.sessionSummary.projectExpanded,
),
serverExpanded: withFallback(
() => store.sessionSummary?.serverExpanded,
defaultSettings.sessionSummary.serverExpanded,
),
setProjectExpanded(value: boolean) {
setStore("sessionSummary", "projectExpanded", value)
},
setServerExpanded(value: boolean) {
setStore("sessionSummary", "serverExpanded", value)
},
},
visibility: {
fileTree: showFileTree,
search: showSearch,
status: showStatus,
customAgents: showCustomAgents,
},
appearance: {
-156
View File
@@ -1,156 +0,0 @@
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { pluginLabels } from "@/providers/catalog/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
const data = useData()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
return (
<div
class="flex items-center gap-1 rounded-xl"
classList={{
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
"w-full min-w-0": props.embedded,
}}
>
<Tabs
aria-label={language.t("status.popover.ariaLabel")}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-active="mcp"
defaultValue="mcp"
variant="underline"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpServers().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base": status() === "needs_auth",
}}
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name()}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
{language.t("mcp.auth.clickToAuthenticate")}
</span>
</Show>
</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name())
}}
/>
</div>
</button>
)
}}
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
</Tabs>
</div>
)
}
@@ -1,65 +0,0 @@
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, issue: false })).toBe("bg-icon-success-base")
})
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
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("pulses the neutral dot while the event stream is reconnecting", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false, connecting: true })).toBe(
"bg-border-weak-base animate-pulse",
)
// A server that is known to be down stays critical rather than looking like a routine reconnect.
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false, connecting: 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: ["connected", "pending", "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)
})
})
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,34 +0,0 @@
import type { LspStatus } from "@/runtime/server/types"
import type { McpServer } from "@opencode/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth")
}
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
}) {
return (
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
input.lsp.some((status) => status === "error")
)
}
export function serverStatusDotClass(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
connecting?: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
// The event stream is (re)connecting: keep the neutral dot but let it breathe so a stale
// session is visibly waiting on the server rather than silently frozen.
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"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
}
@@ -1,9 +0,0 @@
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
align-items: center;
justify-content: center;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-base);
}
@@ -1,36 +0,0 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
return { default: StatusPopoverBody }
})
export function StatusDrawer(props: {
open: boolean
onOpenChange: (open: boolean) => void
returnFocus?: () => HTMLElement | undefined
}) {
const language = useLanguage()
return (
<MobilePanelDrawer
title={language.t("status.popover.trigger")}
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
>
<Suspense
fallback={
<div data-slot="mobile-status-loading" role="status">
{language.t("common.loading")}
</div>
}
>
<Body shown={props.open} embedded />
</Suspense>
</MobilePanelDrawer>
)
}
@@ -1,138 +0,0 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Popover } from "@opencode/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const sidebar = () => desktop() && settings.appearance.tabLayout() === "vertical"
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
connecting: server.ctx.sdk.connection.status() !== "connected",
sidebar: sidebar(),
placement: sidebar() ? "top-start" : "bottom-end",
shift: sidebar() ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown()} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
serverHealth: boolean | undefined
attention: boolean
issue: boolean
connecting: boolean
sidebar: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: props.state.placement,
shift: props.state.shift,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={props.state.sidebar ? "button" : IconButton}
triggerProps={
props.state.sidebar
? {
type: "button",
class:
"flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02 [app-region:no-drag]",
"data-state": props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
: {
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}
}
trigger={
<>
<div class="relative size-4 shrink-0">
<Icon name={props.state.shown ? "status-active" : "status"} />
<div
data-slot="status-indicator"
class={`absolute -top-1 -end-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
/>
</div>
<Show when={props.state.sidebar}>
<span class="min-w-0 truncate">{props.state.label}</span>
</Show>
</>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
+4 -5
View File
@@ -679,12 +679,11 @@ export function Titlebar(props: {
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col gap-2">
<TitlebarRightMount vertical />
<Show when={updateState().visible}>
<Show when={updateState().visible}>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
<TitlebarUpdateIconButton state={updateState()} vertical />
</Show>
</div>
</div>
</Show>
</Portal>
)}
</Show>