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) => (
+
+ )}
+
@@ -775,56 +828,7 @@ function MessageTimelineView(
)}
-
-
-
+
)}
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) {