fix(desktop): keep sidebar tabs and restore controls clear of titlebar chrome
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]')!
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user