From b467432ba434959ca2de9dd55b7bc0eca79a3aeb Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:47:35 +0800 Subject: [PATCH] feat(app): reorganize session navigation controls (#46731) --- .../session-header-controls.spec.ts | 69 +++++++++ .../subagent-child-navigation.spec.ts | 3 +- .../regression/tab-navigate-mousedown.spec.ts | 24 +++- .../session/header/session-header-actions.tsx | 12 +- .../app/src/session/header/session-header.tsx | 19 +-- packages/app/src/session/screen.tsx | 2 - .../src/session/timeline/message-timeline.tsx | 104 +++++++------- packages/app/src/shell/shell.tsx | 2 +- .../app/src/shell/status/status-popover.tsx | 12 +- packages/app/src/shell/titlebar/tab-nav.css | 4 + packages/app/src/shell/titlebar/titlebar.tsx | 135 ++++++++++++------ 11 files changed, 266 insertions(+), 120 deletions(-) create mode 100644 packages/app/e2e/regression/session-header-controls.spec.ts diff --git a/packages/app/e2e/regression/session-header-controls.spec.ts b/packages/app/e2e/regression/session-header-controls.spec.ts new file mode 100644 index 00000000000..e8db2a2782e --- /dev/null +++ b/packages/app/e2e/regression/session-header-controls.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test" +import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture" +import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers" +import { mockOpenCodeServer } from "../utils/mock-server" + +for (const direction of ["ltr", "rtl"] as const) { + test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => { + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + sessions: fixture.sessions, + provider: fixture.provider, + pageMessages, + }) + await installStressSessionTabs(page) + await page.addInitScript(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, general: { ...settings.general, showStatus: true } }), + ) + }) + await page.goto(stressSessionHref(fixture.targetID)) + const header = page.locator("[data-session-title]") + const more = header.getByRole("button", { name: "More options", exact: true }) + const review = header.getByRole("button", { name: "Toggle review", exact: true }) + const details = header.getByRole("button", { name: "Session details", exact: true }) + await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) + await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction) + await expect(review).toBeVisible() + await expect(details).toBeVisible() + const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" }) + await expect(status).toBeVisible() + await expect + .poll(async () => { + const boxes = await Promise.all( + [header.getByRole("heading"), more, review, details].map((button) => button.boundingBox()), + ) + const [title, menu, sidebar, summary] = boxes + if (!title || !menu || !sidebar || !summary) return false + return direction === "ltr" + ? Math.abs(title.x + title.width - menu.x) <= 1 && + menu.x + menu.width <= summary.x && + summary.x + summary.width <= sidebar.x + : Math.abs(menu.x + menu.width - title.x) <= 1 && + sidebar.x + sidebar.width <= summary.x && + summary.x + summary.width <= menu.x + }) + .toBe(true) + + await review.click() + await expect(review).toHaveAttribute("aria-expanded", "true") + await expect(page.locator("#review-panel")).toBeVisible() + await review.click() + await expect(review).toHaveAttribute("aria-expanded", "false") + + await more.click() + await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0) + await page.keyboard.press("Escape") + await status.click() + const mcp = page.getByRole("tab", { name: "MCP", exact: true }) + const plugins = page.getByRole("tab", { name: "Plugins", exact: true }) + await expect(mcp).toHaveAttribute("aria-selected", "true") + await plugins.click() + await expect(plugins).toHaveAttribute("aria-selected", "true") + await page.keyboard.press("Escape") + await expect(mcp).toBeHidden() + }) +} diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 1e0a4519e8b..fd79a7f98f5 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -22,8 +22,7 @@ test("navigates to a subagent child session missing from the session list", asyn await expectSessionTitle(page, taskDescription) await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) - const titlebarRight = page.locator("#opencode-titlebar-right") - await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) + await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible() }) test("returns to the parent session with Escape", async ({ page }) => { diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 87dd95f6b65..402c2a45c21 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -121,7 +121,10 @@ 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( + "settings.v3", + JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }), + ) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([ @@ -144,7 +147,26 @@ test("vertical tabs show project details, resize, and navigate", async ({ page } 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: "Home", exact: true })).toHaveText("Home") await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible() + await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible() + const status = sidebar.getByRole("button", { name: "Status", exact: true }) + await expect(status).toBeVisible() + await expect + .poll(async () => { + const bounds = await sidebar.boundingBox() + const button = await status.boundingBox() + return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12 + }) + .toBe(true) + await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden() + await expect + .poll(async () => { + const button = await sidebar.getByRole("button", { name: "New session" }).boundingBox() + const tab = await tabA.boundingBox() + return !!button && !!tab && button.y + button.height < tab.y + }) + .toBe(true) await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0) const handle = sidebar.locator('[data-component="resize-handle"]') diff --git a/packages/app/src/session/header/session-header-actions.tsx b/packages/app/src/session/header/session-header-actions.tsx index fae5c64b921..31116b84e92 100644 --- a/packages/app/src/session/header/session-header-actions.tsx +++ b/packages/app/src/session/header/session-header-actions.tsx @@ -1,11 +1,10 @@ -import { Show, type JSX } from "solid-js" +import { Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Tooltip } from "@opencode-ai/ui/tooltip" export type SessionHeaderActionsState = { - status?: { label: string; content: () => JSX.Element } reviewLabel: string reviewKeybind: string[] reviewVisible: boolean @@ -16,13 +15,6 @@ export type SessionHeaderActionsState = { export function SessionHeaderActions(props: { state: SessionHeaderActionsState }) { return (
- - {(status) => ( - - {status().content()} - - )} - (() => ({ - status: - isDesktop() && status() - ? { label: language.t("status.popover.trigger"), content: () => } - : undefined, reviewLabel: language.t("command.review.toggle"), reviewKeybind: reviewTooltipKeybind(command), reviewVisible: isDesktop(), @@ -31,8 +27,15 @@ export function SessionHeader() { })) return ( - + <> + + + + + + + - + ) } diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 5643a68e78c..222ed54168f 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -12,7 +12,6 @@ import { } from "solid-js" import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { SessionHeader } from "@/session/header/session-header" import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline" import { useServer } from "@/runtime/server/current" import { projectForSession } from "@/shell/layout/helpers" @@ -273,7 +272,6 @@ export function SessionScreen(props: { session: SessionModel }) { return ( <> -
+ + {(id) => ( + setTitle("menuOpen", open)} + > + } + variant="ghost-muted" + size="large" + class="shrink-0" + aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen} + /> + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void props.action.export(id)}> + {language.t("common.export")}... + + + + {/* TODO: Need a session archive API. */} + + props.action.showDelete(id)}> + {language.t("common.delete")}... + + + + + + )} +
@@ -775,56 +828,7 @@ function MessageTimelineView( )} - - { - setTitle("menuOpen", open) - if (open) return - }} - > - } - variant="ghost-muted" - size="large" - aria-label={language.t("common.moreOptions")} - aria-expanded={title.menuOpen} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void props.action.export(id)}> - {language.t("common.export")}... - - {/* TODO: Need a session archive API. */} - - props.action.showDelete(id)}> - {language.t("common.delete")}... - - - - - +
)}
diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 15fc209b388..c1f67cf6836 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -65,7 +65,7 @@ export default function Layout(props: ParentProps) {