feat(app): add a sidebar toggle for vertical tabs (#55646)

This commit is contained in:
Brendan Allan
2026-10-17 09:39:12 +08:00
committed by GitHub
Unverified
parent b6598c9351
commit 7a636bb737
9 changed files with 288 additions and 35 deletions
+29
View File
@@ -4,6 +4,7 @@ import {
NO_PROVIDER,
REMOTE_SERVER,
SERVER,
draftHref,
expectPath,
holdRoute,
project,
@@ -362,6 +363,34 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
await expectPath(page, sessionHref(b.id))
})
for (const [screen, href] of [
["home", "/"],
["new session", draftHref("draft_collapsed_sidebar")],
["settings", "/settings"],
["session", sessionHref(a.id)],
] as const) {
test(`collapsed vertical tabs sidebar reopens from the ${screen} screen`, async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a],
seed: {
settings: { appearance: { tabLayout: "vertical", sidebarOpen: false } },
tabs: [a.id, { draft: "draft_collapsed_sidebar", directory: "C:/OpenCode/Tabs" }],
},
})
await page.goto(href)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveAttribute("data-state", "closed")
await page.locator("main").getByRole("button", { name: "Toggle sidebar", exact: true }).click()
await expect(sidebar).toHaveAttribute("data-state", "open")
await expect(sidebar.getByRole("button", { name: "Toggle sidebar", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
})
}
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
+9 -2
View File
@@ -27,6 +27,7 @@ import { ServerRowMenuView, serverMenuLabels } from "@/servers/registry/row-menu
import { ServerHealthIndicator } from "@/servers/registry/row"
import { type ServerHealth } from "@/runtime/server/health"
import { fileManagerApp } from "@/home/projects/file-manager"
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
import "./view.css"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
@@ -173,7 +174,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
props.dropdown
? "flex max-h-[min(60dvh,calc(var(--kb-popper-content-available-height)-12px))] min-h-0 min-w-0 flex-col overflow-hidden"
: `
mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
mt-2.5 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden
lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]
`
}
@@ -185,7 +186,13 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
>
<Show when={!props.dropdown}>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
{/* Below lg, Projects heads the page on the collapsed sidebar toggle's line. Starting the space at the row's
edge (the shell's 8px inset plus the grid's 12px padding) keeps the row's 6px padding after the toggle,
which lines the label up with the icons that follow it in the session and settings headers. */}
<div class="flex min-w-0 items-center">
<CollapsedSidebarToggleSpace start={20} class="lg:hidden" />
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
</div>
<Show when={props.servers.length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}>
<Tooltip placement="bottom" value={props.language.t("home.project.add")}>
<IconButton
+1
View File
@@ -54,6 +54,7 @@ export const dict = {
"command.input.focus": "Focus input",
"command.fileTree.toggle": "Toggle file tree",
"command.review.toggle": "Toggle review",
"command.sidebar.toggle": "Toggle sidebar",
"command.message.previous": "Previous message",
"command.message.previous.description": "Go to the previous user message",
"command.message.next": "Next message",
@@ -27,6 +27,7 @@ import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
import { SessionWorkingIndicator } from "./header/session-working-indicator"
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
import "./session-identity-header.css"
export function SessionTitleHeader(props: ParentProps) {
@@ -34,7 +35,10 @@ export function SessionTitleHeader(props: ParentProps) {
<>
{/* The bottom inset reserves room for the fade without covering rows stuck below the bar. */}
<div data-session-title class="pointer-events-none sticky top-0 z-30 w-full pb-4">
<div class="pointer-events-auto bg-v2-background-bg-base pe-3 ps-2.5">{props.children}</div>
<div class="pointer-events-auto flex items-center bg-v2-background-bg-base pe-3 ps-2.5">
<CollapsedSidebarToggleSpace />
<div class="min-w-0 flex-1">{props.children}</div>
</div>
</div>
{/* Stuck timeline headers stack above this fade, so it only fades content scrolling under the bar. */}
<div
+5
View File
@@ -133,6 +133,7 @@ const appearanceSchema = Persistence.struct({
sans: Schema.String,
terminal: Schema.String,
tabLayout: Schema.Literals(["horizontal", "vertical"]),
sidebarOpen: Persistence.optional(Schema.Boolean),
})
const permissionsSchema = Persistence.struct({
@@ -409,6 +410,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTabLayout(value: TabLayout) {
setStore("appearance", "tabLayout", value)
},
sidebarOpen: withFallback(() => store.appearance?.sidebarOpen, true),
setSidebarOpen(value: boolean) {
setStore("appearance", "sidebarOpen", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
+10 -4
View File
@@ -5,6 +5,7 @@ import { Tabs } from "@opencode/ui/tabs"
import { createEffect, ErrorBoundary, For, on, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ErrorPage } from "@/shell/errors/error"
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
import { useSettingsSurface } from "./surface"
import { SettingsSearch } from "./search"
import "./search.css"
@@ -54,10 +55,15 @@ export function SettingsNavigation(props: {
return (
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={change} class="settings">
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={back}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{backLabel()}</span>
</button>
<div class="flex min-w-0 items-center">
{/* Starts after the shell's 8px inset and this row's 16px padding. Its 32px height makes the row 48px tall,
like the session header, so the back button centers on the collapsed sidebar toggle's line. */}
<CollapsedSidebarToggleSpace start={24} class="h-8" />
<button type="button" class="settings-back" onClick={back}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{backLabel()}</span>
</button>
</div>
<div class="settings-mobile-actions">
{props.mobileAction}
<Menu placement="bottom-end" gutter={8}>
+72 -15
View File
@@ -1,4 +1,4 @@
import { Show, Suspense, type ParentProps } from "solid-js"
import { createEffect, on, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { ResizeHandle } from "@opencode/ui/resize-handle"
@@ -11,19 +11,54 @@ import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { ExtensionServerCover } from "@/runtime/extension/server-shell"
import { ExtensionSlot } from "@/runtime/extension/render"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { CollapsedSidebarToggle } from "@/shell/sidebar-toggle"
export default function Layout(props: ParentProps) {
const platform = usePlatform()
const settings = useSettingsSurface()
const preferences = useSettings()
const command = useCommand()
const language = useLanguage()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore<{ tabsWidth: number; tabsMount: HTMLElement | undefined }>({
const [state, setState] = createStore<{
tabsWidth: number
tabsMount: HTMLElement | undefined
tabsAnimating: boolean
}>({
tabsWidth: 260,
tabsMount: undefined,
tabsAnimating: false,
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
// Animate only open/close; resize drags must track the pointer.
createEffect(
on(
preferences.appearance.sidebarOpen,
() => {
if (verticalTabs()) setState("tabsAnimating", true)
},
{ defer: true },
),
)
command.register("sidebar", () =>
verticalTabs()
? [
{
id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"),
keybind: "mod+shift+b",
onSelect: () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen()),
},
]
: [],
)
const bottomTitlebar = () => mobile() && preferences.general.mobileTitlebarPosition() === "bottom"
return (
@@ -49,22 +84,43 @@ export default function Layout(props: ParentProps) {
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
<aside
ref={(element) => setState("tabsMount", element)}
id="vertical-tabs-sidebar"
data-slot="vertical-tabs-sidebar"
class="relative flex h-full min-h-0 shrink-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))",
data-state={preferences.appearance.sidebarOpen() ? "open" : "closed"}
classList={{
"relative h-full min-h-0 shrink-0": true,
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none":
state.tabsAnimating,
}}
style={{ width: preferences.appearance.sidebarOpen() ? `${state.tabsWidth}px` : "0px" }}
inert={!preferences.appearance.sidebarOpen()}
onTransitionEnd={(event) => {
if (event.target === event.currentTarget) setState("tabsAnimating", false)
}}
onTransitionCancel={(event) => {
if (event.target === event.currentTarget) setState("tabsAnimating", false)
}}
>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={140}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
<div class="absolute inset-0 overflow-hidden">
<div
ref={(element) => setState("tabsMount", element)}
class="flex h-full min-h-0 flex-col bg-v2-background-bg-deep pe-0.5 ps-2.5 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))",
}}
/>
</div>
<Show when={preferences.appearance.sidebarOpen()}>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={140}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
</Show>
</aside>
</Show>
{/* Size containment collapses percentage-height descendants in WebKit. */}
@@ -84,6 +140,7 @@ export default function Layout(props: ParentProps) {
<ExtensionServerCover>
<Suspense>{props.children}</Suspense>
</ExtensionServerCover>
<CollapsedSidebarToggle />
</main>
</div>
<ExtensionSlot at="window.bottom" input={{}} />
+111
View File
@@ -0,0 +1,111 @@
import { Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useSettings } from "@/settings/model"
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
export const macTrafficLightsBaseWidth = 68
// The toggle's 28px plus a 4px gap before the header contents.
const slotWidth = 32
// The shell's 8px inline inset plus the session header's 10px start padding.
const contentStart = 18
export function SidebarToggle() {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const keybind = () => command.keybindParts("sidebar.toggle")
return (
<Tooltip
class="shrink-0 [app-region:no-drag]"
placement="bottom"
value={
<>
{language.t("command.sidebar.toggle")}
<Show when={keybind().length > 0}>
<Keybind keys={keybind()} variant="neutral" />
</Show>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
state={settings.appearance.sidebarOpen() ? "pressed" : undefined}
onClick={() => settings.appearance.setSidebarOpen(!settings.appearance.sidebarOpen())}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={settings.appearance.sidebarOpen()}
aria-controls="vertical-tabs-sidebar"
icon={<Icon name="layout-left" />}
/>
</Tooltip>
)
}
// Where the collapsed sidebar's toggle sits at the start of the content area. Windows keeps its titlebar in vertical
// tabs mode, so the toggle lives there instead.
function useCollapsedSlot() {
const platform = usePlatform()
const settings = useSettings()
const desktop = createMediaQuery("(min-width: 768px)")
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const mac = () => platform.platform === "desktop" && platform.os === "macos" && !platform.windowFullscreen?.()
return {
visible: () => desktop() && !windows() && settings.appearance.tabLayout() === "vertical",
open: settings.appearance.sidebarOpen,
// Keep the toggle clear of the traffic lights, which end 68px from the window edge.
width: () =>
mac() ? macTrafficLightsBaseWidth / (platform.webviewZoom?.() ?? 1) - contentStart + slotWidth : slotWidth,
}
}
const slotClass =
"flex shrink-0 justify-end overflow-hidden transition-[width] duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"
// Floats over the top of every screen, centered on the session header's 48px row. Slides in from the content's start
// edge as the sidebar closes.
export function CollapsedSidebarToggle() {
const slot = useCollapsedSlot()
return (
<Show when={slot.visible()}>
<div
class={`absolute top-[calc(var(--shell-top-inset,8px)+10px)] start-[calc(var(--shell-inline-inset,8px)+10px)] z-40 ${slotClass}`}
style={{ width: slot.open() ? "0px" : `${slot.width()}px` }}
inert={slot.open()}
>
<div class="flex shrink-0 pe-1">
<SidebarToggle />
</div>
</div>
</Show>
)
}
// Reserves the floating toggle's room in a header, pushing its contents along as the toggle slides in. `start` is where
// the space begins, in pixels from the content area's start edge, so every header's contents line up after the toggle.
export function CollapsedSidebarToggleSpace(props: { start?: number; class?: string }) {
const slot = useCollapsedSlot()
return (
<Show when={slot.visible()}>
<div
aria-hidden="true"
class={`${slotClass} ${props.class ?? ""}`}
style={{ width: slot.open() ? "0px" : `${slot.width() + contentStart - (props.start ?? contentStart)}px` }}
/>
</Show>
)
}
+46 -13
View File
@@ -15,6 +15,7 @@ import { useSettings } from "@/settings/model"
import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { macTrafficLightsBaseWidth, SidebarToggle } from "@/shell/sidebar-toggle"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/shell/titlebar/session-events"
@@ -45,15 +46,15 @@ const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
// Native controls: 14px left inset, two 20px button pitches, and a 14px button.
const macTrafficLightsBaseWidth = 68
const macTrafficLightsTopClearance = 28
// iOS blurs page content just below the status bar in Home Screen web apps, so with a top safe area the phone
// titlebar row stays 16px clear of it, as ChatGPT's phone header does. Without one, 8px matches the content gap below.
const mobileTopClearance = "max(8px, min(16px, env(safe-area-inset-top, 0px) * 1000))"
const channelIndicatorVisible =
!!import.meta.env.VITE_OPENCODE_CHANNEL && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
const platform = usePlatform()
const command = useCommand()
@@ -109,6 +110,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
const titlebarItems = useTitlebarItems()
const hideVerticalTitlebar = createMemo(() => !!props.verticalTabs && !windows())
const homeInSidebarHeader = () => !windows() && !macTrafficLights() && !channelIndicatorVisible
const back = () => {
const next = backPath(history)
@@ -540,6 +542,9 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={windows() && props.verticalTabs}>
<SidebarToggle />
</Show>
<Show when={!mobile() && !props.verticalTabs}>{homeButton()}</Show>
<Show
@@ -715,17 +720,45 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
mount={mount}
ref={(element) => (element.className = "flex size-full min-h-0 flex-col")}
>
<Show when={macVerticalTabs()}>
<div
class="mb-4 min-h-7 w-full shrink-0"
style={{ height: `${macTrafficLightsTopClearance / zoom()}px` }}
data-tauri-drag-region
/>
</Show>
<Show when={!windows()}>
<ChannelIndicator sidebar />
{/* Shares the session header's 48px center line. Without traffic lights or a channel
icon, Home fills the start so the row doesn't leave an empty band. */}
<div
class="flex w-full shrink-0 gap-2"
classList={{
"items-start pt-2.5": homeInSidebarHeader(),
"mb-2 h-12 items-center": !homeInSidebarHeader(),
}}
style={{
"min-height": macVerticalTabs()
? `${macTrafficLightsTopClearance / zoom()}px`
: undefined,
}}
data-tauri-drag-region
>
<Show when={macTrafficLights()}>
{/* The sidebar's 10px start padding already covers part of the traffic lights. */}
<div
class="shrink-0"
style={{ width: `calc(${macTrafficLightsBaseWidth / zoom()}px - 10px)` }}
aria-hidden="true"
/>
</Show>
<Show
when={homeInSidebarHeader()}
fallback={
<>
<ChannelIndicator sidebar />
<div class="flex-1" />
</>
}
>
<div class="min-w-0 flex-1">{homeButton(true)}</div>
</Show>
<SidebarToggle />
</div>
</Show>
{homeButton(true)}
<Show when={!homeInSidebarHeader()}>{homeButton(true)}</Show>
<RecentlyClosedTabsMenu
vertical
onNewTab={openNewTab}
@@ -798,7 +831,7 @@ function ChannelIndicator(props: { horizontal?: boolean; sidebar?: boolean }) {
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
value={label()}
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "mb-4 ms-0.5 self-start" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
class={`shrink-0 [app-region:no-drag] ${props.sidebar ? "ms-0.5" : ""} ${props.horizontal ? "me-1.5" : ""} ${props.horizontal && platform.platform === "web" ? "ps-2.5" : ""}`}
>
<Dynamic
component={debug() ? "button" : "div"}