mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 18:36:22 +00:00
Compare commits
9
Commits
beta
...
session-summary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ef11dcd8 | ||
|
|
b8990f0e80 | ||
|
|
c620b19bf7 | ||
|
|
457e934f36 | ||
|
|
a3c2f492b8 | ||
|
|
8501afca38 | ||
|
|
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 } })
|
||||
@@ -0,0 +1,452 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
|
||||
|
||||
const directory = "/workspace/summary-project"
|
||||
const workspace = "/workspace/existing-worktree"
|
||||
const createdWorkspace = "/workspace/created-worktree"
|
||||
const draftID = "draft_summary"
|
||||
const secondDraftID = "draft_summary_other"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
|
||||
for (const rtl of [false, true]) {
|
||||
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const mock = await openDraft(page)
|
||||
if (rtl) {
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
await page.getByRole("button", { name: "DIR: LTR", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Toggle debug tools", exact: true }).click()
|
||||
}
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!button || !view) return false
|
||||
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
|
||||
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!view || !project || !server) return
|
||||
return {
|
||||
top: project.y - view.y - 48,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 6, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [name, item] of [
|
||||
["MCP", "summary-mcp"],
|
||||
["Plugins", "project-plugin"],
|
||||
["Skills", "summary-skill"],
|
||||
["LSP", "typescript"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const menu = await worktreeMenu.boundingBox()
|
||||
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
|
||||
if (!menu || !panel) return false
|
||||
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
|
||||
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
|
||||
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await mcp.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
|
||||
await expect(menu).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
|
||||
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
|
||||
)?.mcp?.states,
|
||||
),
|
||||
)
|
||||
.toEqual({ "summary-mcp": false })
|
||||
|
||||
await page.goto(`/new-session?draftId=${secondDraftID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.goto(draftPath)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "worktree", directory },
|
||||
{ type: "mcp", directory: createdWorkspace, enabled: false },
|
||||
{ type: "session", directory: createdWorkspace },
|
||||
{ type: "prompt", directory: createdWorkspace },
|
||||
])
|
||||
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
})
|
||||
|
||||
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
try {
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "mcp", directory, enabled: false },
|
||||
{ type: "session", directory },
|
||||
{ type: "prompt", directory },
|
||||
])
|
||||
})
|
||||
|
||||
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
mock.state.holdDirectory = directory
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
|
||||
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: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
try {
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.state.fail = true
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
|
||||
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
|
||||
mock.state.fail = false
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(mock.status.get(createdWorkspace)).toBe("disabled")
|
||||
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
|
||||
})
|
||||
|
||||
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.status.set(createdWorkspace, "needs_auth")
|
||||
const attempts: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
return route.fulfill({
|
||||
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: createdWorkspace },
|
||||
data: {
|
||||
id: "summary-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
|
||||
expect(attempts).toEqual([createdWorkspace])
|
||||
expect(mock.prompts).toEqual([])
|
||||
mock.status.set(createdWorkspace, "connected")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main") {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
name: "summary-project",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const sessions: ReturnType<typeof currentSession>[] = []
|
||||
const status = new Map<string, string>([
|
||||
[directory, "connected"],
|
||||
[workspace, "disabled"],
|
||||
[createdWorkspace, "connected"],
|
||||
])
|
||||
const calls: { type: string; directory: string; enabled?: boolean }[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt(input) {
|
||||
const session = sessions.find((session) => session.id === input.sessionID)
|
||||
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
|
||||
calls.push({ type: "prompt", directory: session.location.directory })
|
||||
prompts.push(input)
|
||||
},
|
||||
})
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
if (route.request().method() === "POST") {
|
||||
const enabled = url.pathname.endsWith("/connect")
|
||||
calls.push({ type: "mcp", directory: target, enabled })
|
||||
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
|
||||
status.set(target, enabled ? "connected" : "disabled")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "summary-mcp",
|
||||
integrationID: "summary-oauth",
|
||||
status: { status: status.get(target) ?? "connected" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/location",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: project.id, directory, canonical: directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
|
||||
const id = target === directory ? "project-plugin" : "workspace-plugin"
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
location: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
calls.push({ type: "worktree", directory })
|
||||
project.sandboxes.push(createdWorkspace)
|
||||
return route.fulfill({ json: { directory: createdWorkspace } })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
|
||||
calls.push({ type: "session", directory: body.location.directory })
|
||||
const session = currentSession(
|
||||
{ ...body, projectID: project.id, title: "Created summary session" },
|
||||
body.location.directory,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session } })
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
await page.goto(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
|
||||
await expect(
|
||||
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
|
||||
).toBeVisible()
|
||||
return { calls, prompts, status, state }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect(status).toHaveCount(0)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await details.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
await expect(mcp).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
|
||||
}, theme)
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
new URL(route.request().url()).pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
|
||||
status: { status: "connected" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
|
||||
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
|
||||
const summary = page.locator('[data-component="session-summary-panel"]')
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch")).toHaveCount(30)
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await menu.boundingBox()
|
||||
return (
|
||||
!!bounds &&
|
||||
bounds.x >= 15 &&
|
||||
bounds.y >= 15 &&
|
||||
bounds.x + bounds.width <= 785 &&
|
||||
bounds.y + bounds.height <= 585
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
|
||||
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { enabled: true }
|
||||
const writes: string[] = []
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (route.request().method() === "POST") {
|
||||
expect(directory).toBe(fixture.directory)
|
||||
writes.push(url.pathname)
|
||||
state.enabled = url.pathname.endsWith("/connect")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
|
||||
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
|
||||
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
|
||||
{ name: "waiting", status: { status: "pending" } },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
|
||||
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
|
||||
"Sign in required",
|
||||
)
|
||||
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
|
||||
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
if (target === "keyboard") await expect(toggle).toBeFocused()
|
||||
expect(writes).toHaveLength(index + 1)
|
||||
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
|
||||
}
|
||||
})
|
||||
|
||||
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { status: "disabled" }
|
||||
const attempts: string[] = []
|
||||
const resources = Promise.withResolvers<void>()
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.endsWith("/connect")) {
|
||||
state.status = "needs_auth"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(route.request().url())
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: {
|
||||
id: "linear-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
const refresh = page.waitForRequest(
|
||||
(request) =>
|
||||
state.status === "needs_auth" &&
|
||||
new URL(request.url()).pathname === "/api/mcp/resource" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await refresh
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toHaveAccessibleDescription("Sign in required")
|
||||
} finally {
|
||||
resources.resolve()
|
||||
}
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
|
||||
})
|
||||
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
const current = { type: "http", http: { url: server }, displayName: "Design server" }
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
hidden: {},
|
||||
lastProject: {},
|
||||
recentlyClosed: {},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const services = [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
|
||||
] as const
|
||||
|
||||
for (const service of services) {
|
||||
for (const empty of [false, true]) {
|
||||
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
const state = { hold: false }
|
||||
const response = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === service.path,
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (state.hold) await response.promise
|
||||
if (service.name === "LSP")
|
||||
return route.fulfill({
|
||||
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
|
||||
})
|
||||
const items =
|
||||
service.name === "MCP"
|
||||
? [{ name: service.item, status: { status: "connected" } }]
|
||||
: service.name === "Plugins"
|
||||
? [
|
||||
{
|
||||
id: service.item,
|
||||
source: { type: "package", target: service.item },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
|
||||
},
|
||||
)
|
||||
|
||||
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 })
|
||||
const trigger = summary.getByRole("button", { name: service.name, exact: true })
|
||||
await trigger.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
if (empty) {
|
||||
const message = menu.locator(".session-service-empty")
|
||||
await expect(message).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("gap", "8px")
|
||||
await expect(message).toHaveCSS("font-size", "11px")
|
||||
await expect(message).toHaveCSS("line-height", "16px")
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
|
||||
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
|
||||
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
state.hold = true
|
||||
const refresh = page.waitForRequest(
|
||||
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
|
||||
)
|
||||
try {
|
||||
await trigger.click()
|
||||
await refresh
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(content).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
const response = Promise.withResolvers<void>()
|
||||
const state = { requested: false }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
state.requested = true
|
||||
await response.promise
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
|
||||
},
|
||||
)
|
||||
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 })
|
||||
try {
|
||||
await expect.poll(() => state.requested).toBe(true)
|
||||
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(menu.getByRole("status")).toContainText("Loading")
|
||||
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
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")
|
||||
for (const heading of [project, server]) {
|
||||
await expect(heading).toHaveCSS("column-gap", "8px")
|
||||
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
|
||||
}
|
||||
await expect(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()
|
||||
}
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
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")
|
||||
}
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { fail: true, extra: false }
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/plugin**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
|
||||
{
|
||||
id: "supermemory",
|
||||
source: { type: "package", target: "opencode-supermemory" },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
},
|
||||
{
|
||||
id: "broken-plugin",
|
||||
source: { type: "local", path: "/broken.ts" },
|
||||
features: { server: true },
|
||||
state: { status: "failed", error: "Plugin failed to activate" },
|
||||
},
|
||||
...(state.extra
|
||||
? [
|
||||
{
|
||||
id: "daytona",
|
||||
source: { type: "package", target: "opencode-daytona" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/skill**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{
|
||||
id: "review-animations",
|
||||
name: "review-animations",
|
||||
location: "/skills/review/SKILL.md",
|
||||
content: "Review animations",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
|
||||
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
|
||||
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
|
||||
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
state.extra = true
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
|
||||
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
|
||||
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
|
||||
await expect(plugins).toBeHidden()
|
||||
await summary.getByRole("button", { name: "LSP", exact: true }).click()
|
||||
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
|
||||
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
|
||||
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
|
||||
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
await expect(status).toHaveCount(0)
|
||||
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
worktree: () => string
|
||||
branch: () => string | undefined
|
||||
submitted: () => void
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const route = useSessionKey()
|
||||
const prompt = useComposerState()
|
||||
@@ -49,6 +51,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
const projectDirectory = location().directory
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
const id = Session.ID.create()
|
||||
const pending =
|
||||
worktree === "create"
|
||||
@@ -68,6 +71,18 @@ export function createNewSessionComposerAdapter(props: {
|
||||
return
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
if (!pending) return
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
}
|
||||
if (!(await props.mcp.prepare(sessionDirectory, mcp))) {
|
||||
await rollback()
|
||||
if (pending) props.mcp.remember(sessionDirectory, mcp)
|
||||
return
|
||||
}
|
||||
|
||||
const created = data.session.create({
|
||||
id,
|
||||
agent: selection.agent,
|
||||
@@ -89,10 +104,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
},
|
||||
)
|
||||
if (pending && !(await creation).ok) {
|
||||
// Keep retries on the worktree that was already created, not another new checkout.
|
||||
data.project.invalidate()
|
||||
await data.project.sync().catch(() => undefined)
|
||||
await pending.rollback(sessionDirectory)
|
||||
await rollback()
|
||||
return
|
||||
}
|
||||
const afterCreation = async <T>(run: () => Promise<T>) => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMcpToggle, type McpControls } from "@/providers/connect/mcp"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
|
||||
export function createDraftMcpControls(input: { draftID: string; worktree: () => string }) {
|
||||
const tabs = useTabs()
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore<{
|
||||
preparing: boolean
|
||||
pending: Record<string, Promise<boolean> | undefined>
|
||||
}>({ preparing: false, pending: {} })
|
||||
const key = (worktree: string) => JSON.stringify([server.key, location().directory, worktree])
|
||||
const target = createMemo(() => key(input.worktree()))
|
||||
const preview = () => input.worktree() === "create"
|
||||
const directory = createMemo(() => {
|
||||
const selected = input.worktree()
|
||||
return selected === "main" || selected === "create" ? location().directory : selected
|
||||
})
|
||||
const states = createMemo(() => {
|
||||
const draft = tabs.store.find((tab) => tab.type === "draft" && tab.draftID === input.draftID)
|
||||
return draft?.type === "draft" && draft.mcp?.target === target() ? draft.mcp.states : {}
|
||||
})
|
||||
const toggle = useMcpToggle(directory)
|
||||
const controls: McpControls = {
|
||||
get preview() {
|
||||
return preview()
|
||||
},
|
||||
get states() {
|
||||
return states()
|
||||
},
|
||||
get pending() {
|
||||
return store.preparing || (!preview() && store.pending[directory()] !== undefined)
|
||||
},
|
||||
change(name, enabled) {
|
||||
if (controls.pending) return
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: target(), states: { ...states(), [name]: enabled } } })
|
||||
if (preview()) return
|
||||
const current = directory()
|
||||
const request = toggle.mutateAsync({ name, enabled, directory: current }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
setStore("pending", current, request)
|
||||
void request.finally(() => setStore("pending", current, undefined))
|
||||
},
|
||||
}
|
||||
|
||||
const apply = async (directory: string, states: Readonly<Record<string, boolean>>) => {
|
||||
const pending = store.pending[directory]
|
||||
if (pending && !(await pending)) return false
|
||||
const entries = Object.entries(states)
|
||||
if (entries.length === 0) return true
|
||||
const catalog = await sdk.api.mcp.list({ location: { directory } })
|
||||
const missing = entries.find(([name, enabled]) => enabled && !catalog.data.some((server) => server.name === name))
|
||||
if (missing) throw new Error(language.t("session.summary.mcp.unavailable", { name: missing[0] }))
|
||||
const results = await Promise.all(
|
||||
entries
|
||||
.filter(([name, enabled]) => {
|
||||
const server = catalog.data.find((server) => server.name === name)
|
||||
return server && (enabled ? server.status.status !== "connected" : server.status.status !== "disabled")
|
||||
})
|
||||
.map(([name, enabled]) =>
|
||||
toggle.mutateAsync({ name, enabled, directory }).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
if (results.some((success) => !success)) return false
|
||||
const current = await sdk.api.mcp.list({ location: { directory } })
|
||||
const unresolved = entries.find(([name, enabled]) => {
|
||||
const status = current.data.find((server) => server.name === name)?.status.status
|
||||
return enabled ? status !== "connected" : status !== undefined && status !== "disabled"
|
||||
})
|
||||
if (!unresolved) return true
|
||||
const status = current.data.find((server) => server.name === unresolved[0])?.status.status
|
||||
throw new Error(
|
||||
language.t(status === "needs_auth" ? "session.summary.mcp.signInBeforeSend" : "session.summary.mcp.notReady", {
|
||||
name: unresolved[0],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
controls,
|
||||
directory,
|
||||
capture: () => ({ ...states() }),
|
||||
remember(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
tabs.updateDraft(input.draftID, { mcp: { target: key(directory), states: { ...states } } })
|
||||
},
|
||||
async prepare(directory: string, states: Readonly<Record<string, boolean>>) {
|
||||
if (!store.pending[directory] && !Object.keys(states).length) return true
|
||||
setStore("preparing", true)
|
||||
return apply(directory, states)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.summary.mcp.prepareFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
})
|
||||
.finally(() => setStore("preparing", false))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DraftMcpControls = ReturnType<typeof createDraftMcpControls>
|
||||
@@ -1,19 +1,18 @@
|
||||
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"
|
||||
import { createDraftMcpControls } from "./mcp"
|
||||
|
||||
/** 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")
|
||||
@@ -31,11 +30,13 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
},
|
||||
onViewAll: openWorkspaces,
|
||||
})
|
||||
const mcp = createDraftMcpControls({ draftID: props.draftId, worktree: workspace.selection.value })
|
||||
const composer = createNewSessionComposerAdapter({
|
||||
draftID: props.draftId,
|
||||
worktree: workspace.selection.value,
|
||||
branch: workspace.bar.branch,
|
||||
submitted: workspace.selection.remember,
|
||||
mcp,
|
||||
})
|
||||
const model = createComposerModel(composer.adapter)
|
||||
useComposerCommands({ model: composer.model })
|
||||
@@ -72,9 +73,8 @@ 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} />
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} mcp={mcp} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ProjectSummaryCard } from "@/session/summary/project-card"
|
||||
import { SessionServerPanel } from "@/session/summary/server-panel"
|
||||
import type { PromptProject } from "./project/selector"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { PromptWorkspaceSelector } from "./workspace/selector"
|
||||
|
||||
export function NewSessionSummary(props: {
|
||||
project?: PromptProject
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
shown: boolean
|
||||
onChooseProject: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-component="session-summary-panel">
|
||||
<Show
|
||||
when={props.project}
|
||||
fallback={
|
||||
<div class="session-summary-card">
|
||||
<button type="button" class="session-summary-row" onClick={props.onChooseProject}>
|
||||
<Icon name="folder" class="text-v2-icon-icon-muted" />
|
||||
{language.t("session.summary.chooseProject")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(project) => (
|
||||
<>
|
||||
<ProjectSummaryCard project={project()}>
|
||||
<PromptWorkspaceSelector
|
||||
variant="summary"
|
||||
value={props.workspace.selection.value()}
|
||||
projectRoot={props.workspace.project.root()}
|
||||
workspaces={props.workspace.project.workspaces()}
|
||||
branches={props.workspace.project.branches()}
|
||||
branch={props.workspace.bar.branch()}
|
||||
onChange={props.workspace.selection.set}
|
||||
onCreate={props.workspace.selection.create}
|
||||
onSearch={props.workspace.project.searchBranches}
|
||||
onViewAll={props.workspace.project.openAll}
|
||||
/>
|
||||
</ProjectSummaryCard>
|
||||
<SessionServerPanel directory={props.mcp.directory()} shown={props.shown} mcp={props.mcp.controls} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show, createMemo, createSignal } from "solid-js"
|
||||
import { Show, Suspense, createMemo, createSignal, lazy } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import createPresence from "solid-presence"
|
||||
import { Composer } from "@/composer/composer"
|
||||
@@ -13,8 +14,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"
|
||||
@@ -23,6 +22,13 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import type { NewSessionWorkspaceController } from "./workspace/controller"
|
||||
import { NewSessionWordmark } from "./wordmark"
|
||||
import { SummaryPopover } from "@/session/summary/popover"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
const NewSessionSummary = lazy(async () => {
|
||||
const { NewSessionSummary } = await import("./summary")
|
||||
return { default: NewSessionSummary }
|
||||
})
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -40,7 +46,9 @@ export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
workspace: NewSessionWorkspaceController
|
||||
mcp: DraftMcpControls
|
||||
}) {
|
||||
const [store, setStore] = createStore({ summary: false })
|
||||
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||
Persist.global("workspace-onboarding"),
|
||||
WorkspaceOnboardingSchema,
|
||||
@@ -61,6 +69,25 @@ export function NewSessionView(props: {
|
||||
active={props.composer.state.drag === "active"}
|
||||
input={props.composer.model.selection.current()?.capabilities.input}
|
||||
/>
|
||||
<div
|
||||
data-slot="new-session-summary"
|
||||
class="absolute inset-x-0 top-0 z-20 flex h-12 items-center justify-end px-3"
|
||||
>
|
||||
<SummaryPopover open={store.summary} onOpenChange={(open) => setStore("summary", open)}>
|
||||
<Suspense>
|
||||
<NewSessionSummary
|
||||
project={props.project.selected()}
|
||||
workspace={props.workspace}
|
||||
mcp={props.mcp}
|
||||
shown={store.summary}
|
||||
onChooseProject={() => {
|
||||
setStore("summary", false)
|
||||
props.project.add()
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</SummaryPopover>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<NewSessionWordmark />
|
||||
@@ -115,19 +142,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()
|
||||
|
||||
@@ -15,13 +15,18 @@ export function PromptWorkspaceSelector(props: {
|
||||
branches: string[]
|
||||
branch?: string
|
||||
onboarding?: boolean
|
||||
variant?: "inline" | "summary"
|
||||
onChange: (value: string) => void
|
||||
onCreate: (branch: string) => void
|
||||
onSearch: (search: string) => void
|
||||
onDone: () => void
|
||||
onDone?: () => void
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const summary = () => props.variant === "summary"
|
||||
const placement = createMemo(() =>
|
||||
summary() ? (language.direction() === "rtl" ? "right-start" : "left-start") : "bottom",
|
||||
)
|
||||
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
@@ -58,17 +63,20 @@ export function PromptWorkspaceSelector(props: {
|
||||
props.onViewAll()
|
||||
return
|
||||
}
|
||||
props.onDone()
|
||||
props.onDone?.()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (selected() === "main")
|
||||
return language.t(summary() ? "session.new.workspace.local" : "session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Show when={!summary()}>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
</Show>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
@@ -87,18 +95,28 @@ export function PromptWorkspaceSelector(props: {
|
||||
)
|
||||
}
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
class={summary() ? "min-w-0 w-full" : "min-w-0"}
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={24}
|
||||
modal={summary() ? false : undefined}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 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 data-[expanded]:text-v2-text-text-muted"
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 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 data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
class={`shrink-0 ${summary() || selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<span class={summary() ? "session-summary-label" : "min-w-0 truncate"}>{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
data-slot="workspace-onboarding-dot"
|
||||
@@ -106,7 +124,11 @@ export function PromptWorkspaceSelector(props: {
|
||||
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||
/>
|
||||
</Show>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[200px]">
|
||||
@@ -233,29 +255,55 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Tooltip>
|
||||
<Show
|
||||
when={selected() === "create" && props.branch}
|
||||
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
|
||||
fallback={
|
||||
summary() ? (
|
||||
<Show when={props.branch}>
|
||||
<div class="session-summary-row">
|
||||
<Icon name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{props.branch}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
) : (
|
||||
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
class={summary() ? "min-w-0 w-full" : "ms-1 min-w-0 max-w-[220px]"}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu
|
||||
placement="bottom"
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
modal={summary() ? false : undefined}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
<Menu.Trigger
|
||||
class={
|
||||
summary()
|
||||
? "session-summary-row"
|
||||
: "flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted"
|
||||
}
|
||||
>
|
||||
<Icon name="branch-out" size={summary() ? "normal" : "small"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class={summary() ? "session-summary-label" : "min-w-0 truncate"}>
|
||||
{language.t(summary() ? "session.summary.basedOn" : "session.new.workspace.fromBranch", {
|
||||
branch: props.branch!,
|
||||
})}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon
|
||||
name={summary() ? "fill-triangle-down" : "chevron-down"}
|
||||
size={summary() ? "normal" : "small"}
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
|
||||
@@ -6,6 +6,13 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export type McpControls = {
|
||||
readonly preview: boolean
|
||||
readonly states: Readonly<Record<string, boolean>>
|
||||
readonly pending: boolean
|
||||
change: (name: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -17,31 +24,36 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
|
||||
}
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const ref = location()
|
||||
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
|
||||
const name = typeof input === "string" ? input : input.name
|
||||
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : 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") {
|
||||
if (!server || (server.status.status === "pending" && typeof input === "string")) return
|
||||
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)
|
||||
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) =>
|
||||
|
||||
@@ -387,6 +387,7 @@ export const dict = {
|
||||
"mcp.status.needs_auth": "needs auth",
|
||||
"mcp.status.disabled": "disabled",
|
||||
"mcp.auth.clickToAuthenticate": "Click to authenticate",
|
||||
"mcp.auth.interactiveForm": "MCP server {{name}} requires an interactive authentication form",
|
||||
|
||||
"dialog.fork.empty": "No messages to fork from",
|
||||
|
||||
@@ -1385,6 +1386,32 @@ 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.chooseProject": "Choose a project",
|
||||
"session.summary.mcp.onCreation": "Applies when the worktree is created",
|
||||
"session.summary.mcp.prepareFailed": "Could not prepare MCP servers",
|
||||
"session.summary.mcp.unavailable": "MCP server {{name}} is not available in this worktree.",
|
||||
"session.summary.mcp.signInBeforeSend": "Sign in to {{name}} before sending the prompt.",
|
||||
"session.summary.mcp.notReady": "MCP server {{name}} is not ready. Resolve its connection before sending the prompt.",
|
||||
"session.summary.mcp": "MCP",
|
||||
"session.summary.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",
|
||||
"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()
|
||||
|
||||
export function SessionHeaderSpacer() {
|
||||
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,17 @@
|
||||
import type { ConfigEntry } from "@opencode/client"
|
||||
|
||||
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,130 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, 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 { containsDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
import { SessionWorkspaceMenu } from "../timeline/session-workspace-menu"
|
||||
import { BackgroundWorkSummary, type BackgroundTask } from "./background"
|
||||
import { SessionServerPanel } from "./server-panel"
|
||||
import { ProjectSummaryCard } from "./project-card"
|
||||
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 expanded = settings.sessionSummary.projectExpanded
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
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>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={placement()}
|
||||
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="fill-triangle-down" 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} />
|
||||
</ProjectSummaryCard>
|
||||
<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={placement()}
|
||||
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,31 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
export function SummaryPopover(props: ParentProps<{ open: boolean; onOpenChange: (open: boolean) => void }>) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Popover open={props.open} placement="bottom-end" gutter={2} overflowPadding={16} onOpenChange={props.onOpenChange}>
|
||||
<Popover.Anchor class="pointer-events-none absolute end-3 top-0 h-12 w-0" aria-hidden="true" />
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={props.open ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<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")}
|
||||
>
|
||||
{props.children}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
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 "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
const contentID = createUniqueId()
|
||||
const expanded = settings.sessionSummary.projectExpanded
|
||||
return (
|
||||
<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 class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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, type McpControls } 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"]
|
||||
|
||||
type ServiceMenuProps = {
|
||||
service: (typeof services)[number]
|
||||
directory: string
|
||||
shown: boolean
|
||||
open: boolean
|
||||
mobile?: boolean
|
||||
mcp?: McpControls
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SessionServerPanel(props: { directory: string; shown: boolean; mobile?: boolean; mcp?: McpControls }) {
|
||||
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 class="session-summary-heading-label">
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{name()}
|
||||
</span>
|
||||
<Icon name="fill-triangle-down" class="session-summary-disclosure" />
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded() ? props.directory : undefined} keyed>
|
||||
{(directory) => (
|
||||
<div id={contentID} class="session-summary-rows">
|
||||
<For each={services}>
|
||||
{(service) => (
|
||||
<ServiceMenu
|
||||
service={service}
|
||||
directory={directory}
|
||||
shown={props.shown}
|
||||
open={store.submenu === service.type}
|
||||
mobile={props.mobile}
|
||||
mcp={props.mcp}
|
||||
onOpenChange={(open) => setStore("submenu", open ? service.type : undefined)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceMenu(props: ServiceMenuProps) {
|
||||
if (props.service.type === "mcp") return <McpMenu {...props} />
|
||||
if (props.service.type === "lsp") return <LspMenu {...props} />
|
||||
return <ServiceCatalog {...props} />
|
||||
}
|
||||
|
||||
function LspMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && 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 (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={data.location.config.list({ directory: props.directory }) !== undefined}
|
||||
empty={names().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={names().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.lsp.empty")}
|
||||
description={language.t("session.summary.lsp.manage")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<div class="session-service-message">{language.t("session.summary.lsp.manage")}</div>
|
||||
</Show>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function McpMenu(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const toggle = useMcpToggle(() => props.directory)
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && ([props.directory, props.mcp?.preview] as const),
|
||||
async ([directory, preview]) => {
|
||||
data.location.mcp.server.invalidate({ directory })
|
||||
await Promise.all([
|
||||
data.location.mcp.server.sync({ directory }),
|
||||
...(preview ? [data.location.config.sync({ directory })] : []),
|
||||
])
|
||||
},
|
||||
)
|
||||
const servers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
const defaults = createMemo(() =>
|
||||
Object.fromEntries(
|
||||
(data.location.config.list({ directory: props.directory }) ?? []).flatMap((entry) =>
|
||||
entry.type === "document"
|
||||
? Object.entries(entry.info.mcp?.servers ?? {}).map(([name, config]) => [name, !config.disabled] as const)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={
|
||||
data.location.mcp.server.list({ directory: props.directory }) !== undefined &&
|
||||
(!props.mcp?.preview || data.location.config.list({ directory: props.directory }) !== undefined)
|
||||
}
|
||||
empty={servers().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={servers().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t("session.summary.mcp.empty")}
|
||||
description={language.t("session.summary.mcp.add")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show when={props.mcp?.preview}>
|
||||
<div class="session-service-message" data-slot="mcp-preview-hint">
|
||||
{language.t("session.summary.mcp.onCreation")}
|
||||
</div>
|
||||
</Show>
|
||||
<Index each={servers()}>
|
||||
{(server) => {
|
||||
const preview = () => props.mcp?.preview === true
|
||||
const enabled = () =>
|
||||
preview()
|
||||
? (props.mcp?.states[server().name] ?? defaults()[server().name] ?? true)
|
||||
: server().status.status !== "disabled"
|
||||
const pending = () =>
|
||||
(props.mcp?.pending ?? toggle.isPending) || (!preview() && server().status.status === "pending")
|
||||
const error = () => {
|
||||
const status = server().status
|
||||
return status.status === "failed" ? status.error : undefined
|
||||
}
|
||||
const label = () => {
|
||||
if (preview()) return undefined
|
||||
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
|
||||
if (props.mcp) return props.mcp.change(server().name, value)
|
||||
toggle.mutate({ name: server().name, enabled: value })
|
||||
}
|
||||
return (
|
||||
<Switch
|
||||
class="session-mcp-row [&_[data-slot=switch-description]]:sr-only"
|
||||
description={preview() ? language.t("session.summary.mcp.onCreation") : label()}
|
||||
checked={enabled()}
|
||||
readOnly={pending()}
|
||||
aria-disabled={pending()}
|
||||
aria-busy={props.mcp?.pending ?? toggle.isPending}
|
||||
onChange={change}
|
||||
onClick={(event: MouseEvent) => {
|
||||
if (event.target === event.currentTarget) change(!enabled())
|
||||
}}
|
||||
title={preview() ? server().name : (error() ?? server().name)}
|
||||
>
|
||||
<span
|
||||
class="session-service-dot"
|
||||
data-status={preview() ? undefined : 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>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceCatalog(props: ServiceMenuProps) {
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const [items, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
async (directory) => {
|
||||
if (props.service.type === "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 undefined
|
||||
},
|
||||
)
|
||||
const loaded = () => items.state === "ready" || items.state === "refreshing"
|
||||
const list = createMemo(() => {
|
||||
const entries =
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
? (items.latest ?? [])
|
||||
: []
|
||||
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
|
||||
name: skill.name,
|
||||
status: "active",
|
||||
error: undefined,
|
||||
}))
|
||||
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
createEffect(() => {
|
||||
onCleanup(
|
||||
sdk.event
|
||||
.location(props.directory)
|
||||
.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", () => void refetch()),
|
||||
)
|
||||
})
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={items.loading}
|
||||
ready={
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
: data.location.skill.list({ directory: props.directory }) !== undefined
|
||||
}
|
||||
empty={list().length === 0}
|
||||
error={items.error}
|
||||
retry={refetch}
|
||||
>
|
||||
<Show
|
||||
when={list().length}
|
||||
fallback={
|
||||
<ServiceEmpty
|
||||
title={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.empty" : "session.summary.skills.empty",
|
||||
)}
|
||||
description={language.t(
|
||||
props.service.type === "plugins" ? "session.summary.plugins.add" : "session.summary.skills.add",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="session-service-message">
|
||||
{language.t(
|
||||
props.service.type === "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>
|
||||
</ServicePopover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServicePopover(
|
||||
props: ServiceMenuProps & {
|
||||
loading: boolean
|
||||
ready: boolean
|
||||
empty: boolean
|
||||
error: unknown
|
||||
retry: () => unknown
|
||||
children: JSX.Element
|
||||
},
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start",
|
||||
)
|
||||
return (
|
||||
<Popover
|
||||
open={props.open}
|
||||
onOpenChange={(open) => {
|
||||
props.onOpenChange(open)
|
||||
if (open && !props.loading) void props.retry()
|
||||
}}
|
||||
placement={placement()}
|
||||
gutter={4}
|
||||
overflowPadding={16}
|
||||
modal={false}
|
||||
>
|
||||
<Popover.Trigger as="button" type="button" class="session-summary-row">
|
||||
<Icon name={props.service.icon} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="session-summary-label">{language.t(props.service.label)}</span>
|
||||
<Icon name="fill-triangle-down" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="session-service-menu"
|
||||
data-service={props.service.type}
|
||||
data-empty={(props.ready && !props.error && props.empty) || undefined}
|
||||
aria-busy={props.loading}
|
||||
aria-label={language.t(props.service.label)}
|
||||
>
|
||||
<Show
|
||||
when={props.ready || !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>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceEmpty(props: { title: string; description: string }) {
|
||||
return (
|
||||
<div class="session-service-empty">
|
||||
<strong>{props.title}</strong>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
[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);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .session-summary-card {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
.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-heading-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.session-summary-heading[aria-expanded="false"] .session-summary-disclosure {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.session-summary-heading[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);
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
|
||||
&[data-empty] {
|
||||
width: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
strong {
|
||||
font-weight: 530;
|
||||
}
|
||||
}
|
||||
|
||||
.session-service-message {
|
||||
padding: 6px 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-service-menu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -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,34 +21,30 @@ 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 { SummaryPopover } from "../summary/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"
|
||||
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 { SessionHeaderSpacer } 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,42 +537,32 @@ function MessageTimelineView(
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
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>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
<SummaryPopover open={summaryOpen()} onOpenChange={setSummary}>
|
||||
<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>
|
||||
</SummaryPopover>
|
||||
)}
|
||||
</Show>
|
||||
<SessionHeader />
|
||||
<SessionHeaderSpacer />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const Draft = Persistence.struct({
|
||||
directory: Schema.String,
|
||||
worktree: Persistence.optional(Schema.String),
|
||||
branch: Persistence.optional(Schema.String),
|
||||
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
|
||||
})
|
||||
|
||||
const SessionCodec = Session.pipe(
|
||||
|
||||
@@ -16,6 +16,19 @@ function sessionTab(sessionId: string): SessionTab {
|
||||
}
|
||||
|
||||
describe("tab migration", () => {
|
||||
test("round trips draft MCP choices without changing older drafts", () => {
|
||||
const legacy: Tab = { type: "draft", draftID: "legacy-draft", server, directory: "/project" }
|
||||
const draft: Tab = {
|
||||
...legacy,
|
||||
draftID: "mcp-draft",
|
||||
worktree: "create",
|
||||
mcp: { target: "new-worktree", states: { first: true, second: false } },
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
expect(
|
||||
decodeTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -214,7 +214,7 @@ const icons = {
|
||||
},
|
||||
"fill-triangle-down": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M5.37624 6.75194C5.1818 6.41861 5.42223 6 5.80813 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43199 10.5096C8.23905 10.8404 7.76115 10.8404 7.56821 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
|
||||
body: `<path d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
|
||||
},
|
||||
archive: {
|
||||
viewBox: "0 0 16 16",
|
||||
|
||||
Reference in New Issue
Block a user