Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **Delete reads as destructive** — the **Delete** item in a session's ⋮ / ⋯
menu is now red, in both the sidebar rows and the menu beside a session
title, and it has lost its trailing ellipsis. It still asks for confirmation
before deleting. **Share…** keeps its ellipsis.

- **Recents folds away** — the **Recents** header in the sidebar is now a
toggle. Clicking it collapses the section to just its title, hiding the
**New session** row, the session list and **View all**. The title stays where
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions packages/app/e2e/regression/session-menu-delete-item.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"

const content = (page: Page) => page.locator('[data-component="menu-v2-content"]')
const items = (page: Page) => content(page).locator('[data-component="menu-v2-item"]')
const label = (page: Page, name: string) =>
items(page).filter({ hasText: name }).locator('[data-slot="menu-v2-item-content"]')

async function boot(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
}

// Retried, because the sidebar row trigger swallows its first click and opens on
// the second. That reproduces on a clean main with the hover wrapper fully faded
// in and the app settled, so it is a pre-existing defect in the trigger and not
// something these specs should assert into place — retry past it here and fix it
// on its own. The in-session header trigger has no such wrapper and opens first
// time, which is why only this helper retries.
async function clickUntilOpen(page: Page, trigger: ReturnType<Page["locator"]>) {
await expect(async () => {
await trigger.click()
await expect(content(page)).toHaveCount(1, { timeout: 1000 })
}).toPass({ timeout: 15_000 })
}

// Reads the colour the danger token resolves to right now, rather than pinning a
// hex, so a theme change moves the expectation with it.
async function dangerColor(page: Page) {
return page.evaluate(() => {
const probe = document.createElement("span")
probe.style.color = "var(--v2-state-fg-danger)"
document.body.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
})
}

async function expectDeleteReadsAsDestructive(page: Page) {
// Ellipsis dropped deliberately — Share keeps its own, so this is not a blanket
// strip of the convention.
await expect(label(page, "Delete")).toHaveText("Delete")
await expect(label(page, "Share")).toHaveText("Share...")

// Red comes from `--menu-v2-fg`, the item's own colour hook in menu-v2.css. A
// text utility on the item would not reach the label span, which sets its own
// colour — so this asserts the label, not the row.
const danger = await dangerColor(page)
await expect(label(page, "Delete")).toHaveCSS("color", danger)
await expect(label(page, "Archive")).not.toHaveCSS("color", danger)

// Highlighting only swaps the background, so the colour has to survive hover.
await items(page).filter({ hasText: "Delete" }).hover()
await expect(label(page, "Delete")).toHaveCSS("color", danger)
}

test("Delete reads as destructive in the sidebar row menu", async ({ page }) => {
await boot(page)
await page.goto("/")

const sidebar = page.locator('[data-component="app-sidebar"]')
const row = sidebar.locator('[data-component="sidebar-session-row"]').first()
await expectAppVisible(row)
// Wait for the hover-reveal wrapper to finish fading before clicking — the
// button itself is "visible" to Playwright at opacity 0, so it is no signal.
await row.hover()
await expect(sidebar.locator('[data-slot="session-row-actions"]').first()).toHaveCSS("opacity", "1")

await clickUntilOpen(page, sidebar.locator('[data-action="session-menu"]').first())
await expectDeleteReadsAsDestructive(page)
})

test("Delete reads as destructive in the in-session header menu", async ({ page }) => {
await boot(page)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)

// The header menu carries no data-action hook, only its aria-label, and it is
// scoped to main so the sidebar row triggers cannot match.
const trigger = page.locator("main").getByRole("button", { name: "More options" }).first()
await expectAppVisible(trigger)
await trigger.click()
await expectAppVisible(content(page))

await expectDeleteReadsAsDestructive(page)
})
55 changes: 55 additions & 0 deletions packages/app/e2e/regression/sidebar-section-collapse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const sidebar = (page: Page) => page.locator('[data-component="app-sidebar"]')
const recents = (page: Page) => sidebar(page).locator('[data-section="recents"]')
const toggle = (page: Page) => recents(page).locator('[data-action="sidebar-section-toggle"]')
const viewAll = (page: Page) => recents(page).locator('[data-action="sidebar-view-all"]')
const sessionRows = (page: Page) => recents(page).locator('[data-component="sidebar-session-row"]')
const settings = (page: Page) => sidebar(page).getByRole("button", { name: "Settings" })

