diff --git a/packages/app/e2e/regression/new-session-summary.spec.ts b/packages/app/e2e/regression/new-session-summary.spec.ts index 3baf98dae1b..6547867b0e3 100644 --- a/packages/app/e2e/regression/new-session-summary.spec.ts +++ b/packages/app/e2e/regression/new-session-summary.spec.ts @@ -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", diff --git a/packages/app/e2e/regression/session-summary-config.spec.ts b/packages/app/e2e/regression/session-summary-config.spec.ts new file mode 100644 index 00000000000..7f20a6d4d7e --- /dev/null +++ b/packages/app/e2e/regression/session-summary-config.spec.ts @@ -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) +} diff --git a/packages/app/e2e/regression/session-summary-layout.spec.ts b/packages/app/e2e/regression/session-summary-layout.spec.ts index 0b7619ceea6..26f9d3e27fa 100644 --- a/packages/app/e2e/regression/session-summary-layout.spec.ts +++ b/packages/app/e2e/regression/session-summary-layout.spec.ts @@ -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 }) diff --git a/packages/app/e2e/regression/session-summary-mcp.spec.ts b/packages/app/e2e/regression/session-summary-mcp.spec.ts index e98863cbae7..236e7da1779 100644 --- a/packages/app/e2e/regression/session-summary-mcp.spec.ts +++ b/packages/app/e2e/regression/session-summary-mcp.spec.ts @@ -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) }) diff --git a/packages/app/e2e/regression/session-summary-refresh.spec.ts b/packages/app/e2e/regression/session-summary-refresh.spec.ts index 676a38b3099..7c74d1d8b01 100644 --- a/packages/app/e2e/regression/session-summary-refresh.spec.ts +++ b/packages/app/e2e/regression/session-summary-refresh.spec.ts @@ -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() }) diff --git a/packages/app/e2e/regression/session-summary.spec.ts b/packages/app/e2e/regression/session-summary.spec.ts index 613f9988376..5f384e3be09 100644 --- a/packages/app/e2e/regression/session-summary.spec.ts +++ b/packages/app/e2e/regression/session-summary.spec.ts @@ -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() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 09c767268cf..0a06f61ef84 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -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 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 }) => { diff --git a/packages/app/src/new-session/summary.tsx b/packages/app/src/new-session/summary.tsx index 5361880489a..1a9f10143d6 100644 --- a/packages/app/src/new-session/summary.tsx +++ b/packages/app/src/new-session/summary.tsx @@ -24,7 +24,7 @@ export function NewSessionSummary(props: {
} diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index dabeedd9cf3..d0c3890bb30 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -127,7 +127,7 @@ export function PromptWorkspaceSelector(props: { @@ -294,7 +294,7 @@ export function PromptWorkspaceSelector(props: { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index ff37e2f911f..ccb43658116 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/summary/panel.tsx b/packages/app/src/session/summary/panel.tsx index 21674ad3c87..fc646af893a 100644 --- a/packages/app/src/session/summary/panel.tsx +++ b/packages/app/src/session/summary/panel.tsx @@ -58,7 +58,7 @@ export function SessionSummaryPanel(props: { {location()} - +
@@ -87,15 +87,29 @@ export function SessionSummaryPanel(props: {
diff --git a/packages/app/src/session/summary/popover.tsx b/packages/app/src/session/summary/popover.tsx index ac1d8b14867..a49eaede070 100644 --- a/packages/app/src/session/summary/popover.tsx +++ b/packages/app/src/session/summary/popover.tsx @@ -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 ( - -