fix(desktop): panel layout toggle bugs — terminal reveal, side collapse in column-root layouts, zone-menu restore (#65162)

* fix(desktop): un-minimize zone when revealing a collapsed tool panel

`revealTreePane()` fronted the pane's tab but never un-minimized its zone
when the pane lived in a shared zone (terminal + logs, or a tool panel
stacked with the workspace). The shared-zone branch of `setPaneCollapsed`
routes opens through `revealTreePane` instead of `toggleTreeGroupMinimized`,
so the zone stayed collapsed and the terminal appeared to "close but not
open" on Ctrl+` — and tab clicks needed a double-click because the first
click's `restoreTreePane` → opener → listener → `revealTreePane` chain
didn't un-minimize either.

The fix: `revealTreePane` now restores a minimized zone before fronting
the pane, chaining the un-minimize + activate into a single `commit` so
the second op sees the updated tree.

* fix(desktop): side collapse in column-root layouts + restore after zone-menu minimize

Two follow-up fixes for the panel layout:

1. Ctrl+B / Ctrl+J sidebar toggles didn't work in the Terminal deck (and
   Quad) layout. The side-collapse system (treeSideOfPane, paneRootSide,
   layoutHasRootSide, and the renderer's semanticSides) only operated on the
   ROOT split when it was a row — but Terminal deck's root is a column, so the
   side columns (sessions/files) nested inside a child row were invisible to
   the collapse system. Extracted a rootRow() helper that finds the row
   containing main (the root itself for row layouts, or the row child of a
   column root), and a rootRow prop propagated through TreeNode → TreeSplit so
   semanticSides fires on the right split regardless of root orientation.

2. Clicking the "logs" tab in a minimized terminal/logs zone needed a
   double-click when the zone was minimized via the zone menu (right-click →
   minimize), not the toggle. restoreTreePane called the opener
   ($logsOpen.set(true)) and returned early — but when the store was already
   true, nanostores don't fire the listener, so setPaneCollapsed/revealTreePane
   never ran and the zone stayed minimized. Now restoreTreePane also
   un-minimizes directly + calls revealTreePane when the zone is still
   minimized after the opener runs.
This commit is contained in:
ethernet 2026-07-15 15:37:09 -04:00 committed by GitHub
parent b6c11a35ac
commit c1945d410b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 18 deletions

View file

@ -71,7 +71,7 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
display: none;
}
`}</style>
<TreeNode node={tree} root />
<TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} />
<NarrowOverlays />
<TreeEditBar />
<ZoneEditor />

View file

@ -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' ? (
<TreeSplit node={node} root={root} />
<TreeSplit node={node} root={root} rootRow={rootRow} />
) : (
<TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} />
)

View file

@ -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<HTMLDivElement>(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 && (
<TreeNode node={child} parentAxis={axis} railSide={horizontal ? railSideFor(i) : undefined} />
<TreeNode
node={child}
parentAxis={axis}
railSide={horizontal ? railSideFor(i) : undefined}
rootRow={rootRow || childRootRow(child)}
/>
)}
</div>
)

View file

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