Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/app/e2e/regression/sidebar-pinned-sessions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
Expand Down Expand Up @@ -145,3 +146,144 @@ test("the View all list grows a Pinned group above the dated ones", async ({ pag
expect(text.indexOf("Pinned")).toBeLessThan(text.indexOf(TARGET))
expect(text.indexOf(TARGET)).toBeLessThan(text.indexOf(SOURCE))
})

// --- coverage for the paths the first pass left unguarded ---

const PINNED_ROW_LIMIT = 5
// Header (28) + gap (4) + five rows at a 32px pitch, less the last row's gap.
const PINNED_MAX_BODY = PINNED_ROW_LIMIT * 32 - 4

function manySessions(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `ses_pin_${index}`,
slug: `pin-${index}`,
projectID: (fixture.project as { id: string }).id,
directory: fixture.directory,
title: `Pinnable session ${index}`,
version: "dev",
// Descending, so index 0 is newest and sits at the top of Recents.
time: { created: 1700000000000 - index, updated: 1700000000000 - index },
}))
}

async function bootWith(page: Page, sessions: ReturnType<typeof manySessions>) {
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
await expect(rowsIn(page, "recents").first()).toBeVisible({ timeout: 30_000 })
}

test("Pinned scrolls past its row limit instead of growing into Recents", async ({ page }) => {
await bootWith(page, manySessions(PINNED_ROW_LIMIT + 2))
const recentsBefore = (await section(page, "recents").boundingBox())!

// Pin one past the limit. Each pin moves that row out of Recents, so the
// first Recents row is a different session every time.
for (let pinned = 0; pinned < PINNED_ROW_LIMIT + 1; pinned++) {
await openFirstRowMenu(page, "recents")
await menuItem(page, "Pin").click()
await expect(rowsIn(page, "pinned")).toHaveCount(pinned + 1)
}

// All six are rendered — Pinned scrolls, unlike Recents, which truncates its
// list to what fits.
await expect(rowsIn(page, "pinned")).toHaveCount(PINNED_ROW_LIMIT + 1)

// ScrollView caps the root and scrolls an inner viewport, so the overflow has
// to be read off `.scroll-view__viewport`, not off the element carrying the
// max-height.
const list = section(page, "pinned").locator('[data-slot="sidebar-pinned-list"]')
const capped = await list.evaluate((el) => el.clientHeight)
expect(capped).toBeLessThanOrEqual(PINNED_MAX_BODY + 1)
const viewport = await list
.locator(".scroll-view__viewport")
.evaluate((el) => ({ client: el.clientHeight, scroll: el.scrollHeight }))
expect(viewport.scroll).toBeGreaterThan(viewport.client)

// And Recents keeps its space rather than being pushed out by the sixth pin.
const recentsAfter = (await section(page, "recents").boundingBox())!
expect(recentsAfter.height).toBeGreaterThan(recentsBefore.height - PINNED_MAX_BODY - 40)
})

test("pinning from the in-session header menu shows up in the sidebar", async ({ page }) => {
await boot(page)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
await expectAppVisible(page.locator("main").getByText(TARGET).first())

// The header resolves the server a pin belongs to independently of the
// sidebar (params.serverKey ?? the current connection). If the two ever
// disagree the pin lands under a key the sidebar does not read, and this row
// never appears — which is exactly the failure DIVERGENCES warns about.
const trigger = page.locator("main").getByRole("button", { name: "More options" }).first()
await expectAppVisible(trigger)
await trigger.click()
await expectAppVisible(menuContent(page))
await expect(menuItem(page, "Pin")).toHaveCount(1)
await menuItem(page, "Pin").click()

await expect(rowsIn(page, "pinned")).toHaveCount(1)
await expect(rowsIn(page, "pinned").first()).toContainText(TARGET)

// And the header now offers the other direction.
await trigger.click()
await expectAppVisible(menuContent(page))
await expect(menuItem(page, "Unpin")).toHaveCount(1)
})

test("deleting a pinned session drops its pin", async ({ page }) => {
const sessions = manySessions(2)
await bootWith(page, sessions)
// The mock serves `/session` from this array by reference, so removing the
// row here is what stops the index refetch resurrecting it. Registered after
// the mock so it wins for DELETE and falls through for everything else.
await page.route("**/session/*", async (route) => {
if (route.request().method() !== "DELETE") return route.fallback()
const id = new URL(route.request().url()).pathname.split("/").pop()
const index = sessions.findIndex((session) => session.id === id)
if (index >= 0) sessions.splice(index, 1)
return route.fulfill({ status: 200, contentType: "application/json", body: "true" })
})

await openFirstRowMenu(page, "recents")
await menuItem(page, "Pin").click()
await expect(rowsIn(page, "pinned")).toHaveCount(1)

await openFirstRowMenu(page, "pinned")
await menuItem(page, "Delete").click()
await page.getByRole("button", { name: "Delete session" }).click()

await expect(rowsIn(page, "pinned")).toHaveCount(0)
// The id must leave storage too: left behind, it would silently re-pin a
// future session that reused it.
await expect
.poll(async () => page.evaluate(() => localStorage.getItem("opencode.global.dat:sidebar.pinned") ?? ""))
.not.toContain("ses_pin_0")
})

test("pins are stored per server, not in one flat list", async ({ page }) => {
await boot(page)
await pinFirstRecent(page)

// The store is a record keyed by the server the pin was made against: session
// ids are only unique within one, so a flat list would let a pin follow an id
// onto a different server. Asserting the exact key, not just "some key" —
// anything constant would satisfy the looser check.
const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const stored = await page.evaluate(() => localStorage.getItem("opencode.global.dat:sidebar.pinned"))
expect(stored).not.toBeNull()
expect(JSON.parse(stored!)).toEqual({ sessions: { [serverKey]: [fixture.targetID] } })
})
Loading