Compare commits

..
3 Commits
18 changed files with 490 additions and 115 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
"x86_64-linux": "sha256-QJn59dTbxspHl35vGxs6akynlfaWaPqvbMNsi0EqnbM=",
"aarch64-linux": "sha256-wa54uUIAth/ZOyejZ/TfU0zZdkhNirwq7yK1218jhBQ=",
"aarch64-darwin": "sha256-VvsfVdtxcVijCvlrWpBUkgmkYFoXX7QiXM0Fhz1t2VE=",
"x86_64-darwin": "sha256-IxYFBG5I+znXcTVqrAWKhG2qvVtjM+Amp68xIGKZogE="
}
}
@@ -33,19 +33,22 @@ for (const rtl of [false, true]) {
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect
.poll(async () => {
const view = await page.locator('[data-component="new-session"]').boundingBox()
const button = await trigger.boundingBox()
const project = await summary.locator('[data-section="project"]').boundingBox()
const server = await summary.locator('[data-section="server"]').boundingBox()
if (!view || !project || !server) return
if (!button || !project || !server) return
return {
top: project.y - view.y - 48,
top: project.y - button.y - button.height,
cards: server.y - project.y - project.height,
}
})
.toEqual({ top: 6, cards: 8 })
.toEqual({ top: 12, cards: 8 })
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
body: await page.screenshot(),
contentType: "image/png",
@@ -0,0 +1,99 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
const server = "http://summary-remote.test:4096"
const path = "/home/remote/.config/opencode/opencode.jsonc"
test.use({ serviceWorkers: "block", permissions: ["clipboard-read", "clipboard-write"] })
for (const service of [
{ name: "MCP", config: { mcp: { servers: {} } } },
{ name: "Plugins", config: { plugins: [] } },
{ name: "Skills", config: { skills: [] } },
{ name: "LSP", config: { lsp: false } },
]) {
test(`remote ${service.name} copies its configuration path with timeline copy feedback`, async ({ page }) => {
await setup(page)
await page.route("**/api/config**", (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: [
{ type: "document", path, info: service.config },
{ type: "document", path: `${fixture.directory}/opencode.json`, info: {} },
{ type: "document", path: `${fixture.directory}/.opencode/agents/review.md`, info: {} },
],
})
})
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: service.name, exact: true })
.click()
const menu = page.getByRole("dialog", { name: service.name, exact: true })
const copy = menu.getByRole("button", { name: "Copy configuration file path", exact: true })
const tooltipOffset = async () => {
const icon = await copy.locator("svg").boundingBox()
const tooltip = await page.getByRole("tooltip").boundingBox()
if (!icon || !tooltip) return Infinity
return Math.abs(tooltip.x + tooltip.width / 2 - icon.x - icon.width / 2)
}
await expect(copy).toBeEnabled()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
await expect(copy.locator(".session-service-config-arrow")).toHaveCount(0)
await copy.hover()
await expect(page.getByRole("tooltip")).toHaveText("Copy")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await copy.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(path)
await expect(page.getByRole("tooltip")).toHaveText("Copied")
await expect.poll(tooltipOffset).toBeLessThanOrEqual(1)
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-check")
await expect(menu).toBeVisible()
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
}
test("remote configuration without a file path reports the problem instead of copying a directory", async ({
page,
}) => {
await setup(page)
await page.goto(`/server/${base64Encode(server)}/session/${fixture.targetID}`)
await page.evaluate(() => navigator.clipboard.writeText("original clipboard"))
await page.getByRole("button", { name: "Session details", exact: true }).click()
await page
.getByRole("dialog", { name: "Session details", exact: true })
.getByRole("button", { name: "Skills", exact: true })
.click()
const copy = page
.getByRole("dialog", { name: "Skills", exact: true })
.getByRole("button", { name: "Copy configuration file path", exact: true })
await copy.click()
await expect(page.getByText("No configuration file found", { exact: true })).toBeVisible()
await expect(copy).toBeEnabled()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("original clipboard")
await expect(copy.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-outline-copy")
})
async function setup(page: Page) {
await mockOpenCodeServer(page, {
server,
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((server) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
list: [{ type: "http", http: { url: server }, displayName: "Remote server" }],
projects: {},
lastProject: {},
}),
)
}, server)
}
@@ -94,7 +94,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(trigger).toBeFocused()
await trigger.click()
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
const shell = 1440 - (await panel.boundingBox())!.width
await page.setViewportSize({ width: 1320 + shell, height: 900 })
@@ -124,6 +124,53 @@ for (const direction of ["ltr", "rtl"] as const) {
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`summary truncates long copy before the indicator column in ${direction}`, async ({ page }) => {
const branch = `feature/${"long-branch-name-".repeat(12)}`
await mockStressTimeline(page)
await page.route(
(url) => url.pathname === "/api/vcs",
(route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
return route.fulfill({
json: { location: { directory: fixture.directory }, data: { branch: { current: branch, default: "main" } } },
})
},
)
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
await expect(page.locator("html")).toHaveAttribute("dir", direction)
await expect(page.locator("html")).toHaveAttribute("lang", "en")
await page.getByRole("button", { name: "Session details", exact: true }).click()
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
const text = summary.getByText(branch, { exact: true })
await expect(text).toBeVisible()
await expect(text).toHaveCSS("text-overflow", "ellipsis")
await expect.poll(() => text.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const arrow = summary
.getByRole("button", { name: "Local repository", exact: true })
.locator(".session-summary-menu-indicator")
await expect
.poll(async () => {
const label = await text.boundingBox()
const icon = await arrow.boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
for (const name of ["Local repository", "MCP", "Plugins", "Skills", "LSP"]) {
const row = summary.getByRole("button", { name, exact: true })
await expect
.poll(async () => {
const label = await row.locator(".session-summary-label").boundingBox()
const icon = await row.locator(".session-summary-menu-indicator").boundingBox()
if (!label || !icon) return 0
return direction === "ltr" ? icon.x - label.x - label.width : label.x - icon.x - icon.width
})
.toBeGreaterThanOrEqual(12)
}
})
}
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 })
@@ -164,5 +164,5 @@ test("multiple desktop connections show the session's server name", async ({ pag
"aria-expanded",
"true",
)
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveCount(0)
})
@@ -3,10 +3,10 @@ import { fixture } from "../performance/timeline/session-timeline-stress.fixture
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
const services = [
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured", item: "summary-mcp" },
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured", item: "summary-plugin" },
{ name: "Skills", path: "/api/skill", empty: "No skills configured", item: "summary-skill" },
{ name: "LSP", path: "/api/config", empty: "No LSP servers configured", item: "summary-lsp" },
] as const
for (const service of services) {
@@ -56,15 +56,16 @@ for (const service of services) {
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
await expect(content).toBeVisible()
await expect(menu).toHaveAttribute("aria-busy", "false")
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
if (empty) {
const message = menu.locator(".session-service-empty")
await expect(message).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("gap", "8px")
await expect(message).toHaveCSS("font-size", "11px")
await expect(message).toHaveCSS("padding", "0px")
await expect(message).toHaveCSS("gap", "0px")
await expect(message.locator("strong")).toHaveCSS("padding", "8px 12px")
await expect(message).toHaveCSS("font-size", "13px")
await expect(message).toHaveCSS("line-height", "16px")
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
await expect(message.locator(".session-service-footer")).toHaveCSS("font-weight", "440")
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
}
await page.keyboard.press("Escape")
@@ -79,7 +80,7 @@ for (const service of services) {
await expect(menu).toHaveAttribute("aria-busy", "true")
await expect(content).toBeVisible()
await expect(menu.getByRole("status")).toHaveCount(0)
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
await expect(menu).toHaveCSS("width", empty ? "232px" : "280px")
await expect(summary).toBeVisible()
} finally {
response.resolve()
@@ -110,16 +111,16 @@ test("prefetching plugins does not suspend the summary or report an empty catalo
try {
await expect.poll(() => state.requested).toBe(true)
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
await expect(menu.getByRole("status")).toContainText("Loading")
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
await expect(menu.getByText("No plugins configured", { exact: true })).toHaveCount(0)
await expect(summary).toBeVisible()
} finally {
response.resolve()
}
await expect(
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured", { exact: true }),
).toBeVisible()
})
@@ -42,7 +42,7 @@ for (const custom of [false, true]) {
).toBeInViewport()
await page.keyboard.press(shortcut)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toBeVisible()
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
await expect(tooltip).toBeHidden()
await page.keyboard.press(shortcut)
@@ -74,14 +74,14 @@ for (const layout of ["horizontal", "vertical"] as const) {
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 })
const server = summary.getByRole("button", { name: "Extensions", exact: true })
await expect(project).toHaveAttribute("aria-expanded", "true")
await expect(server).toHaveAttribute("aria-expanded", "true")
for (const heading of [project, server]) {
await expect(heading).toHaveCSS("column-gap", "8px")
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
await expect(heading.locator(".session-summary-label")).toHaveCSS("flex-grow", "0")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "14")
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "14")
}
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
@@ -134,7 +134,7 @@ for (const direction of ["ltr", "rtl"] as const) {
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(submenu.getByText("No MCP servers configured", { exact: true })).toBeVisible()
await expect(summary).toBeVisible()
await expect
.poll(async () => {
@@ -150,20 +150,20 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await expect(mcp).toBeFocused()
await mcp.press("Enter")
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
await expect(submenu.getByText("Configuration file")).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"],
["Plugins", "No plugins configured"],
["Skills", "No skills configured"],
["LSP", "No LSP servers 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 summary.getByRole("button", { name: "Extensions", exact: true }).click()
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
await page.keyboard.press("Escape")
await expect(summary).toBeHidden()
@@ -269,7 +269,7 @@ test("catalog submenus show project plugins and skills, refresh on reopen, and d
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)
await expect(plugins.getByText("No plugins configured", { exact: true })).toHaveCount(0)
state.fail = false
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
+4 -1
View File
@@ -6,6 +6,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { MockApi, MockBadRequest, MockNotFound } from "./mock-api"
export interface MockServerConfig {
server?: string
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
@@ -50,7 +51,9 @@ type MockStreamWindow = Window & {
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const server =
config.server ??
`http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ server, retry }) => {
+1 -1
View File
@@ -24,7 +24,7 @@ export function NewSessionSummary(props: {
<div class="session-summary-card">
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
<Icon name="folder" class="text-v2-icon-icon-muted" />
{language.t("session.summary.chooseProject")}
<span class="session-summary-label">{language.t("session.summary.chooseProject")}</span>
</button>
</div>
}
@@ -127,7 +127,7 @@ export function PromptWorkspaceSelector(props: {
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
@@ -294,7 +294,7 @@ export function PromptWorkspaceSelector(props: {
<Icon
name={summary() ? "fill-triangle-down" : "chevron-down"}
size={summary() ? "normal" : "small"}
class="shrink-0 text-v2-icon-icon-muted"
class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted"
/>
</Menu.Trigger>
<Menu.Portal>
+11 -5
View File
@@ -1431,7 +1431,7 @@ export const dict = {
"session.summary.tooltip": "Summary",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"session.summary.server": "Server",
"session.summary.server": "Extensions",
"session.summary.chooseProject": "Choose a project",
"session.summary.mcp.onCreation": "Applies when the worktree is created",
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
@@ -1439,23 +1439,29 @@ export const dict = {
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
"session.summary.mcp": "MCP",
"session.summary.mcp.title": "Configured MCP servers",
"session.summary.plugins": "Plugins",
"session.summary.plugins.configured": "Configured plugins",
"session.summary.skills": "Skills",
"session.summary.skills.configured": "Configured 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.configure": "Configuration file",
"session.summary.copyConfigPath": "Copy configuration file path",
"session.summary.configFileMissing": "No configuration file found",
"session.summary.mcp.empty": "No MCP servers configured",
"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.empty": "No plugins configured",
"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.empty": "No skills configured",
"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.empty": "No LSP servers configured",
"session.summary.lsp.manage": "Manage LSP in opencode.json",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
+24 -10
View File
@@ -58,7 +58,7 @@ export function SessionSummaryPanel(props: {
<span dir="auto" class="session-summary-label">
{location()}
</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class="session-summary-row">
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
@@ -87,15 +87,29 @@ export function SessionSummaryPanel(props: {
</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>
<span class="session-summary-label flex items-center gap-2">
<Show
when={props.diffs}
fallback={
<span class="truncate text-v2-text-text-muted">{language.t("session.review.loadingChanges")}</span>
}
>
{(diffs) => (
<Show
when={diffs().length > 0}
fallback={
<span class="truncate text-v2-text-text-muted">{language.t("session.review.noChanges")}</span>
}
>
<span class="min-w-0 truncate">
{language.plural("ui.sessionTurn.diffs.changed", diffs().length)}
</span>
<span class="shrink-0 text-v2-text-text-muted">·</span>
<DiffChanges appearance="standard" changes={diffs()} />
</Show>
)}
</Show>
</span>
</button>
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
</ProjectSummaryCard>
+5 -3
View File
@@ -6,6 +6,7 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { Show, type ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import "./summary.css"
export function SummaryPopover(
props: ParentProps<{ active?: boolean; open: boolean; onOpenChange: (open: boolean) => void }>,
@@ -28,8 +29,9 @@ export function SummaryPopover(
)
const keybind = () => command.keybindParts("session.summary.toggle")
return (
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
<Popover open={props.open} placement="bottom-end" gutter={8} overflowPadding={16} onOpenChange={props.onOpenChange}>
{/* Match the button's vertical bounds; the 8px gutter plus 4px content padding gives a 12px card gap. */}
<Popover.Anchor class="pointer-events-none absolute end-3 top-2.5 h-7 w-0" aria-hidden="true" />
<Tooltip
placement="bottom"
value={
@@ -53,7 +55,7 @@ export function SummaryPopover(
</Tooltip>
<Popover.Portal>
<Popover.Content
class="z-50 max-h-[calc(100dvh-96px)] overflow-y-auto border-0 bg-transparent p-1 outline-none"
class="session-summary-popover 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")}
>
{props.children}
@@ -33,12 +33,10 @@ export function ProjectSummaryCard(
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{displayName(props.project)}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
<span dir="auto" class="session-summary-label">
{displayName(props.project)}
</span>
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
</button>
<Show when={expanded()}>
<div id={contentID} class="session-summary-rows">
+121 -26
View File
@@ -1,6 +1,8 @@
import { Popover } from "@kobalte/core/popover"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { getDirectory } from "@opencode/util/path"
import {
createEffect,
createMemo,
@@ -15,11 +17,13 @@ import {
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
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 { showToast } from "@/shell/notifications/toast"
import { pluginLabel } from "@/providers/catalog/plugin"
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
import { configuredLsps } from "./configured-lsp"
@@ -27,8 +31,8 @@ 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" },
{ type: "skills", icon: "graduation-cap", label: "session.summary.skills" },
{ type: "lsp", icon: "code-slash", label: "session.summary.lsp" },
] as const
type Service = (typeof services)[number]["type"]
@@ -68,12 +72,10 @@ export function SessionServerPanel(props: { directory: string; shown: boolean; m
onClick={() => settings.sessionSummary.setServerExpanded(!expanded())}
>
<Icon name="server" class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-heading-label">
<span dir="auto" class="min-w-0 truncate">
{name()}
</span>
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
<span dir="auto" class="session-summary-label">
{name()}
</span>
<Icon name="chevron-down" size="small" class="session-summary-disclosure" />
</button>
<Show when={expanded() ? props.directory : undefined} keyed>
{(directory) => (
@@ -131,13 +133,10 @@ function LspMenu(props: ServiceMenuProps) {
<Show
when={names().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.lsp.empty")}
description={language.t("session.summary.lsp.manage")}
/>
<ServiceEmpty title={language.t("session.summary.lsp.empty")} directory={props.directory} service="lsp" />
}
>
<div class="session-service-message">{language.t("session.summary.lsp.configured")}</div>
<h3 class="session-service-title">{language.t("session.summary.lsp.configured")}</h3>
<For each={names()}>
{(name) => (
<div class="session-service-row">
@@ -147,7 +146,9 @@ function LspMenu(props: ServiceMenuProps) {
</div>
)}
</For>
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
<div class="session-service-footer">
<ServiceConfigLink directory={props.directory} service="lsp" />
</div>
</Show>
</ServicePopover>
)
@@ -197,12 +198,10 @@ function McpMenu(props: ServiceMenuProps) {
<Show
when={servers().length}
fallback={
<ServiceEmpty
title={language.t("session.summary.mcp.empty")}
description={language.t("session.summary.mcp.add")}
/>
<ServiceEmpty title={language.t("session.summary.mcp.empty")} directory={props.directory} service="mcp" />
}
>
<h3 class="session-service-title">{language.t("session.summary.mcp.title")}</h3>
<Show when={props.mcp?.preview}>
<div class="session-service-message" data-slot="mcp-preview-hint">
{language.t("session.summary.mcp.onCreation")}
@@ -267,6 +266,9 @@ function McpMenu(props: ServiceMenuProps) {
)
}}
</Index>
<div class="session-service-footer">
<ServiceConfigLink directory={props.directory} service="mcp" />
</div>
</Show>
</ServicePopover>
)
@@ -335,17 +337,18 @@ function ServiceCatalog(props: ServiceMenuProps) {
title={language.t(
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
)}
description={language.t(
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
)}
directory={props.directory}
service={props.service.type}
/>
}
>
<div class="session-service-message">
<h3 class="session-service-title">
{language.t(
props.service.type === "plugins" ? "session.summary.plugins.manage" : "session.summary.skills.manage",
props.service.type === "plugins"
? "session.summary.plugins.configured"
: "session.summary.skills.configured",
)}
</div>
</h3>
<For each={list()}>
{(item) => (
<div class="session-service-row" title={item.error ?? item.name}>
@@ -359,6 +362,9 @@ function ServiceCatalog(props: ServiceMenuProps) {
</div>
)}
</For>
<div class="session-service-footer">
<ServiceConfigLink directory={props.directory} service={props.service.type} />
</div>
</Show>
</ServicePopover>
)
@@ -393,7 +399,7 @@ function ServicePopover(
<Popover.Trigger as="button" type="button" class="session-summary-row">
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="session-summary-label">{language.t(props.service.label)}</span>
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
<Icon name="fill-triangle-down" class="session-summary-menu-indicator shrink-0 text-v2-icon-icon-muted" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
@@ -431,11 +437,100 @@ function ServicePopover(
)
}
function ServiceEmpty(props: { title: string; description: string }) {
function ServiceConfigLink(props: { directory: string; service: Service }) {
const language = useLanguage()
const platform = usePlatform()
const server = useServer()
const sdk = useServerSDK()
const [store, setStore] = createStore({ opening: false, copied: false })
const label = () => language.t(server.isLocal ? "session.summary.configure" : "session.summary.copyConfigPath")
createEffect(() => {
if (!store.copied) return
const timeout = setTimeout(() => setStore("copied", false), 2000)
onCleanup(() => clearTimeout(timeout))
})
const activate = async () => {
const revealPath = platform.revealPath
if (store.opening || (server.isLocal && !revealPath)) return
setStore({ opening: true, copied: false })
const directory = props.directory
await sdk.api.config
.get({ location: { directory } })
.then(async (entries) => {
const documents = entries
.filter((entry) => entry.type === "document")
.filter((entry) => entry.path !== undefined && /\.jsonc?$/.test(entry.path))
const path =
documents.findLast((entry) => entry.info[props.service] !== undefined)?.path ?? documents.at(-1)?.path
if (!server.isLocal) {
if (!path) throw new Error(language.t("session.summary.configFileMissing"))
await (platform.writeClipboardText?.(path) ?? navigator.clipboard.writeText(path))
setStore("copied", true)
return
}
if (path && (await revealPath?.(path))) return
await platform.openPath?.(path ? getDirectory(path) : directory)
})
.catch((error: unknown) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
)
.finally(() => setStore("opening", false))
}
return (
<>
<span class="session-service-config-separator" role="separator" />
<Show
when={!server.isLocal || platform.revealPath}
fallback={
<span class="session-service-row">
<Icon name="settings-gear" class="shrink-0 text-v2-icon-icon-muted" />
{label()}
</span>
}
>
<Tooltip
inactive={server.isLocal}
value={language.t(store.copied ? "ui.message.copied" : "ui.message.copy")}
placement="top"
getAnchorRect={(anchor) => anchor?.querySelector("svg")?.getBoundingClientRect()}
forceOpen={store.copied ? true : undefined}
class="w-full"
>
<button
type="button"
class="session-service-config"
disabled={store.opening}
onMouseDown={(event) => {
if (!server.isLocal) event.preventDefault()
}}
onClick={() => void activate()}
>
<Icon
name={server.isLocal ? "settings-gear" : store.copied ? "check" : "outline-copy"}
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="session-summary-label">{label()}</span>
<Show when={server.isLocal}>
<Icon name="arrow-up-right" class="session-service-config-arrow shrink-0" />
</Show>
</button>
</Tooltip>
</Show>
</>
)
}
function ServiceEmpty(props: { title: string; directory: string; service: Service }) {
return (
<div class="session-service-empty">
<strong>{props.title}</strong>
<p>{props.description}</p>
<div class="session-service-footer">
<ServiceConfigLink directory={props.directory} service={props.service} />
</div>
</div>
)
}
+123 -26
View File
@@ -1,3 +1,8 @@
.session-summary-popover {
transform-origin: var(--kb-popover-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
}
[data-component="session-summary-panel"] {
display: flex;
flex-direction: column;
@@ -14,7 +19,7 @@
.session-summary-card {
position: relative;
z-index: 1;
padding: 4px 2px;
padding: 2px;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
@@ -24,13 +29,16 @@
background: var(--v2-background-bg-layer-01);
}
.session-summary-card,
.session-summary-rows {
display: flex;
flex-direction: column;
gap: 2px;
}
.session-summary-row,
.session-service-row,
.session-service-config,
[data-component="switch"].session-mcp-row {
display: flex;
align-items: center;
@@ -46,7 +54,22 @@
text-align: start;
}
.session-summary-card .session-summary-row {
/* Keep copy out of the trailing indicator column, including rows without an indicator. */
padding-inline-end: 36px;
}
.session-summary-card .session-summary-row:has(> .session-summary-menu-indicator) {
/* Align trailing 16px indicators with the move row's 20px dismiss button. */
padding-inline-end: 8px;
}
.session-summary-card .session-summary-menu-indicator {
margin-inline-start: 4px;
}
.session-summary-row:focus-visible,
.session-service-config:focus-visible,
.session-mcp-row:focus-within {
outline: none;
background: var(--v2-overlay-simple-overlay-hover);
@@ -54,6 +77,8 @@
@media (hover: hover) {
button.session-summary-row:hover,
.session-service-config:not(:disabled):hover,
.session-summary-move:has(> .session-summary-dismiss:hover) > .session-summary-row,
.session-mcp-row:not([data-disabled]):hover {
background: var(--v2-overlay-simple-overlay-hover);
}
@@ -68,13 +93,6 @@
font-weight: 530;
}
.session-summary-heading-label {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.session-summary-label {
flex: 1;
min-width: 0;
@@ -83,6 +101,9 @@
white-space: nowrap;
text-align: start;
}
.session-summary-heading .session-summary-label {
flex: 0 1 auto;
}
.session-summary-disclosure {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
@@ -101,35 +122,59 @@
max-height: min(480px, calc(100dvh - 32px));
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px;
padding: 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);
font-weight: 440;
line-height: var(--line-height-compact);
transform-origin: var(--kb-popover-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
&[data-empty] {
width: 200px;
padding: 0;
width: 232px;
}
}
.session-service-menu .session-summary-row,
.session-service-menu .session-service-row,
.session-service-menu .session-service-config,
.session-service-menu [data-component="switch"].session-mcp-row {
font-size: inherit;
line-height: inherit;
}
.session-service-menu > :is(.session-service-row, .session-mcp-row) + :is(.session-service-row, .session-mcp-row) {
margin-block-start: 2px;
}
.session-service-title {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
color: var(--v2-text-text-faint);
font-size: inherit;
font-weight: 530;
letter-spacing: 0.05px;
}
.session-service-empty {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 12px;
gap: 0;
padding: 0;
color: var(--v2-text-text-faint);
font-size: 11px;
font-size: inherit;
font-weight: 440;
line-height: var(--line-height-compact);
overflow-wrap: anywhere;
strong {
padding: 8px 12px;
font-weight: 530;
}
}
@@ -139,28 +184,72 @@
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.session-service-menu > .session-service-footer {
padding-block-start: 6px;
}
.session-service-config-separator {
display: block;
height: 1px;
width: calc(100% + 4px);
margin: 2px -2px;
background: var(--v2-border-border-muted);
}
.session-service-config {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--v2-text-text-base);
font: inherit;
text-align: start;
text-decoration: none;
cursor: pointer;
.session-service-config-arrow {
opacity: 0;
transition: opacity 150ms ease-out;
}
&:is(:hover, :focus-visible) .session-service-config-arrow {
opacity: 1;
}
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
border-radius: 2px;
}
}
.session-service-dot {
width: 6px;
height: 6px;
display: grid;
place-items: center;
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 50%;
background: var(--v2-icon-icon-faint);
color: var(--v2-icon-icon-faint);
&::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
}
.session-service-dot[data-status="connected"],
.session-service-dot[data-status="active"] {
background: var(--icon-success-base);
color: var(--icon-success-base);
}
.session-service-dot[data-status="failed"] {
background: var(--icon-critical-base);
color: var(--icon-critical-base);
}
.session-service-dot[data-status="needs_auth"] {
background: var(--icon-warning-base);
color: var(--icon-warning-base);
}
[data-component="switch"].session-mcp-row [data-slot="switch-label"] {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex: 1;
min-width: 0;
height: auto;
@@ -171,7 +260,7 @@
}
.session-service-status {
flex-shrink: 0;
font-size: 11px;
font-size: inherit;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
@@ -180,7 +269,8 @@
}
.session-summary-move {
position: relative;
padding-top: 6px;
/* The first 6px sit behind the card, leaving 4px of visible space above the row. */
padding: 10px 2px 2px;
margin-top: -6px;
border-radius: 0 0 6px 6px;
background: var(--v2-background-bg-layer-02);
@@ -191,7 +281,7 @@
.session-summary-dismiss {
position: absolute;
inset-inline-end: 8px;
bottom: 6px;
bottom: 8px;
width: 20px;
height: 20px;
display: flex;
@@ -208,14 +298,21 @@
@media (max-width: 767px) {
[data-component="session-summary-panel"] .session-summary-row,
.session-service-menu .session-service-title,
.session-service-menu .session-service-row,
.session-service-menu .session-service-config,
.session-service-menu .session-mcp-row {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.session-summary-popover,
.session-service-menu {
animation: none;
}
.session-service-config .session-service-config-arrow {
transition: none;
}
}
@@ -10,8 +10,10 @@ const names = [
"check",
"chevron-down",
"close",
"code-slash",
"edit",
"folder",
"graduation-cap",
"help",
"magnifying-glass",
"menu",
+8
View File
@@ -164,6 +164,14 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M14.5 9.8333V13.5H1.5V2.5H7.1667M9.5 2.5V7.5H14.5V2.5H9.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
"graduation-cap": {
viewBox: "0 0 16 16",
body: `<path d="M12.3327 7.50065V11.0007L7.99935 13.6673L3.66602 11.0007V7.50065M14.3327 9.83398V6.16732M7.99935 2.33398L1.66602 6.00065L7.99935 10.0007L14.3327 6.00065L7.99935 2.33398Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"code-slash": {
viewBox: "0 0 16 16",
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0812 2.10803L6.55974 14.0809L5.92016 13.8928L9.44161 1.91992L10.0812 2.10803ZM4.13793 4.97275L1.44666 8.00045L4.13793 11.0281L3.63966 11.471L0.554688 8.00045L3.63966 4.52984L4.13793 4.97275ZM12.3617 4.52984L15.4467 8.00045L12.3617 11.471L11.8634 11.0281L14.5547 8.00045L11.8634 4.97275L12.3617 4.52984Z" fill="currentColor"/>`,
},
trash: {
viewBox: "0 0 20 20",
body: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,