Files

2329 lines
97 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import {
NO_PROVIDER,
REMOTE_SERVER,
SERVER,
T0,
draftHref,
expectPath,
holdRoute,
pageMessagesFrom,
project,
seed,
session,
sessionHref,
} from "../utils/app"
import { openFileLauncher, reviewTabs } from "../utils/side-panel"
import { mockServers } from "../utils/mock-server"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { fileNode, mockWorkspace, openSession } from "../utils/workspace"
import { expectSessionTitle } from "../utils/waits"
import { openWithDirection } from "../utils/direction"
const a = { id: "ses_tab_a", title: "Tab A session" }
const b = { id: "ses_tab_b", title: "Tab B session" }
const c = { id: "ses_tab_c", title: "Tab C session" }
test.use({ serviceWorkers: "block" })
test("recently closed shows ten sessions and searches older closures in close order", async ({ page }) => {
const sessions = Array.from({ length: 100 }, (_, index) => ({
id: `ses_closed_${index}`,
title: `Closed session ${index}`,
}))
await mockWorkspace(page, {
name: "ClosedTabs",
sessions,
seed: {
servers: [SERVER],
tabs: [sessions[0]!.id],
storage: {
"opencode.window.browser.dat:tabs.closed": sessions.slice(1).map((session, index) => ({
tab: { type: "session", server: SERVER, sessionId: session.id },
index,
info: { title: session.title, directory: "C:/OpenCode/ClosedTabs", prompted: true },
})),
},
},
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText("Closed session 0")
const trigger = page.getByRole("button", { name: "New session", exact: true })
await trigger.click({ button: "right" })
const menu = page.getByRole("menu")
const items = menu.getByRole("menuitem")
const search = menu.getByRole("searchbox", { name: "Search recently closed" })
await expect(search).toBeFocused()
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${99 - index}`))
await search.pressSequentially("SESSION 2")
await expect(search).toHaveValue("SESSION 2")
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${29 - index}`))
await search.fill("not found")
await expect(menu.getByRole("status")).toHaveText("No matches")
await expect(items).toHaveCount(0)
await menu.getByRole("button", { name: "Clear", exact: true }).click()
await expect(search).toHaveValue("")
await expect(search).toBeFocused()
await expect(items).toHaveCount(10)
await search.fill("session 1")
await expect(items).toHaveText(Array.from({ length: 10 }, (_, index) => `CClosed session ${19 - index}`))
await search.press("ArrowDown")
await expect(menu.getByRole("menuitem", { name: "C Closed session 19", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expectPath(page, sessionHref("ses_closed_19"))
await expect(menu).toBeHidden()
await trigger.click({ button: "right" })
await expect(search).toHaveValue("")
await expect(items).toHaveCount(10)
await search.press("Escape")
await expect(menu).toBeHidden()
})
test("recently closed keeps the keyboard-focused session in view when the menu overflows", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 360 })
const sessions = Array.from({ length: 11 }, (_, index) => ({
id: `ses_overflow_${index}`,
title: `Overflow session ${index}`,
}))
await mockWorkspace(page, {
name: "ClosedOverflow",
sessions,
seed: {
servers: [SERVER],
tabs: [sessions[0]!.id],
storage: {
"opencode.window.browser.dat:tabs.closed": sessions.slice(1).map((item, index) => ({
tab: { type: "session", server: SERVER, sessionId: item.id },
index,
info: { title: item.title, directory: "C:/OpenCode/ClosedOverflow", prompted: true },
})),
},
},
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText("Overflow session 0")
await page.getByRole("button", { name: "New session", exact: true }).click({ button: "right" })
const menu = page.getByRole("menu")
const last = menu.getByRole("menuitem", { name: "C Overflow session 1", exact: true })
await expect(menu.getByRole("menuitem")).toHaveCount(10)
await menu.getByRole("searchbox", { name: "Search recently closed" }).press("ArrowDown")
await page.keyboard.press("End")
await expect(last).toBeFocused()
await expect(last).toBeInViewport({ ratio: 1 })
})
test("recently closed fills remaining slots in Home order without open or duplicate sessions", async ({ page }) => {
const sessions = Array.from({ length: 12 }, (_, index) => ({
id: `ses_other_${index}`,
title: `Other session ${index}`,
time: { created: 1700000000000, updated: 1700000000000 + (12 - index) * 1000 },
}))
await mockWorkspace(page, {
name: "OtherTabs",
sessions: [
...sessions,
{ id: "ses_other_archived", title: "Archived session", time: { created: Date.now(), archived: Date.now() } },
{ id: "ses_other_child", title: "Child session", parentID: sessions[0]!.id },
],
seed: {
servers: [SERVER],
tabs: [sessions[0]!.id, sessions[2]!.id],
storage: {
"opencode.window.browser.dat:tabs.closed": sessions.slice(10).map((session, index) => ({
tab: { type: "session", server: SERVER, sessionId: session.id },
index,
info: { title: session.title, directory: "C:/OpenCode/OtherTabs", prompted: true },
})),
},
},
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.locator("[data-titlebar-tab-title]")).toHaveText(["Other session 0", "Other session 2"])
const trigger = page.getByRole("button", { name: "New session", exact: true })
await trigger.click({ button: "right" })
const menu = page.getByRole("menu")
const items = menu.getByRole("menuitem")
await expect(items).toHaveText([11, 10, 1, 3, 4, 5, 6, 7, 8, 9].map((index) => `OOther session ${index}`))
await expect(menu.getByRole("group", { name: "Recently closed", exact: true }).getByRole("menuitem")).toHaveText([
"OOther session 11",
"OOther session 10",
])
await expect(menu.getByRole("group", { name: "Recent sessions", exact: true }).getByRole("menuitem")).toHaveCount(8)
await menu.getByRole("menuitem", { name: "O Other session 1", exact: true }).click()
await expectPath(page, sessionHref("ses_other_1"))
await expect(menu).toBeHidden()
await trigger.click({ button: "right" })
await expect(items).toHaveText([11, 10, 3, 4, 5, 6, 7, 8, 9].map((index) => `OOther session ${index}`))
await menu.getByRole("searchbox", { name: "Search recently closed" }).fill("session 9")
await expect(items).toHaveText(["OOther session 9"])
})
test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse down", async ({ page }) => {
const workspace = await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
seed: { tabs: [a.id, { draft: "draft_tab_width", directory: "C:/OpenCode/Tabs" }, b.id] },
})
await page.goto(sessionHref(a.id))
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([a.title, "Session", b.title])
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
const link = page.locator(`a[data-titlebar-tab-link][href="${sessionHref(b.id, workspace.server)}"]`)
const box = await link.boundingBox()
if (!box) throw new Error("tab link has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await expectPath(page, sessionHref(b.id))
await page.mouse.up()
await expectPath(page, sessionHref(b.id))
})
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
page,
}) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
sessionPermissions: {
[b.id]: [
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
const dot = tabB.locator('[data-mark="attention"]')
const status = tabB.locator('[data-slot="tab-status"]')
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(close).toHaveCSS("opacity", "0")
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
await expect
.poll(() =>
dot.evaluate((element) => {
const dot = element.getBoundingClientRect()
const slot = element.parentElement!.getBoundingClientRect()
return (
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
)
}),
)
.toBeLessThan(1)
await tabB.hover()
await expect(status).toHaveCSS("opacity", "0")
await expect(close).toHaveCSS("opacity", "1")
await page.mouse.move(0, 0)
await tabB.locator("[data-titlebar-tab-link]").focus()
await page.keyboard.press("Tab")
await expect(close).toBeFocused()
await expect(status).toHaveCSS("opacity", "0")
await expect(close).toHaveCSS("opacity", "1")
await close.blur()
await expect(status).toHaveCSS("opacity", "1")
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB).toHaveAttribute("data-active", "true")
await expect(dot).toBeHidden()
await tabA.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(dot).toBeVisible()
await tabB.hover()
await close.click()
await expect(tabB).toHaveCount(0)
await expectPath(page, sessionHref(a.id))
})
test("the browser title names the open session and resets after leaving it", async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
await page.goto(sessionHref(a.id))
await expect(page).toHaveTitle(`${a.title} · OpenCode`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(page).toHaveTitle(`${b.title} · OpenCode`)
await page.getByRole("button", { name: "Home", exact: true }).click()
await expectPath(page, "/")
await expect(page).toHaveTitle("OpenCode")
})
for (const [tabLayout, direction, label] of [
["horizontal", "ltr", "Close tabs to the right"],
["horizontal", "rtl", "Close tabs to the left"],
["vertical", "ltr", "Close tabs below"],
] as const) {
test(`${tabLayout} ${direction} tab menus bulk close in visible order and reopen sessions`, async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: {
tabs: [a.id, { draft: "draft_bulk_close", directory: "C:/OpenCode/Tabs" }, b.id, c.id],
settings: { appearance: { tabLayout } },
},
})
await openWithDirection(page, sessionHref(a.id), direction)
const titles = page.locator("[data-titlebar-tab-slot] [data-titlebar-tab-title]")
const draft = page.locator(`a[data-titlebar-tab-link][href="${draftHref("draft_bulk_close")}"]`)
const tabB = page.locator(`a[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`)
const menu = page.getByRole("menu")
const active = page.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')
const modifier = await page.evaluate(() => (/Mac/.test(navigator.platform) ? "Meta" : "Control"))
await expect(titles).toHaveText([a.title, "Session", b.title, c.title])
await draft.click({ button: "right" })
await expect(menu.getByRole("menuitem")).toHaveText(["Close tab", "Close other tabs", label])
await menu.getByRole("menuitem", { name: label, exact: true }).click()
await expect(titles).toHaveText([a.title, "Session"])
await expect(active).toHaveText(a.title)
for (const expected of [
[a.title, "Session", b.title],
[a.title, "Session", b.title, c.title],
]) {
await page.keyboard.press(`${modifier}+Shift+t`)
await expect(titles).toHaveText(expected)
}
await page.locator(`a[data-titlebar-tab-link][href="${sessionHref(a.id)}"]`).click()
await expect(active).toHaveText(a.title)
await tabB.click({ button: "right" })
await menu.getByRole("menuitem", { name: label, exact: true }).click()
await expect(titles).toHaveText([a.title, "Session", b.title])
await expect(active).toHaveText(a.title)
await page.keyboard.press(`${modifier}+Shift+t`)
await expect(titles).toHaveText([a.title, "Session", b.title, c.title])
await tabB.click({ button: "right" })
await expect(menu.getByRole("menuitem")).toHaveText([
"Rename",
"Export…",
"Close tab",
"Close other tabs",
label,
"Delete…",
])
await menu.getByRole("menuitem", { name: "Close other tabs", exact: true }).click()
await expect(titles).toHaveText([b.title])
await expect(active).toHaveText(b.title)
await tabB.click({ button: "right" })
await expect(menu.getByRole("menuitem", { name: "Close other tabs", exact: true })).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: label, exact: true })).toBeDisabled()
await page.keyboard.press("Escape")
for (const expected of [
[a.title, b.title],
[a.title, b.title, c.title],
]) {
await page.keyboard.press(`${modifier}+Shift+t`)
await expect(titles).toHaveText(expected)
}
await expect(draft).toHaveCount(0)
})
}
for (const tabLayout of ["horizontal", "vertical"] as const) {
test(`a ${tabLayout} tab can be renamed again while its previous rename is still saving`, async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b], seed: { settings: { appearance: { tabLayout } } } })
await page.goto(sessionHref(a.id))
const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`)
const editor = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(title).toHaveText(a.title)
const save = await holdRoute(page, (url) => url.pathname === `/api/session/${a.id}`, { method: "PATCH" })
await title.dblclick()
await expect(editor).toBeFocused()
await editor.fill("First name")
await editor.press("Enter")
expect((await save.arrived).postDataJSON()).toEqual({ title: "First name" })
await expect(editor).toHaveCount(0)
await title.dblclick()
await expect(editor).toBeFocused()
await editor.fill("Second name")
await editor.press("Enter")
await expect(title).toHaveText("Second name")
await title.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
await expect(editor).toBeFocused()
await editor.fill("Third name")
await editor.press("Enter")
await expect(title).toHaveText("Third name")
save.release()
await expect
.poll(() => save.requests.map((request) => request.postDataJSON()))
.toEqual([{ title: "First name" }, { title: "Second name" }, { title: "Third name" }])
await expect(title).toHaveText("Third name")
})
}
test("a tab whose overlapping renames both fail shows its saved title again", async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
await page.goto(sessionHref(a.id))
const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`)
const editor = page.locator('[data-slot="tab-title"][contenteditable="plaintext-only"]')
await expect(title).toHaveText(a.title)
const released = Promise.withResolvers<void>()
const saves: string[] = []
await page.route(new RegExp(`/api/session/${a.id}(\\?|$)`), async (route) => {
if (route.request().method() !== "PATCH") return route.fallback()
saves.push(route.request().postDataJSON().title)
await released.promise
await route.fulfill({ status: 500, json: { message: "Rename failed" } })
})
for (const name of ["First name", "Second name"]) {
await title.dblclick()
await expect(editor).toBeFocused()
await editor.fill(name)
await editor.press("Enter")
await expect(title).toHaveText(name)
}
await expect.poll(() => saves).toEqual(["First name", "Second name"])
released.resolve()
// The rollback restores the title the server holds, never the first rename that was never saved.
await expect(title).toHaveText(a.title)
await expect(page.locator('[data-testid^="toast-v2-"]').filter({ hasText: "Request failed" })).toHaveCount(1)
})
for (const tabLayout of ["horizontal", "vertical"] as const) {
test(`a ${tabLayout} tab deletes its session after confirmation`, async ({ page }) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, b], seed: { settings: { appearance: { tabLayout } } } })
await page.goto(sessionHref(a.id))
const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`)
const remove = page.waitForRequest(
(request) => request.method() === "DELETE" && request.url().endsWith(`/api/session/${a.id}`),
)
await expect(title).toHaveText(a.title)
await title.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete…", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(title).toHaveText(a.title)
await title.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete…", exact: true }).click()
await dialog.getByRole("button", { name: "Delete session", exact: true }).click()
await remove
await expect(title).toHaveCount(0)
await expectPath(page, sessionHref(b.id))
})
}
test("keyboard navigation and numbered shortcuts follow the visible tab order and skip unresolved tabs", async ({
page,
}) => {
await mockWorkspace(page, { name: "Tabs", sessions: [a, c], seed: { tabs: [a.id, "ses_tab_unresolved", c.id] } })
await page.route(
(url) => url.pathname === "/api/session/ses_tab_unresolved",
() => new Promise(() => {}),
)
await page.goto(sessionHref(a.id))
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"])`)).toBeVisible()
await page.keyboard.press("Control+Alt+ArrowRight")
await expectPath(page, sessionHref(c.id))
await page.keyboard.press("ControlOrMeta+1")
await expectPath(page, sessionHref(a.id))
await page.keyboard.press("ControlOrMeta+2")
await expectPath(page, sessionHref(c.id))
})
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[c.id]: [
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-mark="attention"]`),
).toBeVisible()
await page.keyboard.press("Alt+ArrowDown")
await expectPath(page, sessionHref(b.id))
await page.keyboard.press("Alt+ArrowUp")
await expectPath(page, sessionHref(a.id))
await page.keyboard.press("Shift+Alt+ArrowDown")
await expectPath(page, sessionHref(c.id))
})
for (const navigation of ["horizontal", "vertical", "keyboard"] as const) {
test(`switching session tabs restores the caret and pastes into the destination draft (${navigation})`, async ({
page,
context,
}) => {
const tabLayout = navigation === "vertical" ? "vertical" : "horizontal"
const strip = page.locator(
tabLayout === "vertical" ? '[data-slot="vertical-tabs-sidebar"]' : '[data-slot="titlebar-tabs"]',
)
await context.grantPermissions(["clipboard-read", "clipboard-write"])
await openSession(page, {
name: "TabFocus",
sessions: [a, b],
seed: { settings: { appearance: { tabLayout } } },
})
const editor = page.locator('[data-component="composer-editor"]')
const tabB = strip.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`)
await editor.fill("Draft A")
await (navigation === "keyboard" ? page.keyboard.press("Control+Alt+ArrowRight") : tabB.click())
await expectPath(page, sessionHref(b.id))
await expect(editor).toBeFocused()
await page.evaluate(() => navigator.clipboard.writeText("Draft B"))
await page.keyboard.press("ControlOrMeta+V")
await expect(editor).toHaveText("Draft B")
await page.keyboard.press("Control+Alt+ArrowLeft")
await expectPath(page, sessionHref(a.id))
await expect(editor).toBeFocused()
await expect(editor).toHaveText("Draft A")
await page.evaluate(() => navigator.clipboard.writeText(" appended"))
await page.keyboard.press("ControlOrMeta+V")
await expect(editor).toHaveText("Draft A appended")
await tabB.click()
await expectPath(page, sessionHref(b.id))
await expect(editor).toBeFocused()
await expect(editor).toHaveText("Draft B")
})
}
for (const row of [
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
]) {
test.describe(`mobile drawer (${row.tabLayout})`, () => {
test.use({ hasTouch: row.hasTouch })
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[b.id]: [
{
id: "permission-mobile-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
})
await page.goto(sessionHref(a.id))
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
// The session location bar also names the "Tabs" project, so scope to the titlebar.
await page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-mark="attention"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-status"]')).toHaveCSS("width", "20px")
await expect(tabB.locator('[data-slot="tab-status"]')).toHaveCSS("height", "20px")
await expect
.poll(() =>
tabB.evaluate((element) => {
const dot = element.querySelector('[data-slot="tab-status"]')!.getBoundingClientRect()
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
return dot.right <= close.left || dot.left >= close.right
}),
)
.toBe(true)
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
await expectPath(page, sessionHref(b.id))
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
if (row.tabLayout !== "vertical") return
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page
.locator('[data-slot="vertical-tabs-sidebar"]')
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
})
}
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
page,
}) => {
await mockWorkspace(page, {
name: "CompactTabs",
sessions: [
a,
b,
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
],
sessionPermissions: {
[b.id]: [
{
id: "permission-compact-b",
sessionID: b.id,
action: "shell",
resources: ["git status"],
metadata: {},
save: [],
},
],
},
})
await page.goto(sessionHref(a.id))
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.hover()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(b.id))
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
})
test("an unread tab whose run failed shows the failed dot, as the inbox does", async ({ page }) => {
const time = { created: T0, updated: T0, idle: T0 + 1 }
await mockWorkspace(page, {
name: "FailedTabs",
sessions: [a, { ...b, outcome: "failed", time }, { ...c, outcome: "succeeded", time }],
})
await page.goto(sessionHref(a.id))
const tab = (id: string) => page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(id)}"])`)
await expect(tab(b.id).locator('[data-mark="failed"]')).toBeVisible()
await expect(tab(b.id).locator('[data-mark="unread"]')).toBeHidden()
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeVisible()
await expect(tab(c.id).locator('[data-mark="failed"]')).toBeHidden()
await tab(c.id).locator("[data-titlebar-tab-link]").click({ button: "right" })
await page.getByRole("menuitem", { name: "Mark as read", exact: true }).click()
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeHidden()
await tab(c.id).locator("[data-titlebar-tab-link]").click({ button: "right" })
await expect(page.getByRole("menu").getByRole("menuitem")).toHaveText([
"Rename",
"Export…",
"Mark as unread",
"Close tab",
"Close other tabs",
"Close tabs to the right",
"Delete…",
])
await page.getByRole("menuitem", { name: "Mark as unread", exact: true }).click()
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeVisible()
await tab(c.id).locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(c.id))
await tab(c.id).locator("[data-titlebar-tab-link]").click({ button: "right" })
await page.getByRole("menuitem", { name: "Mark as unread", exact: true }).click()
await page.mouse.move(0, 0)
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeVisible()
await expect(tab(c.id).locator('[data-slot="tab-status"]')).toHaveCSS("opacity", "1")
await tab(a.id).locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeVisible()
await tab(c.id).locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(c.id))
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeHidden()
await tab(a.id).locator("[data-titlebar-tab-link]").click()
await expectPath(page, sessionHref(a.id))
await expect(tab(c.id).locator('[data-mark="unread"]')).toBeHidden()
})
test("an unfocused window preserves a restored manual unread reminder", async ({ page }) => {
// Playwright emulates focus for each page, so model a background window explicitly.
await page.addInitScript(() => {
document.hasFocus = () => false
})
await mockWorkspace(page, {
name: "BackgroundUnread",
sessions: [a, { ...c, time: { created: T0, updated: T0, idle: T0, viewed: T0 } }],
seed: { storage: { "opencode.global.dat:notification.manual-unread": { sessions: [c.id] } } },
})
await page.goto(sessionHref(c.id))
await expectSessionTitle(page, c.title)
const tab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"])`)
await expect(tab.locator('[data-mark="unread"]')).toBeVisible()
await page.evaluate(() => {
document.hasFocus = Document.prototype.hasFocus.bind(document)
window.dispatchEvent(new Event("focus"))
})
await expect(tab.locator('[data-mark="unread"]')).toBeHidden()
})
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 480 })
const directory = "C:/OpenCode/Tabs"
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b],
seed: {
settings: {
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "ctrl+alt+h", "tab.new": "ctrl+shift+n" },
},
tabs: [
a.id,
...Array.from({ length: 24 }, (_, index) => ({ draft: `draft_vertical_${index}`, directory })),
b.id,
],
},
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`)
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
await expect(sidebar).toHaveCSS("width", "240px")
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
// Actions and session rows share one icon lane, and the resize handle fills only the gap after the rows.
const [homeIcon, avatar, row, gutter, chat] = await Promise.all([
sidebar.locator('[data-action="vertical-tabs-home"] svg').boundingBox(),
sidebar.locator('[data-slot="project-avatar-slot"]').first().boundingBox(),
sidebar.locator("[data-titlebar-tab]").first().boundingBox(),
sidebar.locator('[data-component="resize-handle"]').boundingBox(),
page.locator('[data-slot="session-chat-panel"]').boundingBox(),
])
if (!homeIcon || !avatar || !row || !gutter || !chat) throw new Error("vertical tabs layout is not rendered")
expect(avatar.x).toBe(homeIcon.x)
expect(gutter.x).toBe(row.x + row.width)
expect(gutter.x + gutter.width).toBe(chat.x)
const actions = await Promise.all(
["vertical-tabs-home", "vertical-tabs-search", "vertical-tabs-new-session"].map(async (action) => {
const box = await sidebar.locator(`[data-action="${action}"]`).boundingBox()
if (!box) throw new Error(`${action} is not rendered`)
return box
}),
)
const gaps = actions.slice(1).map((box, index) => box.y - (actions[index].y + actions[index].height))
expect(gaps).toEqual([3, 3])
const handle = sidebar.locator('[data-component="resize-handle"]')
const resize = async (offset: number, width: string) => {
const box = await handle.boundingBox()
if (!box) throw new Error("vertical tab resize handle has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + offset, box.y + box.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", width)
}
await resize(-44, "196px")
await resize(-40, "156px")
for (const name of ["Home", "New session"]) {
const label = sidebar.getByRole("button", { name, exact: true }).getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
}
await resize(-200, "136px")
const homeLabel = sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true })
await expect
.poll(() => homeLabel.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await resize(800, "640px")
await resize(-380, "260px")
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
// The list feathers only the edges with more rows past them.
const feather = () =>
scroll.evaluate((element) => {
const style = getComputedStyle(element)
return [style.getPropertyValue("--vertical-tabs-fade-top"), style.getPropertyValue("--vertical-tabs-fade-bottom")]
})
await expect.poll(feather).toEqual(["0px", "16px"])
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(tabB).toBeInViewport({ ratio: 1 })
await expect.poll(feather).toEqual(["16px", "0px"])
await page.locator("html").evaluate((element) => element.setAttribute("dir", "rtl"))
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
const hint = home.locator('div[aria-hidden="true"]')
await expect(hint.locator('[data-slot="keybind-v2-label"]')).toHaveText(["Ctrl", "Alt", "H"])
await expect(hint.locator('[data-component="keybind-v2"]')).toHaveCSS("direction", "ltr")
await expect(hint).toBeHidden()
await home.focus()
await expect(hint).toBeVisible()
await tabB.click()
await expectPath(page, sessionHref(b.id))
})
test.describe("vertical tabs on a touch desktop", () => {
test.use({ hasTouch: true })
test("long titles fade out before the always-visible close button", async ({ page }) => {
const long = {
id: "ses_tab_long",
title: "A long session title that would otherwise run underneath the close button",
}
await mockWorkspace(page, {
name: "Tabs",
sessions: [long],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(long.id))
const tab = page.locator('[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab]')
await expect(tab).toHaveAttribute("data-title-overflow", "true")
await expect(tab.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
await expect
.poll(() =>
tab.evaluate((element) => {
const link = element.querySelector('[data-slot="tab-link"]')!.getBoundingClientRect()
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
const stops = [
...getComputedStyle(element.querySelector('[data-slot="tab-link"]')!).maskImage.matchAll(
/calc\(100% - ([\d.]+)px\)/g,
),
].map((match) => Number(match[1]))
return link.right - Math.min(...stops) <= close.left
}),
)
.toBe(true)
})
})
for (const [screen, href] of [
["home", "/"],
["new session", draftHref("draft_collapsed_sidebar")],
["settings", "/settings"],
["session", sessionHref(a.id)],
] as const) {
test(`collapsed vertical tabs sidebar reopens from the ${screen} screen`, async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a],
seed: {
settings: { appearance: { tabLayout: "vertical", sidebarOpen: false } },
tabs: [a.id, { draft: "draft_collapsed_sidebar", directory: "C:/OpenCode/Tabs" }],
},
})
await page.goto(href)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveAttribute("data-state", "closed")
await page
.locator('[data-slot="collapsed-sidebar-toggle"]')
.getByRole("button", { name: "Toggle sidebar", exact: true })
.click()
await expect(sidebar).toHaveAttribute("data-state", "open")
await expect(sidebar.getByRole("button", { name: "Toggle sidebar", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
})
}
test("the collapsed sidebar peeks from a resting toggle, Escape cancels a pending peek, and a click docks it", async ({
page,
}) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a],
seed: { settings: { appearance: { tabLayout: "vertical", sidebarOpen: false } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const toggle = page
.locator('[data-slot="collapsed-sidebar-toggle"]')
.getByRole("button", { name: "Toggle sidebar", exact: true })
await expect(sidebar).toHaveAttribute("data-state", "closed")
await expect(toggle).toBeVisible()
// See "tab preview opens cold": pause at a fixed offset from an explicit install time.
const start = Date.now()
await page.clock.install({ time: start })
await page.clock.pauseAt(start + 10_000)
// The toggle's 500ms dwell, cut short by Escape, never peeks.
await toggle.hover()
await page.keyboard.press("Escape")
await page.clock.runFor(600)
await expect(sidebar).not.toHaveAttribute("data-peek")
await page.mouse.move(600, 400)
await toggle.hover()
await page.clock.runFor(600)
await expect(sidebar).toHaveAttribute("data-peek", "")
// Docking from the peek hands the sidebar over, and the peek ends once the dock lands.
await toggle.click()
await page.clock.runFor(2000)
await expect(sidebar).toHaveAttribute("data-state", "open")
await expect(sidebar).not.toHaveAttribute("data-peek")
})
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title])
const upper = await tabB.boundingBox()
const lower = await tabC.boundingBox()
if (!upper || !lower) throw new Error("vertical tabs have no bounding boxes")
expect(lower.y - upper.y - upper.height).toBe(3)
const middle = upper.y + upper.height + 1.5
// The two halves belong to their nearest row; an exact tie belongs to the next row.
for (const [offset, id] of [
[-1, b.id],
[0, c.id],
] as const) {
await page.mouse.move(upper.x + upper.width / 2, middle + offset)
await expect(sidebar.locator("[data-titlebar-tab]:hover [data-titlebar-tab-link]")).toHaveAttribute(
"href",
sessionHref(id),
)
}
await page.mouse.click(upper.x + upper.width / 2, middle)
await expectPath(page, sessionHref(c.id))
await page.mouse.move(upper.x + upper.width / 2, middle - 1)
await page.mouse.down()
await page.mouse.move(upper.x + upper.width / 2, lower.y + lower.height - 2, { steps: 8 })
await page.mouse.up()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title, b.title])
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title])
await expectPath(page, sessionHref(c.id))
const list = await sidebar.locator("[data-titlebar-tab-list]").boundingBox()
if (!list) throw new Error("vertical tab list has no bounding box")
for (const [x, y] of [
[list.x + list.width / 2, list.y - 1],
[list.x + list.width / 2, list.y + list.height + 1],
[list.x - 1, list.y + list.height / 2],
[list.x + list.width + 1, list.y + list.height / 2],
]) {
await page.mouse.move(x, y)
await expect(sidebar.locator("[data-titlebar-tab]:hover")).toHaveCount(0)
}
})
test("tab preview opens cold, switches immediately between tabs, and closes outside the strip", async ({ page }) => {
const d = { id: "ses_tab_d", title: "Library" }
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c, d],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const rows = sidebar.locator("[data-titlebar-tab]")
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
const preview = page.locator('[data-component="session-tab-popover"]')
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
// Until it is paused the installed clock follows real time, and it starts slightly ahead of the runner's wall clock,
// so `pauseAt(new Date())` can name an instant the page has already passed. Pause at a fixed offset from an explicit
// install time instead; the offset only has to outlast the real time between the two calls.
const start = Date.now()
await page.clock.install({ time: start })
await page.clock.pauseAt(start + 10_000)
await tabB.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(b.title)
await page.clock.runFor(10_000)
await expect(preview).toContainText(b.title)
const box = await tabB.boundingBox()
if (!box) throw new Error("vertical tab has no bounding box")
// The 3px gap cannot split evenly into whole pixels; the upper row keeps the first one.
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 0.5)
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).hover()
await page.clock.runFor(1000)
await expect(preview).toContainText(b.title)
await tabC.hover({ position: { x: box.width - 1, y: box.height / 2 } })
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(c.title)
await expect(preview).toHaveCSS("pointer-events", "none")
await rows.filter({ has: page.locator(`a[href="${sessionHref(d.id)}"]`) }).hover()
await page.clock.runFor(20)
await expect(preview).toHaveCount(1)
await expect(preview).toContainText(d.title)
const card = await preview.boundingBox()
if (!card) throw new Error("tab preview has no bounding box")
// The card is informational: entering its painted area leaves the tab strip.
await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2)
await page.clock.runFor(20)
await expect(preview).toHaveCount(0)
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 20, { steps: 8 })
await page.clock.runFor(1000)
await expect(preview).toHaveCount(0)
await page.mouse.up()
// Removing an open tab must not leave the shared preview state permanently warm.
await tabB.hover()
await page.clock.runFor(800)
await expect(preview).toContainText(b.title)
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
await page.mouse.move(900, 500)
await page.clock.runFor(600)
await tabC.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
await page.clock.runFor(50)
await expect(preview).toContainText(c.title)
})
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
const requests: string[] = []
page.on("request", (request) => {
if (request.method() !== "OPTIONS") requests.push(request.url())
})
await twoServers(page, { a: [sessionA], b: [sessionB] })
await page.goto(sessionHref(sessionA.id))
await expectSessionTitle(page, sessionA.title)
await page
.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(sessionA.id)}"])`)
.getByRole("button", { name: "Close tab", exact: true })
.click()
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
await expectSessionTitle(page, sessionB.title)
const reads = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(reads.length).toBeGreaterThan(0)
expect(reads.filter((url) => !url.startsWith(REMOTE_SERVER))).toEqual([])
})
test("a remote tab stays busy while a child session runs", async ({ page }) => {
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
const child = session({ id: "ses_server_b_child", directory: sessionB.directory, parentID: sessionB.id })
await twoServers(page, { a: [sessionA], b: [sessionB, child], running: child.id })
await page.goto(sessionHref(sessionB.id, REMOTE_SERVER))
await expectSessionTitle(page, sessionB.title)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(sessionB.id, REMOTE_SERVER)}"])`)
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(sessionA.id)}"])`)
await expect(tabB.locator('[data-component="activity-ring"]')).toBeVisible()
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
await expect(tabA.locator('[data-component="activity-ring"]')).toBeHidden()
})
test("inactive tabs stay busy while work waits in their inbox, and pulse on a new prompt, as in the TUI", async ({
page,
}) => {
// An inactive tab reads its inbox after a delay. The mock's inbox never gains pushed items, so a prompt pushed
// before that read starts would be replaced by the older snapshot.
const inboxC = page.waitForResponse(new RegExp(`/api/session/${c.id}/inbox(\\?|$)`))
const workspace = await openSession(page, {
name: "TabInbox",
sessions: [a, b, c],
inbox: [
{
id: "inb_tab_b",
sessionID: b.id,
time: { created: 1 },
type: "user",
payload: { text: "Queued follow-up" },
delivery: "queue",
},
// Parked synthetic context, such as a user shell's output, waits without work.
{
id: "inb_tab_c",
sessionID: c.id,
time: { created: 1 },
type: "synthetic",
payload: { text: "Shell output", description: "Shell finished" },
delivery: "steer",
},
],
})
const tab = (id: string) => page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(id)}"])`)
const progress = '[data-component="activity-ring"]'
await expect(tab(b.id).locator(progress)).toBeVisible()
await inboxC
await expect(tab(c.id).locator("[data-titlebar-tab-title]")).toHaveText(c.title)
await expect(tab(c.id).locator(progress)).toBeHidden()
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
// The pulse lasts one animation, so record that it appeared rather than racing its removal.
await tab(c.id).evaluate((element) => {
const observer = new MutationObserver(() => {
if (!element.querySelector('[data-slot="tab-prompt-pulse"]')) return
element.setAttribute("data-test-pulsed", "")
observer.disconnect()
})
observer.observe(element, { childList: true, subtree: true })
})
await workspace.push([
{
id: "evt_tab_c_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: c.id, seq: 1, version: 1 },
data: {
sessionID: c.id,
inboxID: "inb_tab_c_prompt",
item: { type: "user", payload: { text: "Another client's prompt" }, delivery: "queue" },
},
} satisfies Extract<OpenCodeEvent, { type: "session.inbox.enqueued" }>,
])
await expect(tab(c.id)).toHaveAttribute("data-test-pulsed", "")
await expect(tab(c.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
await expect(tab(c.id).locator(progress)).toBeVisible()
await expect(tab(a.id).locator('[data-slot="tab-prompt-pulse"]')).toHaveCount(0)
})
test("switching between busy sessions keeps the busy indicators and the activity capsule at rest", async ({ page }) => {
const children = [
{ id: "ses_tab_a_child", title: "Map the orchard", parentID: a.id },
{ id: "ses_tab_b_child", title: "Chart the harbor", parentID: b.id },
]
await mockWorkspace(page, {
name: "TabActivity",
sessions: [a, b, ...children],
sessionStatus: Object.fromEntries(children.map((child) => [child.id, { type: "running" as const }])),
seed: { settings: { appearance: { tabLayout: "vertical" } }, tabs: [a.id, b.id] },
})
await page.goto(sessionHref(a.id))
await expectSessionTitle(page, a.title)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const indicators = sidebar.locator('[data-component="activity-ring"]')
const capsule = page.locator('[data-slot="session-activity-chip"]')
await expect(indicators).toHaveCount(2)
await expect(capsule).toHaveText("1 subagent")
await expect(capsule).toHaveCSS("opacity", "1")
// Record any capsule animation or any moment without a capsule, and mark the indicators to compare them later. The
// capsule's spinner always runs its own phase-synced loop, so only an animation of the capsule itself counts.
await page.evaluate(() => {
const record = document.documentElement
document.addEventListener("animationstart", (event) => {
if (event.target instanceof Element && event.target.matches('[data-slot="session-activity-chip"]'))
record.setAttribute("data-test-capsule-animated", "")
})
new MutationObserver(() => {
if (!document.querySelector('[data-slot="session-activity-chip"]'))
record.setAttribute("data-test-capsule-missing", "")
}).observe(document.body, { childList: true, subtree: true })
document
.querySelectorAll('[data-slot="vertical-tabs-sidebar"] [data-component="activity-ring"]')
.forEach((element) => element.setAttribute("data-test-before-switch", ""))
})
await sidebar.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click()
await expectSessionTitle(page, b.title)
await expect(page.locator('[data-component="session-activity-chips"]')).toHaveCount(1)
await expect(capsule).toHaveText("1 subagent")
await capsule.click()
await expect(page.locator('[data-slot="session-activity-panel"]')).toContainText(children[1]!.title)
await expect(indicators.and(page.locator("[data-test-before-switch]"))).toHaveCount(2)
await expect(page.locator("html")).not.toHaveAttribute("data-test-capsule-missing")
await expect(page.locator("html")).not.toHaveAttribute("data-test-capsule-animated")
})
test("selecting a tab with waiting work waits for its transcript instead of showing only the inbox", async ({
page,
}) => {
await openSession(page, {
name: "TabInboxTranscript",
sessions: [a, b],
pageMessages: (id) => ({
items:
id === b.id ? [{ id: "msg_tab_b_history", type: "user", text: "Earlier prompt", time: { created: 1 } }] : [],
}),
inbox: [
{
id: "inb_tab_b_steer",
sessionID: b.id,
time: { created: 2 },
type: "user",
payload: { text: "Pending steer" },
delivery: "steer",
},
],
})
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
// The inactive tab read its inbox, which materializes the pending steer as a transcript row.
await expect(tabB.locator('[data-component="activity-ring"]')).toBeVisible()
const transcript = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}/message`)
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`).click()
await transcript.arrived
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
transcript.release()
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_tab_b_history"]')).toContainText(
"Earlier prompt",
)
await expect(page.locator('[data-timeline-row="UserMessage"][data-message-id="inb_tab_b_steer"]')).toContainText(
"Pending steer",
)
})
test("inactive tabs load attention and inbox, but read the transcript only on selection", async ({ page }) => {
const reads: string[] = []
const mutations: string[] = []
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
page.on("response", (response) => {
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
errors.push(`HTTP ${response.status()}: ${response.url()}`)
})
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (!path.startsWith("/api/")) return
if (request.method() === "GET") reads.push(path)
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
})
const state = { text: "Original fixture answer" }
await mockStressTimeline(page, {
sessionStatus: { [fixture.childID]: { type: "running" } },
pageMessages: (id) => ({
items: [
{ id: `msg_${id}_user`, type: "user", text: "Review the renderer change", time: { created: 1 } },
{
id: `msg_${id}_assistant`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
content: [{ type: "text", text: state.text }],
time: { created: 2, completed: 3 },
},
],
}),
})
await seed(page, {
tabs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
const attention = Promise.all([
...["permission", "form", "inbox"].map((kind) =>
page.waitForResponse(
(response) => new URL(response.url()).pathname === `/api/session/${fixture.childID}/${kind}`,
),
),
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${fixture.targetID}/inbox`),
])
await page.goto(sessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
state.text,
)
await attention
expect(reads.filter((path) => path === `/api/session/${fixture.targetID}/permission`)).toEqual([])
expect(reads.filter((path) => path === `/api/session/${fixture.targetID}/form`)).toEqual([])
const child = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(fixture.childID)}"])`)
await child.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(child).toHaveCount(0)
await expectPath(page, sessionHref(fixture.sourceID))
state.text = "Latest fixture answer after tab restoration"
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(fixture.targetID)}"]`).click()
await expectSessionTitle(page, fixture.expected.targetTitle)
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
state.text,
)
// Every tab reads its inbox once, since waiting work keeps a tab busy; selection reuses that read.
for (const id of [fixture.sourceID, fixture.targetID, fixture.childID])
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
for (const id of [fixture.sourceID, fixture.targetID])
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
expect(reads.filter((path) => path === `/api/session/${fixture.childID}/message`)).toEqual([])
expect(mutations).toEqual([])
expect(errors).toEqual([])
})
test("five loaded workspace tabs stay rendered and reactive through repeated switches", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const sessions = Array.from({ length: 5 }, (_, index) => ({
...fixture.sessions[0]!,
id: `ses_workspace_cycle_${index}`,
directory: `${fixture.directory}/worktree-${index}`,
title: `Workspace session ${index}`,
}))
const mock = await mockStressTimeline(page, {
sessions,
pageMessages: (id) => ({
items: [
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
{
id: `msg_assistant_${id}`,
type: "assistant",
agent: "build",
model: { id: "claude-opus-4-6", providerID: "opencode" },
time: { created: 2, completed: 3 },
content: [{ type: "text", text: `Answer for ${id}` }],
},
] satisfies SessionMessageInfo[],
}),
})
// Each worktree session resolves to its own location in the shared project (the mock echoes the requested one).
await seed(page, {
tabs: sessions.map((item) => item.id),
})
await page.goto(sessionHref(sessions[0]!.id))
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
for (const item of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(item.id)}"]`).click()
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${item.id}:text:0"]`)).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
}
const active = sessions[0]!
await mock.push([
{
id: "evt_workspace_cycle_update",
created: 4,
type: "session.text.ended",
location: { directory: active.directory },
durable: { aggregateID: active.id, seq: 0, version: 1 },
data: {
sessionID: active.id,
assistantMessageID: `msg_assistant_${active.id}`,
ordinal: 0,
text: "Still receiving updates",
},
},
])
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
})
test("each session tab shows its own file tab after a switch to a session in another folder", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/FolderSwitch"
const alpha = { id: "ses_folderswitch_alpha", title: "Folder switch alpha" }
const beta = { id: "ses_folderswitch_beta", title: "Folder switch beta" }
const other = { id: "ses_folderswitch_other", title: "Folder switch other", directory: "C:/OpenCode/FolderOther" }
await openSession(page, {
name: "FolderSwitch",
sessions: [alpha, beta, other],
fileList: (path) => (path ? [] : ["greet.ts", "guide.md", "notes.txt"].map((file) => fileNode(directory, file))),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: { panes: Object.fromEntries([alpha, beta, other].map((item) => [item.id, { review: true }])) },
})
const panel = page.locator("#review-panel")
const shows = async (file: string) => {
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText(`contents:${file}`, { exact: true })).toBeVisible()
}
const visit = async (target: { id: string; title: string }) => {
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(target.id)}"]`).click()
await expectSessionTitle(page, target.title)
}
const open = async (file: string) => {
await openFileLauncher(panel)
await panel.getByRole("treeitem", { name: file, exact: true }).click()
await shows(file)
}
await open("greet.ts")
await visit(other)
await open("notes.txt")
await visit(beta)
await open("guide.md")
// The session screen stays mounted while the route moves between folders, and each tab reads its own folder.
for (const [target, file] of [
[alpha, "greet.ts"],
[other, "notes.txt"],
[beta, "guide.md"],
[other, "notes.txt"],
[alpha, "greet.ts"],
] as const) {
await visit(target)
await shows(file)
}
})
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/SidePanelTabs"
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
await openSession(page, {
name: "SidePanelTabs",
sessions: [alpha, beta],
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: {
panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } },
storage: reviewTabs(directory, alpha.id),
},
})
const panel = page.locator("#review-panel")
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await openFileLauncher(panel)
await panel.getByRole("treeitem", { name: "README.md", exact: true }).click()
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
await composer.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expectPath(page, sessionHref(alpha.id))
await expect(fileTab).toHaveAttribute("aria-selected", "true")
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
await panel.getByText("contents:README.md", { exact: true }).click()
await page.keyboard.press("Control+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Shift+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ControlOrMeta+w")
await expect(fileTab).toHaveCount(0)
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await reviewTab.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
})
test("⌘W past the side panel's last tab keeps the session tab", async ({ page }) => {
const sessions = await emptySidePanel(page)
await page.keyboard.press("ControlOrMeta+w")
// From the composer ⌘W closes the session tab, so only a swallowed press above leaves Beta to land on.
await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
await page.keyboard.press("ControlOrMeta+w")
await expectPath(page, sessionHref(sessions.beta.id))
await expect(sessions.alphaTab).toHaveCount(0)
})
test("⌘W past the side panel's last tab keeps the session tab when focus leaves the panel just after", async ({
page,
}) => {
// Stands in for a panel that hands focus back after its close rather than during it.
await page.addInitScript(() => {
const observer = new MutationObserver((records) => {
const hidden = records.some(
(record) =>
record.target instanceof Element &&
record.target.id === "review-panel" &&
record.target.getAttribute("aria-hidden") === "true",
)
if (!hidden) return
observer.disconnect()
document.querySelector<HTMLElement>('[data-component="composer-editor"]')?.focus()
})
observer.observe(document, { subtree: true, attributes: true, attributeFilter: ["aria-hidden"] })
})
const sessions = await emptySidePanel(page)
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(composer).toBeFocused()
await page.keyboard.press("ControlOrMeta+w")
// Only a swallowed press above leaves Beta for this one to land on.
await composer.click()
await page.keyboard.press("ControlOrMeta+w")
await expectPath(page, sessionHref(sessions.beta.id))
await expect(sessions.alphaTab).toHaveCount(0)
})
test("⌘W closes the session tab again once the window loses focus after the side panel empties", async ({ page }) => {
const sessions = await emptySidePanel(page)
// ⌘Tab away and back sends no click or key the page sees.
await page.evaluate(() => window.dispatchEvent(new Event("blur")))
await page.keyboard.press("ControlOrMeta+w")
await expectPath(page, sessionHref(sessions.beta.id))
await expect(sessions.alphaTab).toHaveCount(0)
})
test("⌘W in the side panel closes its last New tab with the panel, and only then the session tab", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const alpha = { id: "ses_side_new_alpha", title: "Side new alpha" }
const beta = { id: "ses_side_new_beta", title: "Side new beta" }
await openSession(page, { name: "SideCloseNewTab", sessions: [alpha, beta] })
const panel = page.locator("#review-panel")
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const newTab = panel.getByRole("tab", { name: "New tab", exact: true })
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
const alphaTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`)
await composer.click()
await page.keyboard.press("ControlOrMeta+Shift+R")
await expect(newTab).toHaveAttribute("aria-selected", "true")
await expect.poll(() => panel.evaluate((element) => element.contains(document.activeElement))).toBe(true)
// A launcher row in the New tab view holds focus as ⌘W removes it.
await panel.getByRole("button", { name: /^Open file/ }).focus()
// The New tab was the panel's last tab, so closing it closes the panel too, and focus goes back.
await page.keyboard.press("ControlOrMeta+w")
await expect(newTab).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(composer).toBeFocused()
// One press too many closes nothing rather than the session.
await page.keyboard.press("ControlOrMeta+w")
await expect(alphaTab).toHaveCount(1)
await expectPath(page, sessionHref(alpha.id))
// With no panel open, ⌘W from the composer closes the session tab.
await composer.click()
await page.keyboard.press("ControlOrMeta+w")
await expectPath(page, sessionHref(beta.id))
await expect(alphaTab).toHaveCount(0)
})
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
await page.goto(`/e2e/utils/windows-menu.html?${new URLSearchParams({ server: SERVER })}`)
await page.getByRole("button", { name: "OpenCode menu", exact: true }).click()
await page.getByRole("menuitem", { name: "Edit", exact: true }).click()
const paste = page.getByRole("menuitem", { name: /^Paste/ })
await expect(paste).toHaveText("PasteCtrl+V")
await paste.click()
await expect(page.getByRole("status", { name: "Desktop menu actions" })).toHaveText("edit.paste")
})
test("the Windows menu shares the vertical sidebar's icon lane", async ({ page }) => {
await mockWorkspace(page, {
name: "WindowsMenu",
sessions: [],
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(`/e2e/utils/windows-menu.html?${new URLSearchParams({ server: SERVER })}`)
const menu = page.getByRole("button", { name: "OpenCode menu", exact: true }).locator("svg")
const home = page.locator('[data-action="vertical-tabs-home"] svg')
await expect(home).toBeVisible()
await expect.poll(async () => [(await menu.boundingBox())?.x, (await home.boundingBox())?.x]).toEqual([19, 19])
})
for (const row of [
{ name: "left tabs", width: 1280, settings: { appearance: { tabLayout: "vertical" } }, position: { x: 20, y: 25 } },
{ name: "top tabs", width: 1280, settings: { appearance: { tabLayout: "horizontal" } }, position: { x: 14, y: 15 } },
{
name: "a bottom titlebar",
width: 700,
settings: { general: { mobileTitlebarPosition: "bottom" } },
position: { x: 14, y: 14 },
},
]) {
test(`macOS traffic lights center on the header line with ${row.name}`, async ({ page }) => {
await page.setViewportSize({ width: row.width, height: 720 })
await mockWorkspace(page, { name: "TrafficLights", sessions: [], seed: { settings: row.settings } })
await page.goto(`/e2e/utils/windows-menu.html?${new URLSearchParams({ server: SERVER, os: "macos" })}`)
await expect(page.getByRole("status", { name: "Traffic lights" })).toHaveText(JSON.stringify(row.position))
})
}
// Server A is the default origin; B is a remote that answers only for its own directories.
async function twoServers(
page: Page,
input: {
a: ReturnType<typeof session>[]
b: ReturnType<typeof session>[]
running?: string
},
) {
const config = (sessions: ReturnType<typeof session>[], id: string) => {
const directory = String(sessions[0]!.directory)
return {
directory,
project: project({ id, directory }),
provider: NO_PROVIDER,
sessions,
pageMessages: () => ({ items: [] }),
strictDirectory: true,
}
}
await mockServers(page, {
[SERVER]: config(input.a, "proj_server_a"),
[REMOTE_SERVER]: {
...config(input.b, "proj_server_b"),
sessionStatus: input.running ? { [input.running]: { type: "running" } } : {},
},
})
await seed(page, {
servers: [REMOTE_SERVER],
tabs: [input.a[0]!.id, { session: input.b[0]!.id, server: REMOTE_SERVER }],
})
}
test("loads a tab's transcript while the pointer rests on it, and not when it passes over", async ({ page }) => {
const requested: string[] = []
page.on("request", (request) => {
const id = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)?.[1]
if (id && request.method() === "GET") requested.push(id)
})
await mockWorkspace(page, {
name: "HoverPrefetch",
sessions: [a, b, c],
pageMessages: pageMessagesFrom(
Object.fromEntries(
[a, b, c].map((item): [string, SessionMessageInfo[]] => [
item.id,
[{ id: `msg_${item.id}`, type: "user", time: { created: 1700000000000 }, text: item.title }],
]),
),
),
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
await expectSessionTitle(page, a.title)
const tab = (id: string) => page.locator(`[data-slot="vertical-tabs"] a[href="${sessionHref(id, SERVER)}"]`)
const message = (id: string) => page.locator(`[data-timeline-virtual-content] [data-message-id="msg_${id}"]`)
await expect(message(a.id)).toBeVisible()
// Raw moves keep the pass well under the dwell, which hover() actionability checks can exceed on slow runners.
const [passed, rest] = await Promise.all([tab(c.id).boundingBox(), tab(a.id).boundingBox()])
await page.mouse.move(passed!.x + passed!.width / 2, passed!.y + passed!.height / 2)
await page.mouse.move(rest!.x + rest!.width / 2, rest!.y + rest!.height / 2)
await tab(b.id).hover()
await expect.poll(() => requested.includes(b.id)).toBe(true)
// A dwell started by the pass would have fired before the one on the rested tab.
expect(requested).not.toContain(c.id)
await tab(b.id).click()
await expect(message(b.id)).toBeVisible()
expect(requested.filter((id) => id === b.id)).toHaveLength(1)
})
test("⌘W in a focused Context tab closes it, then the panel, and keeps the session tab", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const alpha = { id: "ses_side_context_alpha", title: "Side context alpha" }
const beta = { id: "ses_side_context_beta", title: "Side context beta" }
await openSession(page, { name: "SideCloseContext", sessions: [alpha, beta] })
const panel = page.locator("#review-panel")
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
const context = panel.getByRole("tab", { name: "Context", exact: true })
const alphaTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`)
await page.getByRole("button", { name: "Toggle session context" }).click()
await expect(context).toHaveAttribute("aria-selected", "true")
await panel.click({ position: { x: 100, y: 300 } })
await page.keyboard.press("ControlOrMeta+w")
await expect(context).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(alphaTab).toHaveCount(1)
await expectPath(page, sessionHref(alpha.id))
})
// Closes Alpha's side tabs with ⌘W until the side panel empties and collapses, as an overshooting hand would.
async function emptySidePanel(page: Page) {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/SideCloseOvershoot"
const alpha = { id: "ses_side_close_alpha", title: "Side close alpha" }
const beta = { id: "ses_side_close_beta", title: "Side close beta" }
await openSession(page, {
name: "SideCloseOvershoot",
sessions: [alpha, beta],
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: {
panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } },
storage: reviewTabs(directory, alpha.id),
},
})
const panel = page.locator("#review-panel")
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
await openFileLauncher(panel)
await panel.getByRole("treeitem", { name: "README.md", exact: true }).click()
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await panel.getByText("contents:README.md", { exact: true }).click()
await page.keyboard.press("ControlOrMeta+w")
await expect(fileTab).toHaveCount(0)
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ControlOrMeta+w")
await expect(panel).toHaveAttribute("aria-hidden", "true")
return { beta, alphaTab: page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) }
}
test("opening a session from the inbox keeps the list's scroll position and rows", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 480 })
const sessions = Array.from({ length: 24 }, (_, index) => ({
id: `ses_inbox_${String(index).padStart(2, "0")}`,
title: `Inbox session ${index}`,
directory: index === 14 ? "C:/OpenCode/Inbox/worktrees/unopened" : "C:/OpenCode/Inbox",
time: { created: T0, updated: T0 + 100 - index, idle: T0 + index, viewed: T0 + 100 },
}))
await mockWorkspace(page, {
name: "Inbox",
sessions,
vcs: { current: "dev", default: "dev" },
seed: {
settings: { appearance: { tabLayout: "inbox" } },
tabs: [],
storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
},
})
await page.goto("/")
const list = page.locator('[data-slot="sidebar-inbox-scroll"]')
const items = list.locator('[data-slot="sidebar-inbox-item"]')
const target = items.filter({ hasText: "Inbox session 14" })
await expect(items).toHaveCount(10)
await expect(items).toContainText(Array.from({ length: 10 }, (_, index) => `Inbox session ${23 - index}`))
await expect(list.getByText("Recent", { exact: true })).toBeVisible()
await list.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(target).toBeInViewport({ ratio: 1 })
// Git metadata must load for an unopened worktree, not only after selecting its session.
await target.locator('[data-component="tooltip-v2-trigger"]:has(use[href="#opencode-v2-icon-branch"])').hover()
await expect(page.getByRole("tooltip")).toHaveText("dev")
const offset = await list.evaluate((element) => element.scrollTop)
expect(offset).toBeGreaterThan(0)
const row = await target.elementHandle()
await target.click()
await expectPath(page, sessionHref("ses_inbox_14"))
await expect(target).toHaveAttribute("data-state", "active")
// Opening a session loads it into the client, which updates every section.
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
expect(await target.evaluate((element, before) => element === before, row)).toBe(true)
expect(await list.evaluate((element) => element.scrollTop)).toBe(offset)
})
test("renaming an inbox card updates the same session on Home", async ({ page }) => {
await mockWorkspace(page, {
name: "RenameInbox",
sessions: [
{ id: "ses_rename_inbox", title: "Original inbox title" },
{ id: "ses_rename_kept", title: "Unchanged title" },
],
seed: { settings: { appearance: { tabLayout: "inbox" } }, tabs: [] },
})
await page.goto("/")
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const home = page.locator('[data-component="home-session-row-container"][data-session-id="ses_rename_inbox"]')
await expect(home).toContainText("Original inbox title")
await inbox
.locator('[data-slot="sidebar-inbox-item"]')
.filter({ hasText: "Original inbox title" })
.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox").fill("Renamed inbox title")
await dialog.getByRole("button", { name: "Save", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(
inbox.locator('[data-slot="sidebar-inbox-item"]').filter({ hasText: "Renamed inbox title" }),
).toHaveCount(1)
await expect(inbox.getByText("Original inbox title", { exact: true })).toHaveCount(0)
await expect(inbox.getByText("Unchanged title", { exact: true })).toBeVisible()
await expect(home).toContainText("Renamed inbox title")
})
test("the inbox Archived dialog searches only archived sessions and restores the selected session", async ({
page,
}) => {
await page.clock.install({ time: T0 + 41 * 60_000 })
await mockWorkspace(page, {
name: "ArchivedInbox",
sessions: [
{ id: "ses_archive_active", title: "Active inbox session", time: { created: T0, updated: T0, idle: T0 } },
...["First archived session", "Second archived session"].map((title, index) => ({
id: `ses_archive_${index}`,
title,
time: { created: T0 - 100, updated: T0 + index, idle: index ? T0 - 100 : T0, archived: T0 + 10 },
})),
],
seed: {
settings: { appearance: { tabLayout: "inbox" } },
tabs: [],
storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
},
})
await page.goto("/")
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const archived = inbox.getByRole("button", { name: "Archived", exact: true })
const rows = inbox.locator('[data-slot="sidebar-inbox-item"]')
await expect(rows).toHaveCount(1)
await expect(rows).toContainText("Active inbox session")
await expect(archived).toBeVisible()
await archived.focus()
await archived.press("Enter")
const dialog = page.getByRole("dialog")
const search = dialog.getByRole("combobox")
await expect(search).toBeFocused()
await expect(search).toHaveAttribute("placeholder", "Search sessions")
await expect(dialog.getByText("Archived sessions", { exact: true })).toBeVisible()
await expect(dialog.getByRole("option")).toHaveCount(2)
await expect(dialog.getByRole("option", { name: /First archived session/ })).toBeVisible()
await expect(dialog.getByRole("option", { name: /Second archived session/ })).toBeVisible()
await search.fill("Active inbox session")
await expect(dialog.getByText("No results found", { exact: true })).toBeVisible()
await search.fill("First archived")
await expect(dialog.getByRole("option")).toHaveCount(1)
const option = dialog.getByRole("option", { name: /First archived session/ })
await expect(option).toHaveAttribute("aria-selected", "true")
// A second Enter while the restore is in flight restores and opens the session once.
const restore = await holdRoute(page, /\/api\/session\/ses_archive_0(\?|$)/, { method: "PATCH" })
await search.press("Enter")
await restore.arrived
await expect(option).toHaveAttribute("aria-busy", "true")
await expect(option.getByRole("img", { name: "Loading", exact: true })).toBeVisible()
await search.press("Enter")
await expect(dialog).toBeVisible()
restore.release()
await expect(dialog).toHaveCount(0)
expect(restore.requests).toHaveLength(1)
await expectPath(page, sessionHref("ses_archive_0"))
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(rows.filter({ hasText: "First archived session" })).toHaveAttribute("data-state", "active")
})
test("deleting from the inbox closes its dialog at once and a slow delete leaves the next dialog open", async ({
page,
}) => {
await mockWorkspace(page, {
name: "DeleteInbox",
sessions: [
{ id: "ses_delete_kept", title: "Kept inbox session", time: { created: T0, updated: T0, idle: T0, viewed: T0 } },
{
id: "ses_delete_target",
title: "Deleted inbox session",
time: { created: T0, updated: T0 + 1, idle: T0 + 1, viewed: T0 + 1 },
},
],
seed: { settings: { appearance: { tabLayout: "inbox" } }, tabs: [] },
})
const remove = await holdRoute(page, /\/api\/session\/ses_delete_target(\?|$)/, { method: "DELETE" })
await page.goto("/")
const rows = page.locator('[data-slot="sidebar-inbox"] [data-slot="sidebar-inbox-item"]')
await expect(rows).toHaveCount(2)
await rows.filter({ hasText: "Deleted inbox session" }).click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete…", exact: true }).click()
const confirm = page.getByRole("dialog", { name: "Delete session" })
await confirm.getByRole("button", { name: "Delete session", exact: true }).click()
await remove.arrived
await expect(confirm).toHaveCount(0)
await expect(rows).toHaveCount(1)
await expect(rows).toContainText("Kept inbox session")
await page.getByRole("button", { name: "Search sessions", exact: true }).click()
const search = page.getByRole("dialog")
await expect(search.getByRole("combobox")).toBeFocused()
const removed = page.waitForResponse(
(response) => response.request().method() === "DELETE" && response.url().includes("/api/session/ses_delete_target"),
)
remove.release()
await removed
await expect(search.getByRole("combobox")).toBeFocused()
expect(remove.requests).toHaveLength(1)
})
test("opening a working session expands its shelf and still allows manual collapse", async ({ page }) => {
await openSession(page, {
name: "WorkingInbox",
sessions: [{ id: "ses_working_selected", title: "Selected working session" }],
sessionStatus: { ses_working_selected: { type: "running" } },
seed: {
settings: { appearance: { tabLayout: "inbox" } },
storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: false } },
},
})
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const shelf = inbox.locator('[data-slot="sidebar-inbox-working"]')
const toggle = shelf.getByRole("button", { name: "Working", exact: true })
const row = shelf.locator('[data-slot="sidebar-inbox-item"]')
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(row).toHaveAttribute("data-state", "active")
await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(row).toHaveCount(0)
await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expectPath(page, sessionHref("ses_working_selected"))
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(row).toHaveAttribute("data-state", "active")
await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
})
test("sessions opened from Home and Search get a temporary Current session row without duplicating Recent", async ({
page,
}) => {
await mockWorkspace(page, {
name: "CurrentInbox",
sessions: [
{ id: "ses_current_home", title: "Older Home session", time: { created: T0, updated: T0, idle: T0, viewed: T0 } },
{
id: "ses_current_search",
title: "Older Search session",
time: { created: T0, updated: T0, idle: T0, viewed: T0 },
},
{
id: "ses_current_recent",
title: "Recent inbox session",
time: { created: T0, updated: T0 + 2, idle: T0 + 2, viewed: T0 + 2 },
},
...Array.from({ length: 9 }, (_, index) => ({
id: `ses_current_extra_${index}`,
title: `Other recent session ${index}`,
time: { created: T0, updated: T0 + 3 + index, idle: T0 + 3 + index, viewed: T0 + 3 + index },
})),
],
seed: {
settings: { appearance: { tabLayout: "inbox" } },
tabs: [],
storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
},
})
await page.goto("/")
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const rows = inbox.locator('[data-slot="sidebar-inbox-item"]')
await expect(rows).toHaveCount(10)
await page.locator('[data-component="home-session-row"]').filter({ hasText: "Older Home session" }).click()
await expectPath(page, sessionHref("ses_current_home"))
await expect(rows.filter({ hasText: "Older Home session" })).toHaveAttribute("data-state", "active")
await expect(inbox.getByText("Current session", { exact: true })).toHaveCount(0)
await expect(rows).toHaveCount(11)
await rows.filter({ hasText: "Recent inbox session" }).click()
await expectPath(page, sessionHref("ses_current_recent"))
await expect(rows).toHaveCount(10)
await expect(rows.filter({ hasText: "Recent inbox session" })).toHaveAttribute("data-state", "active")
await page.getByRole("button", { name: "Search sessions", exact: true }).click()
const dialog = page.getByRole("dialog")
const search = dialog.getByRole("combobox")
await expect(search).toBeFocused()
await search.fill("Older Search session")
await expect(dialog.getByRole("option")).toHaveCount(1)
await search.press("Enter")
await expectPath(page, sessionHref("ses_current_search"))
await expect(rows.filter({ hasText: "Older Search session" })).toHaveAttribute("data-state", "active")
await expect(rows.filter({ hasText: "Older Home session" })).toHaveCount(0)
await expect(rows).toHaveCount(11)
await page.getByRole("button", { name: "Home", exact: true }).click()
await expectPath(page, "/")
await expect(rows).toHaveCount(10)
})
test("the inbox keeps every unread result separate from expandable Recent, including never-run sessions", async ({
page,
}) => {
await mockWorkspace(page, {
name: "InboxSections",
sessions: [
...Array.from({ length: 12 }, (_, index) => ({
id: `ses_unread_${index}`,
title: `Unread result ${index}`,
outcome: index === 0 ? "failed" : "succeeded",
time: { created: T0, updated: T0 + index, idle: T0 + index },
})),
...Array.from({ length: 12 }, (_, index) => ({
id: `ses_read_${index}`,
title: index === 0 ? "Never-run session" : `Read session ${index}`,
time: { created: T0, updated: T0 + 100 - index, idle: index ? T0 + 100 - index : undefined, viewed: T0 + 200 },
})),
],
seed: { settings: { appearance: { tabLayout: "inbox" } }, tabs: [] },
})
await page.goto("/")
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const unread = inbox.getByRole("group", { name: "Unread", exact: true }).locator('[data-slot="sidebar-inbox-item"]')
const recent = inbox.getByRole("group", { name: "Recent", exact: true }).locator('[data-slot="sidebar-inbox-item"]')
await expect(unread).toContainText(Array.from({ length: 12 }, (_, index) => `Unread result ${11 - index}`))
await expect(unread.filter({ hasText: "Unread result 0" })).toHaveAttribute("data-status", "failed")
await expect(recent).toContainText([
"Never-run session",
...Array.from({ length: 9 }, (_, index) => `Read session ${index + 1}`),
])
await expect(recent).toHaveCount(10)
await expect(inbox.locator('[data-slot="sidebar-inbox-item"][tabindex="0"]')).toHaveCount(1)
await unread.filter({ hasText: "Unread result 11" }).focus()
await page.keyboard.press("ArrowDown")
await expect(unread.filter({ hasText: "Unread result 10" })).toBeFocused()
await page.keyboard.press("End")
await expect(recent.filter({ hasText: "Read session 9" })).toBeFocused()
await page.keyboard.press("Home")
await expect(unread.filter({ hasText: "Unread result 11" })).toBeFocused()
await inbox.getByRole("button", { name: "Show more", exact: true }).click()
await expect(recent).toHaveCount(12)
await expect(recent.filter({ hasText: "Read session 11" })).toHaveCount(1)
await expect(unread).toHaveCount(12)
await page.reload()
await expect(recent).toHaveCount(12)
await inbox.getByRole("button", { name: "Show less", exact: true }).click()
await expect(recent).toHaveCount(10)
await expect(unread).toHaveCount(12)
const marked = page.getByText("Read session 1", { exact: true })
await recent.filter({ has: marked }).click({ button: "right" })
await page.getByRole("menuitem", { name: "Mark as unread", exact: true }).click()
await expect(unread.filter({ has: marked })).toHaveCount(1)
await expect(recent.filter({ has: marked })).toHaveCount(0)
await unread.filter({ has: marked }).click({ button: "right" })
await page.getByRole("menuitem", { name: "Mark as read", exact: true }).click()
await expect(recent.filter({ has: marked })).toHaveCount(1)
await expect(unread.filter({ has: marked })).toHaveCount(0)
})
test("the inbox waits for initial requests and orders attention by waiting age rather than last update", async ({
page,
}) => {
await page.clock.install({ time: T0 + 10 * 60_000 })
const workspace = await mockWorkspace(page, {
name: "InboxWaiting",
sessions: [
{
id: "ses_waiting_old",
title: "Oldest waiting session",
time: { created: T0, updated: T0 + 60_000, busy: T0 },
},
{
id: "ses_waiting_new",
title: "Newer waiting session",
time: { created: T0, updated: T0 + 9 * 60_000, busy: T0 + 5 * 60_000 },
},
{
id: "ses_waiting_live",
title: "New live question",
time: { created: T0, updated: T0 + 2 * 60_000, busy: T0 + 60_000 },
},
],
sessionStatus: {
ses_waiting_old: { type: "running" },
ses_waiting_new: { type: "running" },
ses_waiting_live: { type: "running" },
},
sessionPermissions: {
ses_waiting_old: [{ id: "perm_inbox_old", sessionID: "ses_waiting_old", action: "shell", resources: ["build"] }],
ses_waiting_new: [{ id: "perm_inbox_new", sessionID: "ses_waiting_new", action: "shell", resources: ["test"] }],
},
seed: { settings: { appearance: { tabLayout: "inbox" } }, tabs: [] },
})
const pending = await holdRoute(page, /\/api\/session\/ses_waiting_old\/permission(?:\?|$)/)
// Running sessions join Working only once this snapshot loads, which can be after the inbox shows; runs it reveals
// had already started, so they are not announced.
const running = await holdRoute(page, /\/api\/session\/active(?:\?|$)/)
await page.goto("/")
await Promise.all([pending.arrived, running.arrived])
const inbox = page.locator('[data-slot="sidebar-inbox"]')
await expect(inbox).toHaveAttribute("aria-busy", "true")
await expect(inbox.getByText("Sessions you work on will appear here", { exact: true })).toHaveCount(0)
const checked = page.waitForResponse(/\/api\/session\/ses_waiting_old\/permission(?:\?|$)/)
pending.release()
await checked
running.release()
const needs = inbox
.getByRole("group", { name: "Needs attention", exact: true })
.locator('[data-slot="sidebar-inbox-item"]')
await expect(needs).toContainText(["Oldest waiting session", "Newer waiting session"])
await expect(inbox).toHaveAttribute("aria-busy", "false")
await expect(inbox.getByRole("status")).toBeEmpty()
workspace.push([
{
id: "evt_inbox_question",
type: "form.created",
created: T0 + 8 * 60_000,
location: { directory: workspace.directory },
data: {
form: {
id: "form_inbox_question",
sessionID: "ses_waiting_live",
title: "Choose a library",
metadata: { kind: "question" },
fields: [{ key: "library", type: "string", title: "Library" }],
},
},
} satisfies Extract<OpenCodeEvent, { type: "form.created" }>,
])
await expect(needs).toContainText(["Oldest waiting session", "Newer waiting session", "New live question"])
await expect(needs.filter({ hasText: "New live question" })).toContainText("2m")
await expect(inbox.getByRole("status")).toHaveText("New live question has a question.")
await expect(inbox.getByRole("status")).toHaveCount(1)
})
test("an inactive tab shows attention once auto-accept gives up on its permission", async ({ page }) => {
const reply = await mockGaveUpPermission(page, "horizontal")
await page.goto(sessionHref(a.id))
const tab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
const dot = tab.locator('[data-mark="attention"]')
// The first reply proves the client holds the request; auto-accept still owns it, so the tab only shows work.
await reply.arrived
await expect(tab.locator('[data-mark="busy"]')).toBeVisible()
await expect(dot).toBeHidden()
reply.release()
await expect(dot).toBeVisible()
await expect(tab.locator('[data-mark="busy"]')).toBeHidden()
expect(reply.attempts()).toBe(3)
})
test("the inbox moves a session to Needs attention once auto-accept gives up on its permission", async ({ page }) => {
const reply = await mockGaveUpPermission(page, "inbox")
await page.goto(sessionHref(a.id))
const inbox = page.locator('[data-slot="sidebar-inbox"]')
const row = inbox.locator('[data-slot="sidebar-inbox-item"]').filter({ hasText: b.title })
const needs = inbox
.getByRole("group", { name: "Needs attention", exact: true })
.locator('[data-slot="sidebar-inbox-item"]')
await reply.arrived
await expect(row).toHaveAttribute("data-status", "working")
await expect(needs).toHaveCount(0)
reply.release()
await expect(needs).toHaveCount(1)
await expect(needs).toContainText(b.title)
await expect(needs).toHaveAttribute("data-status", "approval")
expect(reply.attempts()).toBe(3)
})
// Auto-accept is on and every reply to session B's permission fails. The first reply waits for `release()`; then it
// and both retries fail, and auto-accept gives up.
async function mockGaveUpPermission(page: Page, tabLayout: "horizontal" | "inbox") {
const permissionID = "permission-gave-up"
const released = Promise.withResolvers<void>()
const arrived = Promise.withResolvers<void>()
const attempts = { count: 0 }
await mockWorkspace(page, {
name: "GaveUp",
sessions: [a, b],
sessionPermissions: {
[b.id]: [
{ id: permissionID, sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
seed: {
settings: { appearance: { tabLayout }, permissions: { autoApprove: true } },
storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
},
})
await page.route(new RegExp(`/api/session/${b.id}/permission/${permissionID}/reply(\\?|$)`), async (route) => {
if (route.request().method() !== "POST") return route.fallback()
attempts.count++
arrived.resolve()
if (attempts.count === 1) await released.promise
await route.fulfill({ status: 500, json: { message: "Reply failed" } })
})
return {
arrived: arrived.promise,
release: () => released.resolve(),
attempts: () => attempts.count,
}
}