Skip to content

Commit b0bc3d8

Browse files
feat(app): sidebar reveal animation, hover peek overlay, and weaker dividers (#16374)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
1 parent a263433 commit b0bc3d8

21 files changed

Lines changed: 482 additions & 367 deletions

packages/app/e2e/actions.ts

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import path from "node:path"
55
import { execSync } from "node:child_process"
66
import { modKey, serverUrl } from "./utils"
77
import {
8-
sessionItemSelector,
98
dropdownMenuTriggerSelector,
109
dropdownMenuContentSelector,
1110
projectMenuTriggerSelector,
11+
projectCloseMenuSelector,
1212
projectWorkspacesToggleSelector,
1313
titlebarRightSelector,
1414
popoverBodySelector,
@@ -61,9 +61,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
6161
}
6262

6363
export async function isSidebarClosed(page: Page) {
64-
const main = page.locator("main")
65-
const classes = (await main.getAttribute("class")) ?? ""
66-
return classes.includes("xl:border-l")
64+
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
65+
await expect(button).toBeVisible()
66+
return (await button.getAttribute("aria-expanded")) !== "true"
6767
}
6868

6969
export async function toggleSidebar(page: Page) {
@@ -75,48 +75,34 @@ export async function openSidebar(page: Page) {
7575
if (!(await isSidebarClosed(page))) return
7676

7777
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
78-
const visible = await button
79-
.isVisible()
80-
.then((x) => x)
81-
.catch(() => false)
82-
83-
if (visible) await button.click()
84-
if (!visible) await toggleSidebar(page)
78+
await button.click()
8579

86-
const main = page.locator("main")
87-
const opened = await expect(main)
88-
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
80+
const opened = await expect(button)
81+
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
8982
.then(() => true)
9083
.catch(() => false)
9184

9285
if (opened) return
9386

9487
await toggleSidebar(page)
95-
await expect(main).not.toHaveClass(/xl:border-l/)
88+
await expect(button).toHaveAttribute("aria-expanded", "true")
9689
}
9790

9891
export async function closeSidebar(page: Page) {
9992
if (await isSidebarClosed(page)) return
10093

10194
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
102-
const visible = await button
103-
.isVisible()
104-
.then((x) => x)
105-
.catch(() => false)
106-
107-
if (visible) await button.click()
108-
if (!visible) await toggleSidebar(page)
95+
await button.click()
10996

110-
const main = page.locator("main")
111-
const closed = await expect(main)
112-
.toHaveClass(/xl:border-l/, { timeout: 1500 })
97+
const closed = await expect(button)
98+
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
11399
.then(() => true)
114100
.catch(() => false)
115101

116102
if (closed) return
117103

118104
await toggleSidebar(page)
119-
await expect(main).toHaveClass(/xl:border-l/)
105+
await expect(button).toHaveAttribute("aria-expanded", "false")
120106
}
121107

122108
export async function openSettings(page: Page) {
@@ -220,7 +206,7 @@ export function sessionIDFromUrl(url: string) {
220206
}
221207

222208
export async function hoverSessionItem(page: Page, sessionID: string) {
223-
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
209+
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
224210
await expect(sessionEl).toBeVisible()
225211
await sessionEl.hover()
226212
return sessionEl
@@ -570,32 +556,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
570556
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
571557
await expect(trigger).toHaveCount(1)
572558

559+
const menu = page
560+
.locator(dropdownMenuContentSelector)
561+
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
562+
.first()
563+
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
564+
565+
const clicked = await trigger
566+
.click({ timeout: 1500 })
567+
.then(() => true)
568+
.catch(() => false)
569+
570+
if (clicked) {
571+
const opened = await menu
572+
.waitFor({ state: "visible", timeout: 1500 })
573+
.then(() => true)
574+
.catch(() => false)
575+
if (opened) {
576+
await expect(close).toBeVisible()
577+
return menu
578+
}
579+
}
580+
573581
await trigger.focus()
574582
await page.keyboard.press("Enter")
575583

576-
const menu = page.locator(dropdownMenuContentSelector).first()
577584
const opened = await menu
578585
.waitFor({ state: "visible", timeout: 1500 })
579586
.then(() => true)
580587
.catch(() => false)
581588

582589
if (opened) {
583-
const viewport = page.viewportSize()
584-
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
585-
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
586-
await page.mouse.move(x, y)
590+
await expect(close).toBeVisible()
587591
return menu
588592
}
589593

590-
await trigger.click({ force: true })
591-
592-
await expect(menu).toBeVisible()
593-
594-
const viewport = page.viewportSize()
595-
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
596-
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
597-
await page.mouse.move(x, y)
598-
return menu
594+
throw new Error(`Failed to open project menu: ${projectSlug}`)
599595
}
600596

601597
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
@@ -608,11 +604,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
608604

609605
if (current === enabled) return
610606

611-
await openProjectMenu(page, projectSlug)
607+
const flip = async (timeout?: number) => {
608+
const menu = await openProjectMenu(page, projectSlug)
609+
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
610+
await expect(toggle).toBeVisible()
611+
return toggle.click({ force: true, timeout })
612+
}
613+
614+
const flipped = await flip(1500)
615+
.then(() => true)
616+
.catch(() => false)
612617

613-
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
614-
await expect(toggle).toBeVisible()
615-
await toggle.click({ force: true })
618+
if (!flipped) await flip()
616619

617620
const expected = enabled ? "New workspace" : "New session"
618621
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()

packages/app/e2e/app/titlebar-history.spec.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
1616

1717
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
1818
await expect(link).toBeVisible()
19-
await link.scrollIntoViewIfNeeded()
2019
await link.click()
2120

2221
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
5655

5756
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
5857
await expect(second).toBeVisible()
59-
await second.scrollIntoViewIfNeeded()
6058
await second.click()
6159

6260
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
7674

7775
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
7876
await expect(third).toBeVisible()
79-
await third.scrollIntoViewIfNeeded()
8077
await third.click()
8178

8279
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
10299

103100
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
104101
await expect(link).toBeVisible()
105-
await link.scrollIntoViewIfNeeded()
106102
await link.click()
107103

108104
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

packages/app/e2e/projects/project-edit.spec.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,15 @@
11
import { test, expect } from "../fixtures"
2-
import { openSidebar } from "../actions"
2+
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
33

44
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
55
await page.setViewportSize({ width: 1400, height: 800 })
66

7-
await withProject(async () => {
7+
await withProject(async ({ slug }) => {
88
await openSidebar(page)
99

1010
const open = async () => {
11-
const header = page.locator(".group\\/project").first()
12-
await header.hover()
13-
const trigger = header.getByRole("button", { name: "More options" }).first()
14-
await expect(trigger).toBeVisible()
15-
await trigger.click({ force: true })
16-
17-
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
18-
await expect(menu).toBeVisible()
19-
20-
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
21-
await expect(editItem).toBeVisible()
22-
await editItem.click({ force: true })
11+
const menu = await openProjectMenu(page, slug)
12+
await clickMenuItem(menu, /^Edit$/i, { force: true })
2313

2414
const dialog = page.getByRole("dialog")
2515
await expect(dialog).toBeVisible()

packages/app/e2e/projects/projects-switch.spec.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,45 @@
11
import { base64Decode } from "@opencode-ai/util/encode"
2+
import type { Page } from "@playwright/test"
23
import { test, expect } from "../fixtures"
3-
import {
4-
defocus,
5-
createTestProject,
6-
cleanupTestProject,
7-
openSidebar,
8-
setWorkspacesEnabled,
9-
sessionIDFromUrl,
10-
} from "../actions"
4+
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
115
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
126
import { createSdk, dirSlug, sessionPath } from "../utils"
137

148
function slugFromUrl(url: string) {
159
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
1610
}
1711

12+
async function workspaces(page: Page, directory: string, enabled: boolean) {
13+
await page.evaluate(
14+
({ directory, enabled }: { directory: string; enabled: boolean }) => {
15+
const key = "opencode.global.dat:layout"
16+
const raw = localStorage.getItem(key)
17+
const data = raw ? JSON.parse(raw) : {}
18+
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
19+
const current =
20+
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
21+
? sidebar.workspaces
22+
: {}
23+
const next = { ...current }
24+
25+
if (enabled) next[directory] = true
26+
if (!enabled) delete next[directory]
27+
28+
localStorage.setItem(
29+
key,
30+
JSON.stringify({
31+
...data,
32+
sidebar: {
33+
...sidebar,
34+
workspaces: next,
35+
},
36+
}),
37+
)
38+
},
39+
{ directory, enabled },
40+
)
41+
}
42+
1843
test("can switch between projects from sidebar", async ({ page, withProject }) => {
1944
await page.setViewportSize({ width: 1400, height: 800 })
2045

@@ -60,8 +85,11 @@ test("switching back to a project opens the latest workspace session", async ({
6085
async ({ directory, slug }) => {
6186
rootDir = directory
6287
await defocus(page)
88+
await workspaces(page, directory, true)
89+
await page.reload()
90+
await expect(page.locator(promptSelector)).toBeVisible()
6391
await openSidebar(page)
64-
await setWorkspacesEnabled(page, slug, true)
92+
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
6593

6694
await page.getByRole("button", { name: "New workspace" }).first().click()
6795

packages/app/e2e/projects/workspaces.spec.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,9 +336,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
336336
const src = page.locator(workspaceItemSelector(from)).first()
337337
const dst = page.locator(workspaceItemSelector(to)).first()
338338

339-
await src.scrollIntoViewIfNeeded()
340-
await dst.scrollIntoViewIfNeeded()
341-
342339
const a = await src.boundingBox()
343340
const b = await dst.boundingBox()
344341
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")

packages/app/e2e/settings/settings-keybinds.spec.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
3232

3333
await closeDialog(page, dialog)
3434

35-
const main = page.locator("main")
36-
const initialClasses = (await main.getAttribute("class")) ?? ""
37-
const initiallyClosed = initialClasses.includes("xl:border-l")
35+
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
36+
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
3837

3938
await page.keyboard.press(`${modKey}+Shift+H`)
40-
await page.waitForTimeout(100)
39+
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
4140

42-
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
43-
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
41+
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
4442
expect(afterToggleClosed).toBe(!initiallyClosed)
4543

4644
await page.keyboard.press(`${modKey}+Shift+H`)
47-
await page.waitForTimeout(100)
45+
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
4846

49-
const finalClasses = (await main.getAttribute("class")) ?? ""
50-
const finalClosed = finalClasses.includes("xl:border-l")
47+
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
5148
expect(finalClosed).toBe(initiallyClosed)
5249
})
5350

packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect } from "../fixtures"
22
import { closeSidebar, hoverSessionItem } from "../actions"
3-
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
3+
import { projectSwitchSelector } from "../selectors"
44

55
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
66
const stamp = Date.now()
@@ -15,20 +15,23 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
1515
await gotoSession(one.id)
1616
await closeSidebar(page)
1717

18+
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
19+
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
20+
1821
const project = page.locator(projectSwitchSelector(slug)).first()
1922
await expect(project).toBeVisible()
2023
await project.hover()
2124

22-
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
23-
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
25+
await expect(oneItem).toBeVisible()
26+
await expect(twoItem).toBeVisible()
2427

2528
const item = await hoverSessionItem(page, one.id)
2629
await item
2730
.getByRole("button", { name: /archive/i })
2831
.first()
2932
.click()
3033

31-
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
34+
await expect(twoItem).toBeVisible()
3235
} finally {
3336
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
3437
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)

packages/app/e2e/sidebar/sidebar-session-links.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
1818

1919
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
2020
await expect(target).toBeVisible()
21-
await target.scrollIntoViewIfNeeded()
2221
await target.click()
2322

2423
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

0 commit comments

Comments
 (0)