diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx index 04a0f9dca187..ddb466b9f265 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -71,7 +71,7 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) { display: none; } `} - + diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx index 76680473eebc..16c27304c596 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx @@ -5,6 +5,9 @@ import { TreeSplit } from './tree-split' /** Dispatch a layout node to its renderer — the split/group recursion point. * `root` marks the tree's top split (side collapse applies only there). + * `rootRow` marks the row split that owns the side columns — usually the root + * itself, but in a column-root layout (Terminal deck, Quad) it's the row + * child holding sessions/workspace/files. Side collapse (⌘B/⌘J) applies here. * `parentAxis` is the containing split's orientation — a group collapses * ALONG that axis, so it picks the minimized form (row → vertical rail, * column → horizontal header). `railSide` is which half of that row the @@ -13,15 +16,17 @@ export function TreeNode({ node, parentAxis, railSide, - root + root, + rootRow }: { node: LayoutNode parentAxis?: 'column' | 'row' railSide?: 'left' | 'right' root?: boolean + rootRow?: boolean }) { return node.type === 'split' ? ( - + ) : ( ) 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 843018c01a3f..f6fe8e926cd1 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 @@ -66,7 +66,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot) } -export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { +export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boolean; rootRow?: boolean }) { const containerRef = useRef(null) const panes = useContributions('panes') const hiddenPanes = useStore($hiddenTreePanes) @@ -80,6 +80,21 @@ export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { const horizontal = node.orientation === 'row' const axis = node.orientation + // When the root is a column (Terminal deck, Quad), the root ROW — the one + // the side-collapse system operates on — is a row child containing main. + // Propagate `rootRow` to that child so its `semanticSides` fires. + const childRootRow = (child: LayoutNode): boolean => { + if (!root || horizontal) { + return false + } + + if (child.type !== 'split' || child.orientation !== 'row') { + return false + } + + return allPaneIds(child).some(id => paneChrome(paneFor(id)).placement === 'main') + } + // A pane leaves the grid when its contribution isn't registered (yet) — a // runtime plugin's pane collapses until the plugin loads, then appears; no // placeholder flash — when a chrome toggle hides it, or when the viewport @@ -353,7 +368,9 @@ export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { // ⌘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. - const semanticSides = root && horizontal && collapsedSides.size > 0 && !editMode + // `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 const sideGone = (i: number) => { if (!semanticSides) { @@ -463,7 +480,12 @@ export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { /> )} {!narrowCollapsed && ( - + )} ) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 815c07a5f5ce..2ec8e72da067 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -35,7 +35,8 @@ import { setGroupHeaderHidden as setGroupHeaderHiddenOp, setGroupMinimized, setSplitWeights as setSplitWeightsOp, - splitGroupZone as splitGroupZoneOp + splitGroupZone as splitGroupZoneOp, + type SplitNode } from './model' import { rootChildSide } from './renderer/track-model' @@ -357,18 +358,52 @@ export function removeTreePane(paneId: string) { } } +/** The layout's root ROW — the split that contains main + the side columns. + * Usually the root itself (Default, Focus); in a column-root layout (Terminal + * deck, Quad) it's the row child that holds sessions/workspace/files. Returns + * null when the tree has no row split with side-eligible panes. */ +function rootRow(): SplitNode | null { + const tree = $layoutTree.get() + + if (!tree || tree.type !== 'split') { + return null + } + + if (tree.orientation === 'row') { + return tree + } + + // Column root: find the row child that contains the main pane — that's the + // row the side-collapse system operates on (sessions left, files right). + const panes = registry.getArea('panes') + + const hasMain = (node: LayoutNode): boolean => { + if (node.type === 'group') { + return node.panes.some(id => + (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main' + ) + } + + return node.children.some(hasMain) + } + + return tree.children.find(child => child.type === 'split' && child.orientation === 'row' && hasMain(child)) as + | SplitNode + | undefined ?? null +} + /** Which root-row side a pane currently lives in, or null when it's nested * with main (dragged into the middle) — where a side collapse can't hide it. * Lets side-bound closers (files/sessions) fall back to dismissal. */ export function paneRootSide(paneId: string): null | TreeSide { - const tree = $layoutTree.get() + const row = rootRow() - if (tree?.type !== 'split' || tree.orientation !== 'row') { + if (!row) { return null } const panes = registry.getArea('panes') - const child = tree.children.find(c => allPaneIds(c).includes(paneId)) + const child = row.children.find(c => allPaneIds(c).includes(paneId)) return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null } @@ -453,15 +488,15 @@ export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { * reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do. */ export function layoutHasRootSide(side: TreeSide): boolean { - const tree = $layoutTree.get() + const row = rootRow() - if (tree?.type !== 'split' || tree.orientation !== 'row') { + if (!row) { return false } const panes = registry.getArea('panes') - return tree.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) + return row.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) } /** @@ -515,13 +550,13 @@ export function bindTreeSideVisibility( * panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main * column (never side-collapsed). */ export function treeSideOfPane(paneId: string): TreeSide | null { - const tree = $layoutTree.get() + const row = rootRow() - if (!tree || tree.type !== 'split' || tree.orientation !== 'row') { + if (!row) { return null } - const child = tree.children.find(node => allPaneIds(node).includes(paneId)) + const child = row.children.find(node => allPaneIds(node).includes(paneId)) if (!child) { return null @@ -574,8 +609,26 @@ export function revealTreePane(paneId: string) { const tree = $layoutTree.get() const group = tree ? findGroupOfPane(tree, paneId) : null - if (tree && group && group.active !== paneId) { - commit(setActivePaneOp(tree, group.id, paneId)) + if (tree && group) { + // A minimized zone must be restored — "reveal" means show the pane, not + // just front its tab behind a collapsed rail. Without this, a tool panel + // (terminal/logs) in a shared zone stays minimized after its toggle opens + // it: setPaneCollapsed's shared-zone branch calls revealTreePane instead + // of toggleTreeGroupMinimized, so the zone never un-minimizes and the + // pane appears to "close but not open" on ctrl-` / tab click. + let next = tree + + if (group.minimized) { + next = setGroupMinimized(next, group.id, false) + } + + if (group.active !== paneId) { + next = setActivePaneOp(next, group.id, paneId) + } + + if (next !== tree) { + commit(next) + } } } @@ -1039,6 +1092,18 @@ export function restoreTreePane(paneId: string) { if (open) { open() + // The opener may be a no-op — the store was already true (zone minimized + // via the zone menu, not the toggle). nanostores don't fire listeners on + // a same-value .set(), so the bindPaneCollapse listener never runs and + // the zone stays minimized. Un-minimize directly when that happens. + const group = paneGroup(paneId) + + if (group?.minimized) { + toggleTreeGroupMinimized(group.id, false) + } + + revealTreePane(paneId) + return }