diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 9d1c75a4066..f32378e8a28 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -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", diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx index 1454f1685a6..8df87c2aab8 100644 --- a/packages/app/src/home/projects/view.tsx +++ b/packages/app/src/home/projects/view.tsx @@ -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) { >
-
{props.language.t("home.projects")}
+ {/* 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. */} +
+ +
{props.language.t("home.projects")}
+
0)}> {/* The bottom inset reserves room for the fade without covering rows stuck below the bar. */}
-
{props.children}
+
+ +
{props.children}
+
{/* Stuck timeline headers stack above this fade, so it only fades content scrolling under the bar. */}
store.appearance?.sidebarOpen, true), + setSidebarOpen(value: boolean) { + setStore("appearance", "sidebarOpen", value) + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/settings/navigation.tsx b/packages/app/src/settings/navigation.tsx index 76827b88006..c5ca7f0810a 100644 --- a/packages/app/src/settings/navigation.tsx +++ b/packages/app/src/settings/navigation.tsx @@ -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 (
- +
+ {/* 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. */} + + +
{props.mobileAction} diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 25bc4954cd2..edad2c9d5eb 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -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) {
{/* Size containment collapses percentage-height descendants in WebKit. */} @@ -84,6 +140,7 @@ export default function Layout(props: ParentProps) { {props.children} +
diff --git a/packages/app/src/shell/sidebar-toggle.tsx b/packages/app/src/shell/sidebar-toggle.tsx new file mode 100644 index 00000000000..22baad23711 --- /dev/null +++ b/packages/app/src/shell/sidebar-toggle.tsx @@ -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 ( + + {language.t("command.sidebar.toggle")} + 0}> + + + + } + > + settings.appearance.setSidebarOpen(!settings.appearance.sidebarOpen())} + aria-label={language.t("command.sidebar.toggle")} + aria-expanded={settings.appearance.sidebarOpen()} + aria-controls="vertical-tabs-sidebar" + icon={} + /> + + ) +} + +// 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 ( + +
+
+ +
+
+
+ ) +} + +// 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 ( + +