))}
@@ -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 && (
+
)}
{headerVisible ? (
{
// 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({
)}
- ) : (
-
+ ) : null}
+ {topEdge && (!headerVisible || tabsBelowControls) && (
+
)}
- {topEdge && rightEdge && (
-
+ {topEdge && (
+
)}
)}
@@ -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)'
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
index b2c024eb50..d32d4abd94 100644
--- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx
@@ -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