async function boot(page: Page) {
Expand Down Expand Up @@ -69,3 +70,57 @@ test("collapsing Recents moves neither its own header nor the bottom nav", async
expect(Math.abs(after.recents!.y - before.recents!.y)).toBeLessThan(2)
expect(Math.abs(after.settings!.y - before.settings!.y)).toBeLessThan(2)
})

test("the header toggles from the keyboard", async ({ page }) => {
await boot(page)

// The header is a real button carrying the label, so it has to be reachable
// and operable without a pointer — that is what lets `aria-expanded` alone
// name the control instead of a translated aria-label.
await toggle(page).focus()
await expect(toggle(page)).toBeFocused()

await page.keyboard.press("Enter")
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false")
await expect(viewAll(page)).toHaveCount(0)

await page.keyboard.press("Space")
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true")
await expectAppVisible(viewAll(page))
})

test("View all is never pushed out of the list as the window shortens", async ({ page }) => {
await boot(page)
await expectAppVisible(viewAll(page))
expect(await sessionRows(page).count()).toBe(fixture.sessions.length)

// Recents draws only as many rows as fit and reserves the last slot for View
// all (see recentsRowCapacity). The list clips with overflow-hidden, so
// over-filling it does not push anything into the viewport's way — it just
// silently cuts View all off. Compare against the list box rather than the
// viewport, and sweep heights so the assertion does not depend on landing in
// a lucky band.
// Stops at 400. Below that the flexible area itself drops under one row
// (12px at a 380px window, 0 at 360) and View all is drawn anyway, so it is
// clipped — capacity clamps the session rows to zero but nothing withholds
// View all when there is no room for it either. That is pre-existing, not
// something the collapse work introduced, and it wants its own fix; this
// sweep guards the range where the reservation is supposed to hold.
for (const height of [900, 700, 560, 480, 420, 400]) {
await page.setViewportSize({ width: 1280, height })
// The row count is driven by a ResizeObserver, so the list is briefly still
// filled for the old box after a resize. Poll rather than measure the frame
// straight after: a genuine clip never resolves, so this waits out the
// reflow without waiting away the failure.
await expect(async () => {
const list = await recents(page).locator('[data-slot="sidebar-recents-list"]').boundingBox()
const all = await viewAll(page).boundingBox()
if (!all) return // too short to draw the section at all
expect(list).not.toBeNull()
expect(
{ height, bottomOverflow: Math.max(0, Math.round(all.y + all.height - (list!.y + list!.height))) },
`View all is clipped at viewport height ${height}`,
).toEqual({ height, bottomOverflow: 0 })
}).toPass({ timeout: 5000 })
}
})
35 changes: 35 additions & 0 deletions packages/app/src/components/app-sidebar-rows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { HOME_NAV_ROW_GAP, HOME_NAV_ROW_PITCH, recentsRowCapacity } from "./app-sidebar-rows"

// n rows laid out at this pitch occupy 32n - 4: the last row carries no
// trailing gap. Deriving the fixtures rather than hardcoding pixel counts keeps
// the test honest if the row height or the gap ever moves.
const exactly = (rows: number) => rows * HOME_NAV_ROW_PITCH - HOME_NAV_ROW_GAP

