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
}