Compare commits

...
Author SHA1 Message Date
Brendan Allan b467432ba4 feat(app): reorganize session navigation controls (#46731) 2026-09-02 14:47:35 +08:00
11 changed files with 266 additions and 120 deletions
@@ -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()
})
}
@@ -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 }) => {
@@ -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"]')
@@ -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 (
<div class="flex items-center gap-2">
<Show when={props.state.status}>
{(status) => (
<Tooltip appearance="standard" placement="bottom" value={status().label}>
{status().content()}
</Tooltip>
)}
</Show>
<Show when={props.state.reviewVisible}>
<Tooltip
class="shrink-0"
@@ -40,7 +32,7 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
class="shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
@@ -1,4 +1,4 @@
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
@@ -7,6 +7,7 @@ import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
@@ -15,14 +16,9 @@ export function SessionHeader() {
const settings = useSettings()
const { view } = useSessionLayout()
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo<SessionHeaderActionsState>(() => ({
status:
isDesktop() && status()
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
: undefined,
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
@@ -31,8 +27,15 @@ export function SessionHeader() {
}))
return (
<TitlebarRight>
<>
<TitlebarRight>
<Show when={isDesktop() && settings.visibility.status()}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
</>
)
}
-2
View File
@@ -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 (
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
<div
@@ -32,6 +32,7 @@ import { parseCommentNote, readPromptPresentation } from "@/composer/comment-not
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
import { SessionHeader } from "@/session/header/session-header"
type BackgroundTask = {
id: string
@@ -732,6 +733,58 @@ function MessageTimelineView(
/>
</Show>
</Show>
<Show when={sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
class="shrink-0"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ "min-width": "160px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<Show when={!parentID()}>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
</Show>
<Show when={!parentID()}>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
</div>
</div>
<Show when={sessionID()} keyed>
@@ -775,56 +828,7 @@ function MessageTimelineView(
</Popover>
)}
</Show>
<Show when={!parentID()}>
<Menu
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
}}
>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}...
</Menu.Item>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<SessionHeader />
</div>
)}
</Show>
+1 -1
View File
@@ -65,7 +65,7 @@ export default function Layout(props: ParentProps) {
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(8px, env(safe-area-inset-bottom, 0px))",
@@ -7,6 +7,8 @@ import { useGlobal } from "@/runtime/server/runtime"
import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass } from "./indicator"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSettings } from "@/settings/model"
import { createMediaQuery } from "@solid-primitives/media"
const Body = lazy(() => import("./body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -16,6 +18,8 @@ export function StatusPopover() {
const global = useGlobal()
const data = useData()
const sdk = useWorkspaceLocation()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
@@ -37,6 +41,8 @@ export function StatusPopover() {
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
placement: desktop() && settings.appearance.tabLayout() === "vertical" ? "top-start" : "bottom-end",
shift: desktop() && settings.appearance.tabLayout() === "vertical" ? 0 : -168,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
@@ -55,6 +61,8 @@ type StatusPopoverState = {
serverHealth: boolean | undefined
attention: boolean
issue: boolean
placement: "top-start" | "bottom-end"
shift: number
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
@@ -77,8 +85,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
placement: props.state.placement,
shift: props.state.shift,
}
return (
@@ -86,6 +86,10 @@
gap: 2px;
}
[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-list][data-orientation="vertical"] {
gap: 4px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
+91 -44
View File
@@ -37,6 +37,7 @@ const windowsTitlebarHeight = 44 // Includes the content inset; matches the nati
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
const macTrafficLightsTopClearance = 28
export type TitlebarUpdate = {
version: string | undefined
@@ -63,6 +64,7 @@ export function Titlebar(props: {
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const macVerticalTabs = createMemo(() => mac() && !!props.verticalTabs)
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
@@ -105,6 +107,7 @@ export function Titlebar(props: {
const rightState = createMemo<TitlebarRightState>(() => ({
update: updateState(),
}))
const hideVerticalTitlebar = createMemo(() => !!props.verticalTabs && !windows())
const back = () => {
const next = backPath(history)
@@ -140,6 +143,7 @@ export function Titlebar(props: {
return (
<header
data-slot="titlebar-v2"
hidden={hideVerticalTitlebar()}
classList={{
"shrink-0 relative flex flex-row h-9 bg-v2-background-bg-deep overflow-visible": true,
"order-last": bottom(),
@@ -311,6 +315,48 @@ export function Titlebar(props: {
}
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
const homeButton = (vertical = false) => (
<Show
when={vertical}
fallback={
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
}
>
<button
type="button"
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
{language.t("home.title")}
</button>
</Show>
)
command.register("titlebar-home", () => [
{
@@ -386,36 +432,13 @@ export function Titlebar(props: {
"md:pl-4": !macTrafficLights(),
}}
>
<Show when={!mobile()}>
<Show when={!mobile() && !props.verticalTabs}>
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={!mobile()}>
<Tooltip
placement="bottom"
value={
<>
{language.t("home.title")}
<Keybind keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
class="shrink-0"
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<Icon name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</Tooltip>
</Show>
<Show when={!mobile() && !props.verticalTabs}>{homeButton()}</Show>
<Show
when={!mobile()}
@@ -595,31 +618,53 @@ export function Titlebar(props: {
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Portal
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
<Show when={macVerticalTabs()}>
<div
class="relative w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
></div>
</Show>
{homeButton(true)}
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="my-1 h-px w-full shrink-0 bg-v2-border-border-muted" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
</div>
<div data-slot="vertical-tabs-footer" class="relative mt-auto h-9 w-full shrink-0">
<div class="absolute bottom-0 left-0 flex h-9 items-center">
<ChannelIndicator debugTools={props.debugTools} />
</div>
<div class="absolute bottom-0 right-0 flex h-9 items-center">
<TitlebarRightMount />
</div>
</div>
</Portal>
)}
</Show>
@@ -629,7 +674,7 @@ export function Titlebar(props: {
<Show when={!mobile()}>
<div class="flex-1" />
</Show>
<TitlebarRight state={rightState()} />
<TitlebarRight state={rightState()} mount={!props.verticalTabs} />
</div>
)
}}
@@ -652,13 +697,15 @@ type TitlebarRightState = {
update: TitlebarUpdatePillState
}
function TitlebarRight(props: { state: TitlebarRightState }) {
function TitlebarRight(props: { state: TitlebarRightState; mount?: boolean }) {
return (
<div class="relative z-20 flex shrink-0 items-center justify-end gap-0 overflow-visible">
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<TitlebarRightMount />
<Show when={props.mount !== false}>
<TitlebarRightMount />
</Show>
</div>
)
}