fix(desktop): keep sidebar tabs and restore controls clear of titlebar chrome

This commit is contained in:
brooklyn!
2026-09-11 04:00:24 -07:00
parent 477194879c
commit 6cc51b407c
7 changed files with 117 additions and 49 deletions

View File

@@ -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.

View File

@@ -268,7 +268,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
return (
<>
<div aria-label={t.shell.windowControls} className={leftClusterClass}>
<div aria-label={t.shell.windowControls} className={leftClusterClass} data-titlebar-cluster="left">
{visibleLeftTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
@@ -278,6 +278,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<div
aria-label={t.shell.appControls}
className={cn(titlebarToolClusterClass, 'right-(--titlebar-tools-right) top-(--titlebar-controls-top)')}
data-titlebar-cluster="right"
>
<TitlebarToolButton navigate={navigate} tool={flipTool} />
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />

View File

@@ -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')
})
})

View File

@@ -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<HTMLElement | null>, 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<HTMLElement>('[data-titlebar-cluster="left"]')?.getBoundingClientRect()
const rightControls = document
.querySelector<HTMLElement>('[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
}

View File

@@ -66,6 +66,14 @@ describe('TreeGroup', () => {
render: () => <div>Terminal</div>
})
vi.stubGlobal('CSS', { escape: (value: string) => value })
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
render(<TreeGroup leftEdge node={terminalGroup(false)} rightEdge topEdge />)
const zone = container!.querySelector('[data-tree-group]')!
const strip = zone.querySelector<HTMLElement>('[data-zone-tabstrip]')!

View File

@@ -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<HTMLDivElement>(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 && (
<div
@@ -498,34 +503,25 @@ export function TreeGroup({
{/* Keep the header INSIDE its zone: titlebar drops use the same panel
bounds, strip refs, focus ownership and split geometry as the body. */}
{(headerVisible || topEdge) && (
{(headerVisible || (topEdge && !verticalCollapse)) && (
<div
className="flex min-w-0 shrink-0 bg-(--ui-sidebar-surface-background)"
className="relative flex min-w-0 shrink-0 bg-(--ui-sidebar-surface-background)"
data-panel-header=""
style={topEdge ? { height: TITLEBAR_HEIGHT } : undefined}
style={topEdge ? { height: TITLEBAR_HEIGHT + (tabsBelowControls && headerVisible ? 28 : 0) } : undefined}
>
{topEdge && leftEdge && (
<div className="flex shrink-0">
<div className="w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]" data-window-drag-handle="" />
<div className="relative w-(--titlebar-controls-width,96px)">
<div
className="absolute inset-x-0 top-0 h-(--titlebar-controls-top,5px) [-webkit-app-region:drag]"
data-window-drag-handle=""
/>
</div>
<div className="w-3 [-webkit-app-region:drag]" data-window-drag-handle="" />
</div>
{topEdge && (
<div aria-hidden="true" className="shrink-0" style={{ width: 'var(--panel-titlebar-left, 100%)' }} />
)}
{headerVisible ? (
<ZoneMenu {...zoneMenu}>
<PaneTabStrip
className="flex-1"
className={cn('flex-1', tabsBelowControls && 'absolute inset-x-0 bottom-0')}
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
data-zone-tabstrip={node.id}
listRef={tabsRef}
onPointerDown={event => {
// Native titlebar gaps move the window; tabs keep their own drag.
if (!topEdge) {
if (!tabsInTitlebar) {
startPaneDrag(
activeId,
event,
@@ -537,7 +533,7 @@ export function TreeGroup({
}}
ref={stripRef}
style={{ cursor: 'grab', WebkitAppRegion: dragging ? 'no-drag' : undefined } as CSSProperties}
titlebar={topEdge}
titlebar={tabsInTitlebar}
trailing={
<>
{minimizable && (
@@ -694,18 +690,16 @@ export function TreeGroup({
)}
</PaneTabStrip>
</ZoneMenu>
) : (
<div className="min-w-0 flex-1 [-webkit-app-region:drag]" />
) : null}
{topEdge && (!headerVisible || tabsBelowControls) && (
<div
className="min-w-0 flex-1 self-start [-webkit-app-region:drag]"
data-window-drag-handle=""
style={{ height: TITLEBAR_HEIGHT }}
/>
)}
{topEdge && rightEdge && (
<div className="flex shrink-0">
<div
className="w-6"
data-window-drag-handle=""
style={{ WebkitAppRegion: dragging ? 'no-drag' : 'drag' } as CSSProperties}
/>
<div className="w-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,24px))] [-webkit-app-region:no-drag]" />
</div>
{topEdge && (
<div aria-hidden="true" className="shrink-0" style={{ width: 'var(--panel-titlebar-right, 0px)' }} />
)}
</div>
)}
@@ -781,7 +775,7 @@ export function TreeGroup({
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, active?.title ?? activeId)}
style={{
top: topEdge ? TITLEBAR_HEIGHT : headerVisible ? 28 : 0,
top: topEdge ? TITLEBAR_HEIGHT + (tabsBelowControls && headerVisible ? 28 : 0) : headerVisible ? 28 : 0,
background:
'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))',
outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)'

View File

@@ -24,6 +24,7 @@ import {
$hiddenTreePanes,
$narrowViewport,
isCollapsePane,
paneRootSide,
persistTree,
presetSplitWeights,
setTreeGroupMinimized,
@@ -42,7 +43,6 @@ import {
paneChrome,
type PaneSizing,
resolveCssPx,
rootChildSide,
shownPaneIds,
subtreeGone,
type TrackContext
@@ -610,10 +610,8 @@ export function TreeSplit({
// leftover; capped sidebars (review/files) keep their max and stay put.
const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized)
// SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row,
// ⌘B owns the sessions column and ⌘J the other side columns — by pane
// placement, NOT position, so a ⌘\ flip moves the columns without
// rewiring the toggles (main parity). In edit mode sides stay visible.
// Side toggles own physical sides of the root row, including after a flip.
// In edit mode sides stay visible.
// `rootRow` covers both a row root (Default, Focus) and a row nested inside
// a column root (Terminal deck, Quad) — wherever the side columns live.
const semanticSides = rootRow && horizontal && collapsedSides.size > 0 && !editMode
@@ -623,7 +621,7 @@ export function TreeSplit({
return false
}
const side = rootChildSide(node.children[i], paneFor)
const side = paneRootSide(allPaneIds(node.children[i])[0])
return side !== null && collapsedSides.has(side)
}
@@ -704,7 +702,7 @@ export function TreeSplit({
collapsed
? { display: 'none' }
: minimized
? { flex: `0 0 ${MINIMIZED_TRACK}` }
? { flex: `0 0 ${horizontal ? MINIMIZED_TRACK : 'auto'}` }
: {
// One flexbox formula for everything: a sized zone is
// grow-0 shrink-1 from its preferred basis (it yields