From 5d7c2ccfc0c8727aad896abca398901e1f2b1ebe Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Wed, 26 Aug 2026 19:40:09 +1000
Subject: [PATCH] feat(app): add experimental vertical session tabs (#45210)
---
.../regression/tab-navigate-mousedown.spec.ts | 121 ++++++++++++++++++
packages/app/src/runtime/i18n/en.ts | 5 +
.../src/settings/appearance/appearance.tsx | 25 ++++
.../app/src/settings/general/controllers.ts | 4 +
packages/app/src/settings/model.tsx | 7 +
packages/app/src/settings/settings.css | 108 +++++++++++++++-
packages/app/src/shell/errors/error.tsx | 2 +-
packages/app/src/shell/shell.tsx | 60 ++++++---
packages/app/src/shell/titlebar/tab-nav.css | 59 ++++++++-
packages/app/src/shell/titlebar/tab-nav.tsx | 12 ++
.../app/src/shell/titlebar/tab-popover.css | 1 +
.../app/src/shell/titlebar/tab-popover.tsx | 13 +-
packages/app/src/shell/titlebar/tab-strip.tsx | 82 +++++++++---
packages/app/src/shell/titlebar/titlebar.tsx | 109 +++++++++++-----
14 files changed, 534 insertions(+), 74 deletions(-)
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")}
+
+
+
+
+
>
)
diff --git a/packages/app/src/settings/general/controllers.ts b/packages/app/src/settings/general/controllers.ts
index c67e3ce8d47..2c314ebf0d0 100644
--- a/packages/app/src/settings/general/controllers.ts
+++ b/packages/app/src/settings/general/controllers.ts
@@ -81,6 +81,10 @@ export function createAppearanceSettingsController() {
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
+ tabs: {
+ current: settings.appearance.tabLayout,
+ select: settings.appearance.setTabLayout,
+ },
}
}
diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx
index f64e99abe69..b440e62ca1f 100644
--- a/packages/app/src/settings/model.tsx
+++ b/packages/app/src/settings/model.tsx
@@ -8,6 +8,7 @@ export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
+export type TabLayout = "horizontal" | "vertical"
export interface NotificationSettings {
agent: boolean
@@ -47,6 +48,7 @@ export interface Settings {
mono: string
sans: string
terminal: string
+ tabLayout: TabLayout
}
keybinds: Record
permissions: {
@@ -135,6 +137,7 @@ const defaultSettings: Settings = {
mono: "",
sans: "",
terminal: "",
+ tabLayout: "horizontal",
},
keybinds: {},
permissions: {
@@ -287,6 +290,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTerminalFont(value: string) {
setStore("appearance", "terminal", value.trim() ? value : "")
},
+ tabLayout: withFallback(() => store.appearance?.tabLayout, defaultSettings.appearance.tabLayout),
+ setTabLayout(value: TabLayout) {
+ setStore("appearance", "tabLayout", value)
+ },
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css
index 7f94987eac3..941eff4039c 100644
--- a/packages/app/src/settings/settings.css
+++ b/packages/app/src/settings/settings.css
@@ -15,6 +15,7 @@
overflow: hidden;
background: var(--v2-background-bg-deep);
outline: none;
+ container: settings-screen / inline-size;
}
.settings-screen > .settings {
@@ -104,6 +105,7 @@
overflow-y: auto;
scrollbar-width: none;
user-select: none;
+ container: settings-panel / inline-size;
}
.settings-panel :is(input, textarea, [contenteditable="true"]) {
@@ -260,6 +262,10 @@
max-width: 100%;
}
+:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2"] {
+ max-width: 100%;
+}
+
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
@@ -299,6 +305,66 @@
}
}
+@container settings-screen (min-width: 800px) and (max-width: 1047px) {
+ .settings-screen > .settings {
+ padding-inline: 24px;
+ }
+
+ .settings-screen
+ > .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
+ > [data-slot="tabs-v2-list"] {
+ width: 240px;
+ min-width: 240px;
+ padding-inline-start: 16px;
+ padding-inline-end: 24px;
+ }
+}
+
+@container settings-screen (max-width: 799px) {
+ .settings-screen .settings-nav {
+ width: 100%;
+ }
+
+ .settings-screen > .settings > .settings-panel {
+ padding-inline-end: 16px;
+ }
+
+ .settings-screen .settings-tab-header {
+ padding-top: 24px;
+ }
+
+ .settings-screen
+ > .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
+ > [data-slot="tabs-v2-list"] {
+ width: 160px;
+ min-width: 160px;
+ padding-block: 24px;
+ padding-inline: 12px;
+ }
+}
+
+@container settings-panel (max-width: 520px) {
+ .settings-tab-header-row {
+ flex-wrap: wrap;
+ }
+
+ .settings-tab-header-row [data-component="select-v2-root"] {
+ width: 100%;
+ }
+
+ .settings-tab-header-row [data-component="select-v2"] {
+ width: 100%;
+ }
+
+ [data-component="settings-row"] {
+ flex-wrap: wrap;
+ }
+
+ [data-slot="settings-row-control"] {
+ width: 100%;
+ }
+}
+
.settings-provider-row {
display: flex;
flex-wrap: wrap;
@@ -319,6 +385,12 @@
}
}
+@container settings-panel (max-width: 520px) {
+ .settings-provider-row {
+ flex-wrap: wrap;
+ }
+}
+
.settings-providers [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
@@ -993,6 +1065,40 @@
}
}
+@container settings-panel (max-width: 520px) {
+ .settings-workspaces-toolbar,
+ .settings-workspaces-main {
+ align-items: flex-start;
+ }
+
+ .settings-workspaces-toolbar {
+ flex-wrap: wrap;
+ }
+
+ .settings-workspaces-toolbar-actions {
+ width: 100%;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ .settings-workspaces-inventory [data-component="settings-list"] {
+ max-height: none;
+ overflow-y: visible;
+ padding: 14px;
+ }
+
+ .settings-workspaces-path {
+ overflow: visible;
+ text-overflow: clip;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .settings-workspaces-active {
+ display: none;
+ }
+}
+
[data-component="dialog-v2"].settings-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -1038,7 +1144,7 @@
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
- width: 280px;
+ width: min(280px, 100%);
padding-inline: 0 !important;
}
diff --git a/packages/app/src/shell/errors/error.tsx b/packages/app/src/shell/errors/error.tsx
index 883d9ae94bb..f4d677b5db0 100644
--- a/packages/app/src/shell/errors/error.tsx
+++ b/packages/app/src/shell/errors/error.tsx
@@ -278,7 +278,7 @@ export const ErrorPage: Component = (props) => {
return (
diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx
index 060e988dee4..ea914843ce4 100644
--- a/packages/app/src/shell/shell.tsx
+++ b/packages/app/src/shell/shell.tsx
@@ -1,10 +1,13 @@
import { lazy, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
+import { createMediaQuery } from "@solid-primitives/media"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
+import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
@@ -12,7 +15,14 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
export default function Layout(props: ParentProps) {
const platform = usePlatform()
const settings = useSettingsSurface()
- const [state, setState] = createStore({ debugTools: false })
+ const preferences = useSettings()
+ const mobile = createMediaQuery("(max-width: 767px)")
+ const [state, setState] = createStore({
+ debugTools: false,
+ tabsWidth: 260,
+ tabsMount: undefined as HTMLElement | undefined,
+ })
+ const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const update: TitlebarUpdate = {
get version() {
@@ -37,27 +47,47 @@ export default function Layout(props: ParentProps) {
>
setState("debugTools", (value) => !value) }
: undefined
}
/>
-
-
- {props.children}
-
-
-
-
-
+
+
+
-
+
+
+ {props.children}
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/shell/titlebar/tab-nav.css b/packages/app/src/shell/titlebar/tab-nav.css
index 621f3d9d97b..a08b329c465 100644
--- a/packages/app/src/shell/titlebar/tab-nav.css
+++ b/packages/app/src/shell/titlebar/tab-nav.css
@@ -15,6 +15,48 @@
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) {
+ height: 45px;
+}
+
+[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) [data-slot="tab-link"] {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ grid-template-rows: repeat(2, var(--line-height-compact));
+ align-content: center;
+ column-gap: 6px;
+ row-gap: 0;
+ font-size: 13px;
+ line-height: var(--line-height-compact);
+}
+
+[data-titlebar-tab][data-orientation="vertical"] [data-slot="project-avatar-slot"] {
+ grid-column: 1;
+ grid-row: 1;
+}
+
+[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-title"] {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-project"] {
+ grid-column: 2;
+ grid-row: 2;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--v2-text-text-muted);
+ font-size: 13px;
+ line-height: var(--line-height-compact);
+ white-space: nowrap;
+}
+
+[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-close"] {
+ top: 0;
+ bottom: 0;
+ margin-block: auto;
+}
+
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
@@ -39,6 +81,10 @@
gap: 6px;
}
+[data-titlebar-tab-list][data-orientation="vertical"] {
+ gap: 2px;
+}
+
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
@@ -68,6 +114,11 @@
display: none;
}
+[data-titlebar-tab-list][data-orientation="vertical"] [data-titlebar-tab-slot]::before,
+[data-titlebar-tab-slot]:has([data-titlebar-tab][data-orientation="vertical"])::before {
+ display: none;
+}
+
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
@@ -133,21 +184,21 @@
}
@container (max-width: 64px) {
- [data-titlebar-tab-link] {
+ [data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}
- [data-titlebar-tab-title] {
+ [data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
display: none;
}
- [data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
+ [data-titlebar-tab]:not([data-orientation="vertical"]):not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
- [data-slot="tab-close"] {
+ [data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx
index 1147242f856..ca2a6c7309b 100644
--- a/packages/app/src/shell/titlebar/tab-nav.tsx
+++ b/packages/app/src/shell/titlebar/tab-nav.tsx
@@ -33,6 +33,7 @@ export function TabNavItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
+ orientation?: "horizontal" | "vertical"
}) {
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
@@ -180,6 +181,7 @@ export function TabNavItem(props: {
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
+ data-orientation={props.orientation ?? "horizontal"}
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
@@ -278,6 +280,13 @@ export function TabNavItem(props: {
event.preventDefault()
}}
/>
+
+ {(name) => (
+
+ {name()}
+
+ )}
+
@@ -299,6 +308,7 @@ export function TabNavItem(props: {
return (
{
if (value && previewBlocked()) return
@@ -325,6 +335,7 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
+ orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
@@ -337,6 +348,7 @@ export function DraftTabItem(props: {
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
+ data-orientation={props.orientation ?? "horizontal"}
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
diff --git a/packages/app/src/shell/titlebar/tab-popover.css b/packages/app/src/shell/titlebar/tab-popover.css
index a213e520310..99fa0bbd46d 100644
--- a/packages/app/src/shell/titlebar/tab-popover.css
+++ b/packages/app/src/shell/titlebar/tab-popover.css
@@ -8,6 +8,7 @@
z-index: 50;
box-sizing: border-box;
width: 256px;
+ max-width: calc(100dvw - 24px);
display: flex;
flex-direction: column;
gap: 6px;
diff --git a/packages/app/src/shell/titlebar/tab-popover.tsx b/packages/app/src/shell/titlebar/tab-popover.tsx
index d3385b6316b..0b9adcaab63 100644
--- a/packages/app/src/shell/titlebar/tab-popover.tsx
+++ b/packages/app/src/shell/titlebar/tab-popover.tsx
@@ -1,9 +1,10 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { createSignal, Show, type JSXElement } from "solid-js"
+import { useLanguage } from "@/runtime/i18n/language"
import "./tab-popover.css"
// Initial hover delay before the preview appears, per design.
-const OPEN_DELAY = 2_000
+const OPEN_DELAY = 750
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -24,7 +25,9 @@ export function TabPreviewPopover(props: {
open: boolean
onOpenChange: (open: boolean) => void
data: TabPreviewData
+ orientation?: "horizontal" | "vertical"
}) {
+ const language = useLanguage()
let triggerEl: HTMLDivElement | undefined
// When opened during a rapid tab-hopping streak, this preview appears and
// disappears instantly (no repeated enter/exit animation) — only the first,
@@ -50,7 +53,13 @@ export function TabPreviewPopover(props: {
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
ignoreSafeArea
- placement="bottom-start"
+ placement={
+ props.orientation === "vertical"
+ ? language.direction() === "rtl"
+ ? "left-start"
+ : "right-start"
+ : "bottom-start"
+ }
gutter={6}
>
diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx
index 1d8a3d34f67..8ae11ba749c 100644
--- a/packages/app/src/shell/titlebar/tab-strip.tsx
+++ b/packages/app/src/shell/titlebar/tab-strip.tsx
@@ -4,7 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
-import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToHorizontalAxis, RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/shell/tabs/tabs"
@@ -27,6 +27,7 @@ function SessionTabSlot(props: {
index: number
active: boolean
forceTruncate: boolean
+ orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise
@@ -49,7 +50,12 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
- class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
+ data-orientation={props.orientation}
+ class="relative flex"
+ classList={{
+ "w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
+ "w-full shrink-0": props.orientation === "vertical",
+ }}
>
{
@@ -65,6 +71,7 @@ function SessionTabSlot(props: {
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
+ orientation={props.orientation}
/>
)
@@ -76,6 +83,7 @@ function SessionTabEntry(props: {
index: number
active: boolean
forceTruncate: boolean
+ orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
@@ -160,6 +168,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
+ orientation={props.orientation}
session={session()}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
@@ -175,6 +184,7 @@ function DraftTabSlot(props: {
id: string
index: number
active: boolean
+ orientation: "horizontal" | "vertical"
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -195,7 +205,12 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
- class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
+ data-orientation={props.orientation}
+ class="relative flex"
+ classList={{
+ "w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
+ "w-full shrink-0": props.orientation === "vertical",
+ }}
>
{
@@ -207,12 +222,14 @@ function DraftTabSlot(props: {
onClose={props.onClose}
active={props.active}
dragging={sortable.isDragSource()}
+ orientation={props.orientation}
/>
)
}
export function TitlebarTabStrip(props: {
+ orientation?: "horizontal" | "vertical"
tabs: Tab[]
currentTab: Tab | undefined
forceTruncate: boolean
@@ -224,6 +241,7 @@ export function TitlebarTabStrip(props: {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
+ const vertical = () => props.orientation === "vertical"
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
@@ -259,7 +277,9 @@ export function TitlebarTabStrip(props: {
function refreshOverflow() {
if (!scrollRef) return
- props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
+ props.onOverflowChange(
+ vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
+ )
}
createResizeObserver(
@@ -288,10 +308,19 @@ export function TitlebarTabStrip(props: {
})
return (
-
+
)
}
diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx
index 4ebaf0ad966..d54d417e75f 100644
--- a/packages/app/src/shell/titlebar/titlebar.tsx
+++ b/packages/app/src/shell/titlebar/titlebar.tsx
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
+import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -36,7 +37,11 @@ export type TitlebarUpdate = {
install: () => void
}
-export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
+export function Titlebar(props: {
+ update?: TitlebarUpdate
+ debugTools?: { visible: boolean; toggle: () => void }
+ verticalTabs?: { mount?: HTMLElement }
+}) {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
@@ -357,40 +362,82 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
/>
-
{
- tabs.select(tab)
- el?.scrollIntoView({ behavior: "instant" })
- }}
- onClose={(tab) => {
- const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
- if (index !== -1) tabsStoreActions.closeTab(index)
- }}
- onReorder={(keys) => tabsStoreActions.reorder(keys)}
- />
-
- {language.t("command.session.new")}
-
+ {
+ tabs.select(tab)
+ el?.scrollIntoView({ behavior: "instant" })
+ }}
+ onClose={(tab) => {
+ const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
+ if (index !== -1) tabsStoreActions.closeTab(index)
+ }}
+ onReorder={(keys) => tabsStoreActions.reorder(keys)}
+ />
+
+ {language.t("command.session.new")}
+
+ >
+ }
+ >
+ }
+ onClick={openNewTab}
+ aria-label={language.t("command.session.new")}
+ />
+
>
}
>
- }
- onClick={openNewTab}
- aria-label={language.t("command.session.new")}
- />
-
+ {(vertical) => (
+
+ {(mount) => (
+
+ {
+ 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)}
+ />
+
+
+ )}
+
+ )}
+