describe("recentsRowCapacity", () => {
test("reserves the last fitting row for View all", () => {
expect(recentsRowCapacity(exactly(1))).toBe(0)
expect(recentsRowCapacity(exactly(2))).toBe(1)
expect(recentsRowCapacity(exactly(5))).toBe(4)
})

test("never reports a row it cannot draw whole", () => {
// One pixel short of fitting a third row is still a two-row box, so the
// budget stays at one session plus View all.
expect(recentsRowCapacity(exactly(3) - 1)).toBe(1)
expect(recentsRowCapacity(exactly(3))).toBe(2)
})

test("clamps instead of going negative when nothing fits", () => {
expect(recentsRowCapacity(0)).toBe(0)
expect(recentsRowCapacity(exactly(1) - 1)).toBe(0)
// The observer reports 0 while the section is collapsed and the list is
// unmounted; a negative slice length would throw off Array.slice.
expect(recentsRowCapacity(-40)).toBe(0)
})

test("grows one session at a time with the box", () => {
const heights = Array.from({ length: 8 }, (_, index) => exactly(index + 1))
expect(heights.map(recentsRowCapacity)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
})
13 changes: 13 additions & 0 deletions packages/app/src/components/app-sidebar-rows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// HOME_PROJECT_NAV_ROW rows are h-7 (28px) in a flex column with gap-1 (4px),
// so n rows occupy 32n - 4 — the last one carries no trailing gap.
export const HOME_NAV_ROW_PITCH = 32
export const HOME_NAV_ROW_GAP = 4

// How many session rows the sidebar's Recents list may draw into `height` px of
// the flexible area. One row of the budget belongs to "View all", which renders
// inside the same container so it hugs the last session instead of being pushed
// to the bottom — reserving it here is what keeps "View all" reachable without
// scrolling at any window height.
export function recentsRowCapacity(height: number) {
return Math.max(0, Math.floor((height + HOME_NAV_ROW_GAP) / HOME_NAV_ROW_PITCH) - 1)
}
19 changes: 8 additions & 11 deletions packages/app/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { recentsRowCapacity } from "@/components/app-sidebar-rows"
import { useServerManagementController } from "@/components/dialog-select-server"
import { DialogDeleteSession, SessionActionsMenu } from "@/components/session-actions-menu"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
Expand Down Expand Up @@ -45,10 +46,6 @@ import { sessionTitle } from "@/utils/session-title"
// every route. Sections top to bottom: Projects, Recents (New row, a list sized
// to the free height, and a pinned View all), then Settings/Help anchored to the
// bottom.
// HOME_PROJECT_NAV_ROW rows are h-7 (28px) in a flex column with gap-1 (4px),
// so n rows occupy 32n - 4 — the last one carries no trailing gap.
const HOME_NAV_ROW_PITCH = 32
const HOME_NAV_ROW_GAP = 4

const CHANNEL = import.meta.env.VITE_CODEV_CHANNEL

Expand Down Expand Up @@ -249,12 +246,7 @@ function SidebarRecents(props: {
() => list() ?? [],
(rect) => setListHeight(rect.height),
)
// One row of the budget belongs to "View all", which renders inside the same
// container so it hugs the last session instead of being pushed to the bottom
// of the flexible area.
const visible = createMemo(() =>
props.records.slice(0, Math.max(0, Math.floor((listHeight() + HOME_NAV_ROW_GAP) / HOME_NAV_ROW_PITCH) - 1)),
)
const visible = createMemo(() => props.records.slice(0, recentsRowCapacity(listHeight())))

return (
<SidebarSection
Expand All @@ -274,7 +266,11 @@ function SidebarRecents(props: {
<span class={HOME_PROJECT_NAV_LABEL}>{language.t("sidebar.recents.new")}</span>
</button>
</Show>
<div ref={setList} class="flex min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<div
ref={setList}
data-slot="sidebar-recents-list"
class="flex min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-hidden"
>
<For each={visible()}>
{(record) => (
<SidebarSessionRow
Expand Down Expand Up @@ -369,6 +365,7 @@ function SidebarSessionRow(props: {
</Show>

<div
data-slot="session-row-actions"
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center transition-opacity"
classList={{
"opacity-100": menuOpen(),
Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/components/session-actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export function SessionActionsMenu(props: SessionActionsMenuProps) {
// trick components/app-sidebar.tsx uses to point a chevron right. Applied to
// the svg through the button class because IconButton builds its own Icon and
// forwards no style; both sprites tag it data-slot="icon-svg".
const triggerClass = () =>
`${props.vertical ? "[&_[data-slot=icon-svg]]:rotate-90" : ""} ${props.class ?? ""}`.trim()
const triggerClass = () => `${props.vertical ? "[&_[data-slot=icon-svg]]:rotate-90" : ""} ${props.class ?? ""}`.trim()

return (
<Show
Expand Down Expand Up @@ -136,7 +135,14 @@ export function SessionActionsMenu(props: SessionActionsMenuProps) {
</Show>
<MenuV2.Item onSelect={() => props.onArchive()}>{language.t("common.archive")}</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.onDelete()}>{language.t("common.delete")}...</MenuV2.Item>
{/* Danger colour comes from the item's own `--menu-v2-fg` hook rather
than a text utility: menu-v2.css colours the label span directly,
so a class on the item would not reach it. Setting the variable
recolours both, and the highlighted state only swaps the
background, so it survives hover. */}
<MenuV2.Item onSelect={() => props.onDelete()} style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}>
{language.t("common.delete")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
Expand Down
Loading