From 6cc51b407c2504941e44957d4df658dd8d01a6de Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 11 Sep 2026 04:00:24 -0700 Subject: [PATCH] fix(desktop): keep sidebar tabs and restore controls clear of titlebar chrome --- apps/desktop/DESIGN.md | 6 +- .../src/app/shell/titlebar-controls.tsx | 3 +- .../collapse-restore-affordance.test.tsx | 5 +- .../tree/renderer/panel-titlebar.ts | 62 ++++++++++++++++ .../tree/renderer/tree-group.test.tsx | 8 +++ .../pane-shell/tree/renderer/tree-group.tsx | 70 +++++++++---------- .../pane-shell/tree/renderer/tree-split.tsx | 12 ++-- 7 files changed, 117 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/panel-titlebar.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 22217f20a0..845b36de62 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -206,7 +206,11 @@ Sizes: `default`, `xs`, `overlay` (titlebar glyph counts). Top-edge panels extend into the native titlebar band. Their tab strips remain inside their own zones so tab drops, focus, and split boundaries use the same -geometry. Lower panels keep local headers. Empty header space moves the window; +geometry. Panels without room beside the measured window controls place their +tabs on a full-width row below the controls. Minimized row groups use vertical +restore rails, including groups with multiple tabs. Sidebar buttons and shortcuts +restore minimized or fully hidden side groups without changing the selected tab. +Lower panels keep local headers. Empty header space moves the window; tabs and actions remain no-drag, with native-control space reserved from the existing traffic-light and Window Controls Overlay measurements. diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 6014a3eb16..43917cfa5f 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -268,7 +268,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: return ( <> -
+
{visibleLeftTools.map(tool => ( ))} @@ -278,6 +278,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/collapse-restore-affordance.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/collapse-restore-affordance.test.tsx index 02faeb78ec..88ab49cb96 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/collapse-restore-affordance.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/collapse-restore-affordance.test.tsx @@ -200,7 +200,7 @@ describe('docked tool tile — collapsing keeps the restore chip', () => { }) }) -describe('a stacked tool zone collapsed in a row keeps the horizontal strip', () => { +describe('a stacked tool zone collapsed in a row keeps a vertical restore rail', () => { beforeEach(() => { registerPane('workspace', { placement: 'main', uncloseable: true }, 'Chat') registerPane('terminal', { placement: 'bottom' }, 'Terminal') @@ -223,6 +223,7 @@ describe('a stacked tool zone collapsed in a row keeps the horizontal strip', () expect(tabEl('terminal')).toBeTruthy() expect(tabEl('logs')).toBeTruthy() - expect(globalThis.document.querySelector('[data-zone-tabstrip="g-tools"]')).toBeTruthy() + expect(tabEl('terminal')?.getAttribute('data-vertical')).toBe('true') + expect(tabEl('logs')?.getAttribute('data-vertical')).toBe('true') }) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/panel-titlebar.ts b/apps/desktop/src/components/pane-shell/tree/renderer/panel-titlebar.ts new file mode 100644 index 0000000000..f3135469c8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/panel-titlebar.ts @@ -0,0 +1,62 @@ +import { type RefObject, useCallback, useLayoutEffect, useState } from 'react' + +import { useResizeObserver } from '@/hooks/use-resize-observer' + +/** Reserve actual chrome intersections, including after a neighbor becomes a rail. */ +export function usePanelTitlebar(ref: RefObject, enabled: boolean, minimized: boolean) { + const [belowControls, setBelowControls] = useState(true) + + const measure = useCallback(() => { + const element = ref.current + + if (!enabled || !element) { + return + } + + const rect = element.getBoundingClientRect() + + if (!rect.width) { + return + } + + const leftControls = document.querySelector('[data-titlebar-cluster="left"]')?.getBoundingClientRect() + + const rightControls = document + .querySelector('[data-titlebar-cluster="right"]') + ?.getBoundingClientRect() + + // Empty chrome during a route overlay retains the last safe reservation. + if (!leftControls || !rightControls) { + return + } + + const left = Math.min(rect.width, Math.max(0, leftControls.right + 12 - rect.left)) + const right = Math.min(rect.width - left, Math.max(0, rect.right - rightControls.left + 24)) + element.style.setProperty('--panel-titlebar-left', `${left}px`) + element.style.setProperty('--panel-titlebar-right', `${right}px`) + setBelowControls(minimized || rect.width - left - right < 120) + }, [enabled, minimized, ref]) + + useResizeObserver(measure, ref) + useLayoutEffect(() => { + if (!enabled) { + return + } + + measure() + const observer = new ResizeObserver(measure) + + for (const element of document.querySelectorAll('[data-titlebar-cluster], [data-tree-group]')) { + observer.observe(element) + } + + window.addEventListener('resize', measure) + + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + } + }, [enabled, measure]) + + return enabled && belowControls +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx index 849efeb5e8..663d771625 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.test.tsx @@ -66,6 +66,14 @@ describe('TreeGroup', () => { render: () =>
Terminal
}) vi.stubGlobal('CSS', { escape: (value: string) => value }) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) render() const zone = container!.querySelector('[data-tree-group]')! const strip = zone.querySelector('[data-zone-tabstrip]')! diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index a582eb8d42..c33a8e3736 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -84,6 +84,7 @@ import { } from '../tab-selection' import { startPaneDrag } from './drag-session' +import { usePanelTitlebar } from './panel-titlebar' import { tabStripVisibleForZone } from './strip-visibility' import { useActiveTabVisible } from './tab-strip-scroll' import { paneChrome } from './track-model' @@ -222,9 +223,7 @@ export function TreeGroup({ node, parentAxis, railSide = 'left', - topEdge = false, - leftEdge = false, - rightEdge = false + topEdge = false }: { node: GroupNode parentAxis?: 'column' | 'row' @@ -239,6 +238,8 @@ export function TreeGroup({ // The scrolling tab list inside the header (the strip also holds the // minimize chevron, which must not scroll away). const tabsRef = useRef(null) + const tabsBelowControls = usePanelTitlebar(ref, topEdge, Boolean(node.minimized)) + const tabsInTitlebar = topEdge && !tabsBelowControls // The chip under the last right-click — the pane the zone menu's Split // actions carry into the new zone (header background = the active pane). // STATE, not a ref: the menu items (incl. Close's visibility) are JSX @@ -350,11 +351,9 @@ export function TreeGroup({ // (tabs reading top-to-bottom). In a column (stacked zones) the horizontal // header IS the collapsed form, exactly as before. // - // EXCEPTION: when the zone has ≥2 shown panes, keep the horizontal tab bar - // even when minimized — the user can still switch (and restore) without - // expanding first. The vertical rail is only for a lone pane, where it - // still renders that pane's tab as the restore handle. - const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty && shown.length <= 1 + // Every minimized row group becomes a vertical restore rail. A horizontal + // multi-tab strip cannot fit in the collapsed 28px track. + const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty // A minimized group IS its header, so it shows one regardless. const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || stripVisible) @@ -440,7 +439,13 @@ export function TreeGroup({ setMenuPane((e.target as HTMLElement).closest('[data-tree-tab]')?.getAttribute('data-tree-tab') ?? undefined) }} ref={ref} - style={wcOverlap ? { paddingTop: wcOverlap.y + wcOverlap.height } : undefined} + style={ + wcOverlap + ? { paddingTop: wcOverlap.y + wcOverlap.height } + : topEdge && verticalCollapse + ? { paddingTop: TITLEBAR_HEIGHT } + : undefined + } > {wcOverlap && (
- {topEdge && leftEdge && ( -
-
-
-
-
-
-
+ {topEdge && ( +