cmux NEXT panes: a pane in a column with rows closes in the click's turn, never after the daemon's tree

Merged
TE
2d ago
+203-14

Leo (2026-11-03): "it's so slow to open and close idk if there's some sort of threading issue but it's nigh instant for claude and codex so we nede to be at least as fast". (2026-10-25): "closing the right pane seems be like... waiting for the right tab's closing animation to play before closing".

leo-named-fix: closing a pane never waits for anything; the close takes effect on the click.

What changed

Before this change, a pane in a column with rows (rows-v1) waited for the daemon's tree when its last tab closed or on Close Pane: removingSplitPane returned nil for hasRows, so showPaneClose had nothing to show. Now such a pane leaves in the click's turn, the same way #25163 made a column's only pane leave.

LayoutModel.removingRowPane mirrors cmux-tui's close (remove_pane_from_screen_layout, then LayoutColumn::normalize_rows and project_layout_columns):

  • the pane leaves its row's split tree, and its sibling takes its place;
  • a row the close empties leaves the column. When the top row goes, the next row becomes the top and keeps its id and height. The chain split under it goes, as normalize_rows drops it;
  • the rows that stay keep their heights (the daemon does not rescale them);
  • a column left with one row becomes a plain column (rows = [], root = that row's tree), and a lone column like that becomes the screen's split tree again unless it is a pinned dock;
  • the compat root chain is folded again from the rows: chain split ids are kept, and each ratio is the heights above over the heights through, computed in f32 as fold does. That keeps ratio(of:) and hasSameStructure equal to the echo's;
  • the agent chat dock's role is released when its top pane leaves (release_agent_chat_role). The same applies to the plain-column path.

CloseSizing.tree(in:holding:) now reads the row's tree, so even sizing equalizes a chain inside a row. A row's only pane leaves no chain to size, and the rows that stay keep their heights. successor(of:in:) picks the row sibling's first pane, then for a row's only pane the row above, else the one below. In the app the focus reducer still makes the pick.

The close-animation DESIGN.md is updated to match.

Tests (PaneCloseControllerTests, through the real controller and the daemon store's echo)

  • aRowsOnlyPaneLeavesInTheKeysTurnAndItsEchoShowsTheSame: rows beside a column. The bottom row's pane leaves, the column becomes plain, and the echo's layout == what was shown.
  • closingRowsOfALoneColumnShowWhatTheDaemonsTreeShows: three rows. The top row's close keeps rows 22 and 23 with their heights, then the bottom row's close leaves the screen as a split tree. Each echo == what was shown.
  • aPaneLeavingARowsChainEvensTheChainThroughItsEcho: even sizing inside a row. The echo keeps the ratio, and it is sent once.
  • aRowsPaneLeavesFasterThanTheDaemonsTreeBringsIt: the timing below.

Without the fix, each #require(showPaneClose()) fails, because it returns nil for a row pane. All five PaneClose suites pass at 9955ef3 (fleet step 022f196b56f713a7b1d67db5), and the CmuxNext compile check passed (job d93c8e0e9905194939551043).

Performance

metricbeforeafterdeltaharness or run link
close to pane gone from the shown layout, p50daemon round trip + 2.042 ms (the echo's apply)1.849 ms, in the click's turnthe whole daemon round trip comes offaRowsPaneLeavesFasterThanTheDaemonsTreeBringsIt, 20 rounds, fleet step 022f196b56f713a7b1d67db5
same, p95daemon round trip + 2.086 ms1.924 mssamesame

The harness times the echo's apply alone with the daemon's reply already in hand, so the "before" numbers are a lower bound: in the app the pane also waits for the close-pane round trip and the snapshot that follows it.

Risk

This changes layout state, but only the shown overlay: nothing new is sent to the daemon, and a refused close brings the pane back (cancelPaneClose). If the mirror got a daemon row rule wrong, the echo would make a one-frame structural jump. The echo-equality tests cover the row-leaves, top-row-leaves, last-two-rows and chain-sizing cases. A chain that is not the daemon's fold, or a pane in no mapped row, falls back to a plain removal or to waiting for the daemon's tree. The terminal close guard and the browser beforeunload path are untouched.

Agent session: agent session

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Makes closing a pane in a column with rows take effect in the click's turn instead of waiting for the daemon's tree, so the right pane closes as fast as other panes.

  • The optimistic close now mirrors cmux-tui's normalize_rows for row panes: an emptied row leaves its column, remaining rows keep their heights, and a lone column becomes the screen's split tree. Close sizing and the focus successor read the row's tree, and the agent chat dock releases its role when its top pane leaves.
  • Layout state changes only affect the shown overlay; nothing new is sent to the daemon, and a refused close still brings the pane back.