diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 261cada314a..f78801b8fd5 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -103,6 +103,127 @@ test("cramped tabs only show the close button for the active tab", async ({ page await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible() }) +test("vertical tabs show project details, resize, and navigate", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, sessionB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: sessionB }, + ]), + ) + }, + { server, sessionA: sessionA.id, sessionB: sessionB.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}` + await page.goto(hrefA) + + const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]') + const tabA = sidebar.locator(`[data-titlebar-tab-link][href="${hrefA}"]`) + const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`) + await expect(sidebar).toHaveCSS("width", "260px") + await expect(tabA).toContainText(sessionA.title) + await expect(tabB).toContainText(sessionB.title) + await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project") + await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0) + + const handle = sidebar.locator('[data-component="resize-handle"]') + await expect(handle).toHaveCSS("cursor", "col-resize") + 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 - 80, box.y + box.height / 2) + await page.mouse.up() + await expect(sidebar).toHaveCSS("width", "180px") + await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project") + + const resized = await handle.boundingBox() + if (!resized) throw new Error("resized vertical tab handle has no bounding box") + await page.mouse.move(resized.x + resized.width / 2, resized.y + resized.height / 2) + await page.mouse.down() + await page.mouse.move(resized.x - 200, resized.y + resized.height / 2) + await page.mouse.up() + await expect(sidebar).toHaveCSS("width", "130px") + + await tabB.click() + await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) + await expect(tabB).toBeVisible() +}) + +test("appearance experimental setting switches tab orientation", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA }) => { + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionA }]), + ) + }, + { server, sessionA: sessionA.id }, + ) + + await page.goto("/") + await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toBeVisible() + await page.keyboard.press("Control+,") + + const settings = page.getByTestId("settings-screen") + await expect(settings).toBeVisible() + await settings.getByRole("tab", { name: "Appearance" }).click() + await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible() + + const layout = settings.locator('[data-action="settings-tab-layout"]') + await expect(layout).toContainText("Horizontal") + await layout.click() + await page.getByRole("option", { name: "Vertical" }).click() + + await expect(layout).toContainText("Vertical") + await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0) + await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px") + + await page.setViewportSize({ width: 920, height: 720 }) + await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px") + await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px") + + await page.setViewportSize({ width: 800, height: 720 }) + await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px") +}) + +test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 720 }) + await mockServer(page) + await page.addInitScript( + ({ server, sessionA }) => { + localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionA }]), + ) + }, + { server, sessionA: sessionA.id }, + ) + + const href = `/server/${base64Encode(server)}/session/${sessionA.id}` + await page.goto(href) + + const tabs = page.locator('[data-slot="titlebar-tabs"]') + await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title) + await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0) + + await page.setViewportSize({ width: 1280, height: 720 }) + await expect( + page.locator('[data-slot="vertical-tabs-sidebar"]').locator(`[data-titlebar-tab-link][href="${href}"]`), + ).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0) +}) + function session(id: string, title: string) { return { id, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 898ae4fd1ab..7a7bcc83b9f 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -893,6 +893,11 @@ export const dict = { "settings.tab.extensions": "Extensions", "settings.preferences.description": "Customize preferences and theme and default behavior", "settings.appearance.description": "Customize theme and fonts", + "settings.appearance.section.experimental": "Experimental", + "settings.appearance.row.tabs.title": "Tabs", + "settings.appearance.row.tabs.description": "Choose how session tabs are arranged", + "settings.appearance.row.tabs.horizontal": "Horizontal", + "settings.appearance.row.tabs.vertical": "Vertical", "settings.notifications.description": "Choose when to receive notifications and hear sounds", "settings.shortcuts.description": "Customize shortcuts for common actions", "settings.servers.description": "Manage server connections", diff --git a/packages/app/src/settings/appearance/appearance.tsx b/packages/app/src/settings/appearance/appearance.tsx index f15747675cd..a4325525af5 100644 --- a/packages/app/src/settings/appearance/appearance.tsx +++ b/packages/app/src/settings/appearance/appearance.tsx @@ -9,6 +9,7 @@ import { createAppearanceSettingsController, type AppearanceSettingsController } import "@/settings/settings.css" const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"] const fontSettings = { ui: { action: "settings-ui-font", @@ -126,6 +127,30 @@ export const SettingsAppearance: Component = () => { + +
+

{language.t("settings.appearance.section.experimental")}

+ + +