mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 10:26:25 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e711dcea6 | ||
|
|
bb6bfa7219 | ||
|
|
dc62569153 |
@@ -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()
|
||||
|
||||
+5
-4
@@ -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 } })
|
||||
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -308,7 +308,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^Used\s*2\s*Read, Grep$/)
|
||||
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/^2 used\s*Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("1 used Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -110,7 +110,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
|
||||
@@ -386,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -45,7 +45,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
|
||||
@@ -75,7 +75,7 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
@@ -162,7 +162,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
|
||||
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,6 @@ const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.String,
|
||||
expires_in: Schema.Number,
|
||||
org_id: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const TokenPending = Schema.Struct({ error: Schema.String })
|
||||
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||
@@ -49,12 +48,7 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
authorize: (answer) =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* normalizeServer(answer.server ?? defaultServer)
|
||||
const device = yield* post(
|
||||
http,
|
||||
`${server}/auth/device/code`,
|
||||
{ client_id: clientID, supports_org_scope: true },
|
||||
Device,
|
||||
)
|
||||
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
|
||||
const verification = yield* Effect.try({
|
||||
try: () => {
|
||||
const url = new URL(device.verification_uri_complete, `${server}/`)
|
||||
@@ -79,23 +73,11 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||
Token,
|
||||
)
|
||||
// Persist rotated tokens without depending on discovery requests.
|
||||
return {
|
||||
...credential,
|
||||
access: token.access_token,
|
||||
refresh: token.refresh_token,
|
||||
expires: Date.now() + token.expires_in * 1000,
|
||||
metadata:
|
||||
token.org_id == null
|
||||
? credential.metadata
|
||||
: {
|
||||
...credential.metadata,
|
||||
orgID: token.org_id,
|
||||
orgName:
|
||||
credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string"
|
||||
? credential.metadata.orgName
|
||||
: token.org_id,
|
||||
},
|
||||
}
|
||||
}),
|
||||
label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined),
|
||||
@@ -387,13 +369,7 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const org =
|
||||
token.org_id == null
|
||||
? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
: orgs.find((org) => org.id === token.org_id)
|
||||
if (token.org_id != null && !org) {
|
||||
return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`))
|
||||
}
|
||||
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth" as const,
|
||||
methodID,
|
||||
@@ -420,13 +396,13 @@ function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, tok
|
||||
function post<S extends Schema.Top>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
body: Record<string, string | boolean>,
|
||||
body: Record<string, string>,
|
||||
schema: S,
|
||||
statusOk = true,
|
||||
) {
|
||||
return HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
|
||||
Effect.flatMap((request) => http.execute(request)),
|
||||
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
||||
|
||||
@@ -28,48 +28,6 @@ const addPlugin = Effect.fn(function* () {
|
||||
yield* OpencodePlugin.effect(host)
|
||||
})
|
||||
|
||||
function consoleServer(orgID: string | null | undefined, unavailable = false) {
|
||||
const config: { authorization: string | null; orgID: string | null }[] = []
|
||||
const requests: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
requests.push(path)
|
||||
if (path === "/auth/device/code") {
|
||||
expect(await request.json()).toEqual({ client_id: "opencode-cli", supports_org_scope: true })
|
||||
return Response.json({
|
||||
device_code: "device",
|
||||
user_code: "user",
|
||||
verification_uri_complete: "/device?user_code=user",
|
||||
expires_in: 60,
|
||||
interval: 0,
|
||||
})
|
||||
}
|
||||
if (path === "/auth/device/token") {
|
||||
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600, org_id: orgID })
|
||||
}
|
||||
if (unavailable && (path === "/api/user" || path === "/api/orgs")) {
|
||||
return new Response("Unavailable", { status: 503 })
|
||||
}
|
||||
if (path === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
|
||||
if (path === "/api/orgs") {
|
||||
return Response.json([
|
||||
{ id: "org-z", name: "Zebra" },
|
||||
{ id: "org-a", name: "Alpha" },
|
||||
])
|
||||
}
|
||||
if (path === "/api/v2/config") {
|
||||
config.push({ authorization: request.headers.get("authorization"), orgID: request.headers.get("x-org-id") })
|
||||
if (orgID === "org-missing") return new Response("Forbidden", { status: 403 })
|
||||
return Response.json({ providers: {} })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
return { server, config, requests }
|
||||
}
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
@@ -206,119 +164,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
for (const orgID of ["org-z", undefined, null, "org-missing"]) {
|
||||
it.live(`uses the device token organization during authorization: ${orgID}`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(orgID)),
|
||||
({ server, config }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("opencode")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answer: { server: server.url.origin },
|
||||
})
|
||||
const status = yield* eventually(
|
||||
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
|
||||
(status) => status.status !== "pending",
|
||||
)
|
||||
if (orgID === "org-missing") {
|
||||
expect(status).toMatchObject({
|
||||
status: "failed",
|
||||
message: "OpenCode organization not found: org-missing",
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
expect(config).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(status.status).toBe("complete")
|
||||
expect((yield* credentials.list(integrationID))[0]).toMatchObject({
|
||||
label: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
value: {
|
||||
type: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
accountID: "user",
|
||||
email: "user@example.com",
|
||||
orgID: orgID ?? "org-a",
|
||||
orgName: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => config.length),
|
||||
(count) => count > 0,
|
||||
)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: orgID ?? "org-a" }])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ orgID: "org-z" },
|
||||
{ orgID: undefined },
|
||||
{ orgID: null },
|
||||
{ orgID: "org-missing" },
|
||||
{ orgID: "org-z", unavailable: true },
|
||||
{ orgID: "org-a", unavailable: true },
|
||||
]) {
|
||||
it.live(
|
||||
`persists rotated credentials for ${scenario.orgID}${scenario.unavailable ? " with discovery unavailable" : ""}`,
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(scenario.orgID, scenario.unavailable)),
|
||||
({ server, config, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const initial = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
label: "Custom label",
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired-access",
|
||||
refresh: "old-refresh",
|
||||
expires: 0,
|
||||
metadata: { server: server.url.origin, orgID: "org-a", orgName: "Alpha", custom: "preserved" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const stored = required(yield* credentials.get(initial.id))
|
||||
expect(stored).toMatchObject({
|
||||
label: "Custom label",
|
||||
value: {
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
orgID: scenario.orgID ?? "org-a",
|
||||
orgName: scenario.orgID === "org-a" ? "Alpha" : (scenario.orgID ?? "Alpha"),
|
||||
custom: "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
expect(stored.value.expires).toBeGreaterThan(Date.now())
|
||||
if (scenario.orgID == null) expect(stored.value.metadata).toEqual(initial.value.metadata)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
|
||||
const integrations = yield* Integration.Service
|
||||
expect(
|
||||
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
|
||||
).toEqual(stored.value)
|
||||
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects non-HTTP OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -7,7 +7,7 @@ for (const theme of ["light", "dark"]) {
|
||||
globals: { theme },
|
||||
})
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" })
|
||||
const disclosure = group.getByRole("button", { name: /^Used \d+ .*Edit.*Write.*Patch$/ })
|
||||
const disclosure = group.getByRole("button", { name: /^\d+ used .*Edit.*Write.*Patch$/ })
|
||||
await disclosure.click()
|
||||
for (const name of ["edit", "write", "patch"]) {
|
||||
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
|
||||
|
||||
@@ -10,9 +10,9 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText("3 used")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
|
||||
@@ -9,7 +9,7 @@ const png = Buffer.from(
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
|
||||
@@ -17,11 +17,9 @@ for (const tool of ["shell", "execute", "subagent"]) {
|
||||
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
|
||||
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
|
||||
const usage = group.locator(
|
||||
'[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]',
|
||||
)
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("2")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText("2 used")
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
@@ -37,7 +35,7 @@ for (const expanded of [false, true]) {
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
|
||||
const trigger = expanded
|
||||
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
|
||||
: timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
: timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
@@ -85,7 +83,7 @@ story("transitions a streaming shell from writing through command execution", as
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
await expect(tool).not.toContainText("Writing command…")
|
||||
await timeline.getByRole("button", { name: "Complete command" }).click()
|
||||
const summary = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const summary = timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
@@ -132,7 +130,7 @@ for (const open of [false, true]) {
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const used = group.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -189,7 +187,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
await timeline.getByRole("button", { name: "Complete read" }).click()
|
||||
await timeline.getByRole("button", { name: "Complete glob" }).click()
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^2 used /)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob")
|
||||
|
||||
@@ -50,7 +50,7 @@ for (const mode of ["hidden", "compact", "full"] as const) {
|
||||
if (following === "tool") {
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveText(/^Used\s*1\s*Skill$/)
|
||||
await expect(trigger).toHaveText(/^1 used\s*Skill$/)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
|
||||
@@ -53,7 +53,7 @@ story("space activates a focused timeline button instead of scrolling", async ({
|
||||
await page.setViewportSize({ width: 800, height: 240 })
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
|
||||
const trigger = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.focus()
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
|
||||
@@ -44,9 +44,9 @@ story("renders every tool error outcome without leaking hidden tools", async ({
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
|
||||
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
|
||||
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText(String(names.length))
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText(`${names.length} used`)
|
||||
await group.getByRole("button").click()
|
||||
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
|
||||
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
|
||||
@@ -72,7 +72,7 @@ story("transitions shell and question through running error outcomes", async ({
|
||||
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
|
||||
story("labels all web search provider variants", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
|
||||
await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
await timeline.getByRole("button", { name: "3 used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
const tools = timeline.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
@@ -105,7 +105,7 @@ story("labels read tools from their path input", async ({ mount }) => {
|
||||
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("2 used Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Skill")
|
||||
@@ -130,7 +130,7 @@ story("groups every collapsed tool until visible text separates the stack", asyn
|
||||
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("4 used Glob, Grep, Shell, List")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Glob, Grep, Shell, List")
|
||||
|
||||
@@ -6,8 +6,8 @@ for (const open of [true, false]) {
|
||||
async ({ mount }, info) => {
|
||||
const root = await mount("current-session-file-changes--appending-tool-calls")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
const trigger = group.getByRole("button", { name: /^\d+ used Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("2 used Shell, Patch")
|
||||
await trigger.click()
|
||||
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
@@ -34,7 +34,7 @@ for (const open of [true, false]) {
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Patch")
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName(`${count} used Shell, Patch`)
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
|
||||
@@ -6,11 +6,11 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: /^Used \d+ Read, Skill$/ })
|
||||
const used = group.getByRole("button", { name: /^\d+ used Read, Skill$/ })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(used).toHaveAccessibleName("Used 4 Read, Skill")
|
||||
await expect(used).toHaveAccessibleName("4 used Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Read, Skill")
|
||||
@@ -34,7 +34,7 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(used).toHaveAccessibleName("Used 5 Read, Skill")
|
||||
await expect(used).toHaveAccessibleName("5 used Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Read, Skill")
|
||||
@@ -70,19 +70,10 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
const prefix = header.locator('[data-slot="context-tool-group-prefix"]')
|
||||
const count = header.locator('[data-slot="context-tool-group-count"]')
|
||||
const title = header.locator('[data-slot="basic-tool-tool-title"]')
|
||||
await expect(prefix).toHaveText("Used")
|
||||
await expect(count).toHaveText("4")
|
||||
await expect(title).toHaveText("Shell, Read, Agent")
|
||||
const colors = await Promise.all(
|
||||
[prefix, count, title].map((part) => part.evaluate((node) => getComputedStyle(node).color)),
|
||||
)
|
||||
expect(colors[1]).toBe(colors[2])
|
||||
expect(colors[0]).not.toBe(colors[1])
|
||||
await expect(group.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Read, Agent")
|
||||
const gap = await group.evaluate((element) => {
|
||||
const title = element.querySelector('[data-component="context-tool-group-trigger"]')!.getBoundingClientRect()
|
||||
const arrow = element.querySelector('[data-slot="collapsible-arrow-icon"]')!.getBoundingClientRect()
|
||||
@@ -97,7 +88,7 @@ for (const width of [840, 390]) {
|
||||
await page.setViewportSize({ width, height: 600 })
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })
|
||||
const trigger = group.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Read, Agent")
|
||||
await expect(header.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
|
||||
@@ -607,35 +607,19 @@
|
||||
[data-slot="context-tool-group-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
gap: 8px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-usage"] {
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
|
||||
& > [data-slot="context-tool-group-prefix"],
|
||||
& > [data-slot="context-tool-group-count"] {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-count"] {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
|
||||
@@ -536,25 +536,13 @@ export function CurrentContextToolGroup(props: {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
if (!names() && !thoughts) {
|
||||
const title = i18n.t("ui.messagePart.context.details")
|
||||
return { text: title, title, before: "", count: "", between: "", after: "" }
|
||||
return { text: title, title, before: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
const before = text.slice(0, index).trim()
|
||||
const countText = String(count)
|
||||
const countIndex = before.indexOf(countText)
|
||||
const after = text.slice(index + title.length).trim()
|
||||
if (countIndex === -1) return { text, title, before, count: "", between: "", after }
|
||||
return {
|
||||
text,
|
||||
title,
|
||||
before: before.slice(0, countIndex).trim(),
|
||||
count: countText,
|
||||
between: before.slice(countIndex + countText.length).trim(),
|
||||
after,
|
||||
}
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
|
||||
@@ -619,18 +607,8 @@ export function CurrentContextToolGroup(props: {
|
||||
trigger={
|
||||
<div data-component="context-tool-group-trigger" aria-label={label().text}>
|
||||
<span data-slot="context-tool-group-title">
|
||||
<Show when={label().before || label().count || label().between}>
|
||||
<span data-slot="context-tool-group-usage">
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()} </span>}
|
||||
</Show>
|
||||
<Show when={label().count}>
|
||||
{(count) => <span data-slot="context-tool-group-count">{count()} </span>}
|
||||
</Show>
|
||||
<Show when={label().between}>
|
||||
{(between) => <span data-slot="context-tool-group-prefix">{between()} </span>}
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title">{label().title}</span>
|
||||
<Show when={label().after}>
|
||||
|
||||
@@ -118,8 +118,8 @@ const source = {
|
||||
"ui.messagePart.context.thought.other": "Thoughts",
|
||||
"ui.messagePart.context.match.one": "({{count}} match)",
|
||||
"ui.messagePart.context.match.other": "({{count}} matches)",
|
||||
"ui.messagePart.tools.used.one": "Used {{count}} {{tools}}",
|
||||
"ui.messagePart.tools.used.other": "Used {{count}} {{tools}}",
|
||||
"ui.messagePart.tools.used.one": "{{count}} used {{tools}}",
|
||||
"ui.messagePart.tools.used.other": "{{count}} used {{tools}}",
|
||||
|
||||
"ui.list.loading": "Loading",
|
||||
"ui.list.empty": "No results",
|
||||
|
||||
Reference in New Issue
Block a user