Skip to content

cmux NEXT: anything opened on the right goes to the lower pane when the right already holds something - #28044

Closed
teamleaderleo wants to merge 17 commits into
feat-cmux-nextfrom
lane/ux-right-lower-pane
Closed

teamleaderleo wants to merge 17 commits into
feat-cmux-nextfrom
lane/ux-right-lower-pane

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Leo, 2026-10-30:

"subagent stuff... or anything we expect to open on "the right"... if there's already a thing on the right, there should be logic for just putting it on the lower pane of the right. that kinda split."

Earlier, 2026-10-28 (the spec, leo-workspace design/pane-placement.md):

"I mean the right top pane, and then you split to the bottom once so that you get the three items. But you want four items. I don't know what the fourth item should go."

Stacked on #27789. This PR's own diff is ef884b554e8f..HEAD. It stays a draft until #27789 lands.

The rule

One function, RightColumnPlacement.decide (CmuxNextAgentPane), decides every open on the right of a pane. Its input is the right column the window actually shows (RightColumn.measure, from the pane geometry):

What's on the right of the source Where the new item goes
nothing a new split right of the source
one pane (diff, terminal, browser, session, anything) a new split below it; that pane keeps what it shows
two or more panes a tab in the lowest pane, never a third split
  • No split leaves the chat under 480 pt or the right column under 420 pt; that open becomes a tab instead.
  • Closing the lower pane's last tab removes the pane, so the split collapses back, and the next open splits below again.

Paths routed through it

  • Transcript subagent / Task cards (AgentSubagentPanes): cmux NEXT: subagents fill one split below the first, then open as tabs #27789's placement function, now shared and reading the window's geometry instead of the tracker's own list.

  • Side chats, /btw, Session Info sessions, transcript session cards (AgentSidePanes; chat.sideChat and chat.offer.open).

  • Links opened to the right, and chats opened from Needs you (LinkOpener / LinkOpenPlacement): new .splitBelow target for a source that takes tabs. openWeb's reuse of a page already open on the right, openFolder and DurableTabLink's fallback handle it.

  • Main-pane layout (ux-owner, 2026-10-30, on Leo's "anything we expect to open on 'the right'"): the strip column right of the docked chat is its right side, so the same lower split applies (LinkOpenPlacement.inColumn, openOnRight). With nothing on its right yet, main-pane layout still makes the column by its own rule.

Unchanged: Home's pinned dock (a different surface, not named) keeps the link rule: the pane on its right, else a column beside the dock.

Overlap

This overlaps #26582 (RightPanePlacement, the general document/session policy, owned by another lane), which doesn't touch these paths yet. This PR doesn't take its type or touch its branch. Every path here calls one pure function, so it can fold into that policy later.

Tests (one per path)

  • RightColumnPlacementTests: the rule itself, narrow floors, and close-collapses-then-splits-again.
  • AgentSubagentPanesPlacementTests: with the right side occupied, the first subagent splits below it and the second is a tab there; closing collapses; a 4th subagent is a tab, never a third split.
  • AgentSideSessionPaneTests: side sessions split right, then once below, then become tabs in the lower pane; with the right side occupied they split below it; closing the lower pane's tabs collapses it and the next open splits again.
  • LinkOpenPlacementTests: with the right side measured, a link beside a pane splits below it, a full column takes a tab in its lower pane, narrow columns take a tab, and the main-pane strip column gets the same lower split.

Spec: leo-workspace design/pane-placement.md gains "Something already on the right (2026-10-30)" (lane/ux-pane-placement-lower).

Screenshots

Before/after: pending a fleet capture (requested from ux-owner for the media tour). They'll be added before this leaves draft.

Risk: a behaviour change in where the 2nd+ item opens on the right, for all of these paths. No new timers, observers or animation loops.

Agent session: agent session

🤖 Generated with Claude Code

teamleaderleo and others added 5 commits October 30, 2026 01:13
Leo, 2026-10-28: "But I don't know if you want to do more splits versus just
adding more to the top tab or space pane, the top pane. I mean the right top
pane, and then you split to the bottom once so that you get the three items.
But you want four items. I don't know what the fourth item should go."

Transcript subagent and Task cards (chat.subagentPane, AgentSubagentPanes)
stacked a new split below the newest subagent pane for every subagent, with
no cap and no width floor. They now follow leo-workspace
design/pane-placement.md: an open subagent's tab is shown; the first opens in
a split right of the chat, the second in one split below it, and the third
and later are tabs in the newest subagent's pane, never another split. No
split leaves the chat under 480 pt or the right column under 420 pt; that
open is a tab instead. An unfocused tab open leaves focus where it is.

The decision is one function, AgentSubagentPanes.placement(chat:hooks:), so
it can fold into #26582's RightPanePlacement later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…cused subagent tab is shown in its pane

Review of #27789 at 1eed675 (Codex, BLOCK):
- openSubagent passed main: for every split, so in main pane layout the
  second subagent's split below became another tab in the right column.
  Only the first, chat-relative split goes through main pane layout now.
- An unfocused .tab open was created unselected, so the new subagent was
  hidden behind the one already showing. It is selected in its subagent
  pane without moving keyboard focus (selectWhenReported); in the chat's
  own pane (a narrow window) it waits in the strip so the chat stays shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
… public API)

Fleet compile at b17ce6d: 'cannot be declared public because its type
uses an internal type' for the width floors and the width hook, under
InternalImportsByDefault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…he right already holds something

Leo, 2026-10-30: "subagent stuff... or anything we expect to open on "the
right"... if there's already a thing on the right, there should be logic for
just putting it on the lower pane of the right. that kinda split."

One rule, RightColumnPlacement.decide (CmuxNextAgentPane), now places every
open on the right of a pane, from the right column the window shows
(RightColumn.measure / RightColumn.panes):
- nothing on the right: a split right of the source;
- one pane on the right: a split below it (it keeps what it shows);
- two or more: a tab in the lowest pane, never another split;
- no split leaves the chat under 480 pt or the right column under 420 pt.
Closing the lower pane's last tab removes the pane, so the split collapses
and the next open splits below again.

Paths routed through it:
- transcript subagent / Task cards (AgentSubagentPanes, #27789's function,
  now shared and read from the window's geometry);
- side chats, /btw, Session Info sessions and transcript session cards
  (AgentSidePanes; chat.sideChat and chat.offer.open);
- links opened to the right and chats opened from Needs you (LinkOpener /
  LinkOpenPlacement gain .splitBelow, for a source that takes tabs).
A chat that is its screen's main pane keeps main pane layout's own rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 30, 2026
@blacksmith-sh

blacksmith-sh Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_log2']/
CoreFoundation, missing 1: ['_log2']
View Logs
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)/
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)
View Logs
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)/
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)
View Logs

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

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

web and size checks: green

@teamleaderleo teamleaderleo added media-tour-mac media-tour Run the cmux-next UI tour (cmux-next-media.yml) on every push labels Oct 30, 2026
ux-owner, 2026-10-30, on Leo's "anything we expect to open on 'the right'":
in main pane layout the strip column right of the docked chat is that right
side. A chat there whose right side already holds a pane splits below it,
then fills the lower pane with tabs (links through LinkOpenPlacement.inColumn,
chat opens through openOnRight). With nothing on its right yet, main pane
layout still makes the column (dock the chat, or a tab in the strip pane).
Home's pinned dock is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
Review of #28044 at 20e433f (Codex, BLOCK, P1): RightColumn walked the
column with PaneHandlers.neighbor, whose horizontal step picks the adjacent
column's most recently focused pane, so a two-pane right column could read
as one and the next open split again or targeted the wrong pane.
RightColumnPlacement.column(beside:frames:gap:) now takes the panes whose
left edge is nearest the source's right edge and that overlap its height,
top to bottom, from the layout's navigation frames.

The review's other two P1s (main pane layout's chat opens and links skipped
the rule) were fixed at 36862fa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…the tab

Review of #28044 at 34122a1 (Codex, BLOCK, P1): with no pane on its right,
LinkOpenPlacement returned .splitRight without the chat floor, so a 900-959 pt
source split to under 480 pt. RightColumnPlacement.decide now judges that
split too: measured too narrow, the tab opens in the source pane. A new
column (layout.linkPlacement new-column) adds width to the strip, so it keeps
opening one; an unmeasured source keeps the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
teamleaderleo added a commit that referenced this pull request Oct 30, 2026
…s pane through the real placement

A capture of #28044 (4154db0 vs 34122a1) never reached its scene: a tray
line's chat.subagentPane answered opened:false for the fixture's subagents,
because a showcase chat's page never persists the mock daemon's session, so
the model has none, and the page showed the subagent in the chat instead.

`debug.agent_pane {action: open_subagent, index, focus}` (DEBUG and bench
builds only, DebugAgentPaneSubagent) calls the same header hook
chat.subagentPane calls (AgentTabStore.openSubagent, then AgentSubagentPanes
and openBeside place the pane), with the page's session when the model has
none. Both capture states use it instead of tray clicks. An unfocused open
keeps focus on the chat, so their focusLeft steps go.

Also formats the fixture test's import (fast gates' web lint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…pane

Conflicts: #28044 replaces #27789's per-tracker placement with the shared
RightColumnPlacement, so #28044's side wins in AgentSubagentPanes,
AgentTabs, AgentTabs+Header and the placement tests, keeping #27789's two
later fixes:
- selectInPane uses selectWhenReportedKeepingFocus (review at 28ad8d1);
- main pane layout decides first when nothing is on the chat's right,
  including a chat too narrow to split (review at 84718cf): #28044's
  openOnRight already asks openInMainPane for .splitRight and .tab(nil).
SubagentMainPaneRoutingTests (from #27789) feeds rightColumn instead of
the removed paneWidth hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex-review-box/gpt-6-astra high, lane-place): BLOCK at bd2b918

BLOCK

  1. [P2] Measure the source’s screen, not the active screen. RightColumn.swift:23 uses navigationFrames, which contains only the active screen. Switch screens while subagent opens are queued: subsequent requests see an empty right column and open in the chat’s own pane or create another right split, despite existing right-side panes. Resolve geometry for the screen containing source.

  2. [P2] Search both right-side panes before duplicating a link. LinkOpener.swift:167 checks only the newly selected destination for an existing browser tab. Open link A in the upper pane, then B in the lower pane, then click A again: placement targets the lower pane, so reuse misses A and creates a duplicate. Search the right column before applying placement for a new tab.

Full diff reviewed and verified against the supplied tree. macOS tests could not run: this Linux box has no Swift toolchain.

… open again is found anywhere in it

Review of #28044 at bd2b918 (Codex, BLOCK, two P2s):
- RightColumn read navigationFrames, which holds the active screen only. An
  open still queued when the person switched screens read the chat's
  screen as empty, so it opened in the chat's own pane or split again
  beside panes already there. LayoutRootView.navigationFrames(ofScreenContaining:)
  reads the screen holding the pane, active or not (a hidden screen view
  keeps its geometry), and RightColumn.panes uses it.
- openWeb looked for a tab of the same page only in the pane this open
  would use. With A (upper) and B (lower) open, A again targets the lower
  pane and opened a second A. LinkOpenPlacement.reusePanes lists that pane
  first, then every pane of the right column, never the source.

Tests: RightColumnScreenTests (the chat's column survives a switch to
another screen; red with the active screen's frames) and
LinkOpenPlacementTests.aLinkOpenAgainIsFoundAnywhereInTheRightColumn
(A, B, A: A is selected in the upper pane).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex-review-box/gpt-6-astra high, lane-place): BLOCK at 1cfd9d1

BLOCK

  1. [P1] Link opens from an inactive screen ignore the existing right column. LinkOpener.target measures column from the source’s screen, but computes right through PaneHandlers.neighbor, which reads only the active screen’s frames (tree/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift:90-102). After switching away from a screen containing chat | diff, opening a link from chat gets right == nil while column == ["diff"]; LinkOpenPlacement.target therefore chooses splitRight and creates another pane instead of splitting below or reusing diff. Use the measured column (or source-screen frames) when determining right.

…the active one

Review of #28044 at 1cfd9d1 (Codex, BLOCK, P1): LinkOpener.target measured
the right column on the chat's own screen but found the pane on its right
with PaneHandlers.neighbor, which read the active screen's frames. A link
opened from a chat on a screen the person left found no neighbour and
split again.

PaneHandlers.neighbor now reads navigationFrames(ofScreenContaining: pane),
as do the two PanePlacementRouting sites (New Pane auto layout and tiling
placement), the only other places that paired a pane's own screen layout
with the active screen's frames. On the active screen nothing changes.
RightColumnScreenTests covers the right-hand neighbour on a screen the
person left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…link reuses nothing on the right

Review of #28044 at c15e0d8 (Codex, BLOCK, two P2s):
- Two links opened before either's move landed both read a one-pane right
  column and both split below it: three panes on the right. A lower split
  under way is reserved (RightColumn.reserveBelow, released when its move
  lands or fails, 30 s backstop): until then the column reads as full, so
  an open meanwhile is a tab, in the right pane that holds the moving tab
  (RightColumn.realPane). LinkOpener and the agent split-below path
  (openOnRight) both reserve, so a link and a subagent can't race either.
  Subagent opens were already one at a time per tracker.
- LinkOpenPlacement.reusePanes reused a tab on the right even when the open
  lands in the source (Same Pane, or a source too narrow to split). It
  returns nothing then.

Tests: RightColumnPendingSplitTests (a second open during a split is a tab
in the right pane; overlapping reservations hold until the last ends) and
LinkOpenPlacementTests (Same Pane reuses nothing on the right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…'s stand-in

Review of #28044 at a4ae195 (Codex, BLOCK, P2): while a lower split's move
was under way, RightColumn.panes listed a `pending-below:` stand-in for the
pane still moving in, and LinkOpener.target passed it on. openFolder looked
it up, found no pane and opened its terminal in the source over the chat;
DurableTabLink's fallback lost its destination the same way.

The stand-in now never leaves RightColumn: panes(beside:in:) maps it back
to the right pane (realPane) before returning, so a pending split reads as
its right pane listed twice. RightColumnPlacement still sees a full column,
and the tab goes to the pane that holds the moving tab. Every caller
(LinkOpener.target for links, folders, DurableTabLink, PaneBrowserTabOpener
and New Tab; RightColumn.measure for agent opens) gets real panes only, so
the per-call realPane mapping in LinkOpener.open and locatePane goes.

RightColumnPendingTargetTests: in a real two-pane window with a lower split
pending, LinkOpener.target names the real right pane (.pane, not a split
below and not the stand-in), DurableTabLink's existingPane resolves, and
every pane the column and its measure name exists. The test checks the
placement answer that openFolder and the fallback read, not a daemon-made
split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…rce pane

Five reviews of #28044 each found two opens that read one right column
before either landed, in a path the last fix missed (links at c15e0d8,
folders at e954bd8), or a pending split's stand-in id leaking to a caller
(a4ae195). The reservation and its stand-in pane are gone. In their place,
one entry point: RightColumn.serially(beside:_:) runs the opens beside one
source pane one at a time. Each decides its place from the column as the
open before it left it, and holds the turn until its move has landed or
failed (30 s backstop). Placement only ever sees real panes.

Every opener on the right goes through it:
- LinkOpener.open: links, files, browser tabs, New Tab picks, the diff
  page, Needs you (openRight). It decides inside its turn (place), and
  moved/moveBeside/dockChat end the turn when their move lands.
- LinkOpener.openFolder: its turn too. A split's terminal is made in the
  pane it splits and moved there, as a New Tab page's terminal already
  was, instead of a splitRight/splitDown action the turn could not see
  land. New Column still makes a split, as before.
- AgentTabStore.openOnRight: subagents, side chats, /btw, Session Info
  sessions, transcript session cards. It decides again in its turn (the
  trackers' decision is from when they asked), passes that decision to
  `created`, and ends the turn in `finished`. Wired in AgentTabs.serially.

Tests: RightColumnTurnTests (the turn; every pair of the seven opener
kinds from an empty column and from one pane on the right, both asked
before either lands, never leaves more than two panes; all of them at
once fill one column) and RightColumnEntryPointTests (a LinkOpener open
and an AgentTabStore open each wait for the open under way, then decide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
teamleaderleo and others added 3 commits October 30, 2026 16:15
PaneHandlers.neighbor and PanePlacementRouting's autoLayoutTarget and route
now read the frames of the pane's own screen (navigationFrames(ofScreenContaining:));
the defaults guard hashes them as tunable sources. Nothing a person gets by
default changes on the active screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
Fleet run of #28044 at 8e52036 (scratch #28392):
- SideOpenStripTests failed: its pane has no window, so its width was 0,
  and the narrow-chat floor (5ade38f) took 0 pt for too narrow to split,
  opening the link in place. RightColumn.width(of:) answers nil for a pane
  with no width yet; LinkOpener.target and RightColumn.measure use it, so
  an unmeasured pane sets no floor, as before the floor.
- RightColumnScreenTests required the chat's screen to be hidden after the
  switch, but its fade ends on later frames a headless window never draws.
  It requires the screen to be inactive instead; a hidden screen view keeps
  the same geometry, so the lookup reads it either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
…e is no width

Fleet run of #28044 at caf8e69 (scratch #28392): SideOpenStripTests still
opened its link in place. A PaneContentView starts at a 400 pt placeholder
frame, so a pane not yet laid out read as 400 pt, under the 480 pt chat
floor. caf8e69 only treated a 0 pt width as unmeasured; RightColumn.width(of:)
now answers nil for any pane that is not in a window, then for a 0 pt one.
SideOpenStripTests.aPaneNotInAWindowIsUnmeasured covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
…(agents), plus capture states (#28213)

* debug(next): the subagents fixture can start more than two subagents (agents)

`debug.agent_pane seed_rows` with `fixture: "subagents"` takes `agents` (2 by
default, up to 8): past the reviewer and the auditor, helpers that stay
running through every step. A capture can then open 3 or more subagents'
panes and record where each lands (leo-workspace design/pane-placement.md;
cmux#27789 and #28044). Leo, 2026-10-28: "I mean the right top pane, and then
you split to the bottom once so that you get the three items. But you want
four items."

Debug-only (debug.agent_pane, DEBUG builds); with `agents` unset the fixture
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* media(next): capture states for subagents in the right column (#27789, #28044)

subagents-four-panes.json opens four fixture subagents from the chat's tray;
subagents-beside-right-pane.json opens two beside a terminal already on the
chat's right. Both drive the tray by CSS selector, refocusing the chat
between opens, so no step depends on label text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* test(next): the fixture test's count parameter no longer shadows its agents list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* debug(next): debug.agent_pane open_subagent opens a fixture subagent's pane through the real placement

A capture of #28044 (4154db0 vs 34122a1) never reached its scene: a tray
line's chat.subagentPane answered opened:false for the fixture's subagents,
because a showcase chat's page never persists the mock daemon's session, so
the model has none, and the page showed the subagent in the chat instead.

`debug.agent_pane {action: open_subagent, index, focus}` (DEBUG and bench
builds only, DebugAgentPaneSubagent) calls the same header hook
chat.subagentPane calls (AgentTabStore.openSubagent, then AgentSubagentPanes
and openBeside place the pane), with the page's session when the model has
none. Both capture states use it instead of tray clicks. An unfocused open
keeps focus on the chat, so their focusLeft steps go.

Also formats the fixture test's import (fast gates' web lint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* test(next): name the Settings JSONValue in the subagent fixture test

The base merge brought a second JSONValue into scope: CmuxNextDaemon's beside CmuxNextSettings'. That made the test's type ambiguous (fleet test-compile at 2dbdebd).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · took over from an earlier session · session

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Carried to #28989 for today's landing (coordinator's ask; this PR's claim is still held, so nothing was pushed here). It keeps every commit, merges current feat-cmux-next, and fixes the review findings at bd2b918 and 1cfd9d1 plus three more from reviews of the carry. Close this once #28989 lands.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

This PR's change is carried forward in #28989, which takes this branch's commits and fixes the review findings: LinkOpener now reads the right column from the source pane's own workspace (not the active screen), pane keys are scoped to the chat's workspace, an open shown in place ends its turn, and a refusal ends its turn right away. #28989 has an independent SHIP at its head and is merging now. This PR is yours to close once that lands.

teamleaderleo added a commit that referenced this pull request Oct 31, 2026
…lower pane when the right already holds something (#28989)

* fix(next): subagents fill one split below the first, then open as tabs

Leo, 2026-10-28: "But I don't know if you want to do more splits versus just
adding more to the top tab or space pane, the top pane. I mean the right top
pane, and then you split to the bottom once so that you get the three items.
But you want four items. I don't know what the fourth item should go."

Transcript subagent and Task cards (chat.subagentPane, AgentSubagentPanes)
stacked a new split below the newest subagent pane for every subagent, with
no cap and no width floor. They now follow leo-workspace
design/pane-placement.md: an open subagent's tab is shown; the first opens in
a split right of the chat, the second in one split below it, and the third
and later are tabs in the newest subagent's pane, never another split. No
split leaves the chat under 480 pt or the right column under 420 pt; that
open is a tab instead. An unfocused tab open leaves focus where it is.

The decision is one function, AgentSubagentPanes.placement(chat:hooks:), so
it can fold into #26582's RightPanePlacement later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): the lower subagent split splits the subagent pane; an unfocused subagent tab is shown in its pane

Review of #27789 at 1eed675 (Codex, BLOCK):
- openSubagent passed main: for every split, so in main pane layout the
  second subagent's split below became another tab in the right column.
  Only the first, chat-relative split goes through main pane layout now.
- An unfocused .tab open was created unselected, so the new subagent was
  hidden behind the one already showing. It is selected in its subagent
  pane without moving keyboard focus (selectWhenReported); in the chat's
  own pane (a narrow window) it waits in the strip so the chat stays shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): AgentSubagentPanes imports AppKit publicly (CGFloat in its public API)

Fleet compile at b17ce6d: 'cannot be declared public because its type
uses an internal type' for the width floors and the width hook, under
InternalImportsByDefault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* cmux NEXT: anything opened on the right goes to the lower pane when the right already holds something

Leo, 2026-10-30: "subagent stuff... or anything we expect to open on "the
right"... if there's already a thing on the right, there should be logic for
just putting it on the lower pane of the right. that kinda split."

One rule, RightColumnPlacement.decide (CmuxNextAgentPane), now places every
open on the right of a pane, from the right column the window shows
(RightColumn.measure / RightColumn.panes):
- nothing on the right: a split right of the source;
- one pane on the right: a split below it (it keeps what it shows);
- two or more: a tab in the lowest pane, never another split;
- no split leaves the chat under 480 pt or the right column under 420 pt.
Closing the lower pane's last tab removes the pane, so the split collapses
and the next open splits below again.

Paths routed through it:
- transcript subagent / Task cards (AgentSubagentPanes, #27789's function,
  now shared and read from the window's geometry);
- side chats, /btw, Session Info sessions and transcript session cards
  (AgentSidePanes; chat.sideChat and chat.offer.open);
- links opened to the right and chats opened from Needs you (LinkOpener /
  LinkOpenPlacement gain .splitBelow, for a source that takes tabs).
A chat that is its screen's main pane keeps main pane layout's own rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* cmux NEXT: main pane layout's strip column gets the same lower split

ux-owner, 2026-10-30, on Leo's "anything we expect to open on 'the right'":
in main pane layout the strip column right of the docked chat is that right
side. A chat there whose right side already holds a pane splits below it,
then fills the lower pane with tabs (links through LinkOpenPlacement.inColumn,
chat opens through openOnRight). With nothing on its right yet, main pane
layout still makes the column (dock the chat, or a tab in the strip pane).
Home's pinned dock is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): read the right column from pane frames, not focus recency

Review of #28044 at 20e433f (Codex, BLOCK, P1): RightColumn walked the
column with PaneHandlers.neighbor, whose horizontal step picks the adjacent
column's most recently focused pane, so a two-pane right column could read
as one and the next open split again or targeted the wrong pane.
RightColumnPlacement.column(beside:frames:gap:) now takes the panes whose
left edge is nearest the source's right edge and that overlap its height,
top to bottom, from the layout's navigation frames.

The review's other two P1s (main pane layout's chat opens and links skipped
the rule) were fixed at 36862fa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a link from a narrow pane with nothing on its right keeps the tab

Review of #28044 at 34122a1 (Codex, BLOCK, P1): with no pane on its right,
LinkOpenPlacement returned .splitRight without the chat floor, so a 900-959 pt
source split to under 480 pt. RightColumnPlacement.decide now judges that
split too: measured too narrow, the tab opens in the source pane. A new
column (layout.linkPlacement new-column) adds width to the strip, so it keeps
opening one; an unmeasured source keeps the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): the right column reads the source's own screen, and a link open again is found anywhere in it

Review of #28044 at bd2b918 (Codex, BLOCK, two P2s):
- RightColumn read navigationFrames, which holds the active screen only. An
  open still queued when the person switched screens read the chat's
  screen as empty, so it opened in the chat's own pane or split again
  beside panes already there. LayoutRootView.navigationFrames(ofScreenContaining:)
  reads the screen holding the pane, active or not (a hidden screen view
  keeps its geometry), and RightColumn.panes uses it.
- openWeb looked for a tab of the same page only in the pane this open
  would use. With A (upper) and B (lower) open, A again targets the lower
  pane and opened a second A. LinkOpenPlacement.reusePanes lists that pane
  first, then every pane of the right column, never the source.

Tests: RightColumnScreenTests (the chat's column survives a switch to
another screen; red with the active screen's frames) and
LinkOpenPlacementTests.aLinkOpenAgainIsFoundAnywhereInTheRightColumn
(A, B, A: A is selected in the upper pane).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): every placement reads the screen that holds the pane, not the active one

Review of #28044 at 1cfd9d1 (Codex, BLOCK, P1): LinkOpener.target measured
the right column on the chat's own screen but found the pane on its right
with PaneHandlers.neighbor, which read the active screen's frames. A link
opened from a chat on a screen the person left found no neighbour and
split again.

PaneHandlers.neighbor now reads navigationFrames(ofScreenContaining: pane),
as do the two PanePlacementRouting sites (New Pane auto layout and tiling
placement), the only other places that paired a pane's own screen layout
with the active screen's frames. On the active screen nothing changes.
RightColumnScreenTests covers the right-hand neighbour on a screen the
person left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): an open during a lower split's move is a tab; a Same Pane link reuses nothing on the right

Review of #28044 at c15e0d8 (Codex, BLOCK, two P2s):
- Two links opened before either's move landed both read a one-pane right
  column and both split below it: three panes on the right. A lower split
  under way is reserved (RightColumn.reserveBelow, released when its move
  lands or fails, 30 s backstop): until then the column reads as full, so
  an open meanwhile is a tab, in the right pane that holds the moving tab
  (RightColumn.realPane). LinkOpener and the agent split-below path
  (openOnRight) both reserve, so a link and a subagent can't race either.
  Subagent opens were already one at a time per tracker.
- LinkOpenPlacement.reusePanes reused a tab on the right even when the open
  lands in the source (Same Pane, or a source too narrow to split). It
  returns nothing then.

Tests: RightColumnPendingSplitTests (a second open during a split is a tab
in the right pane; overlapping reservations hold until the last ends) and
LinkOpenPlacementTests (Same Pane reuses nothing on the right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): placement hands out real panes only, never a pending split's stand-in

Review of #28044 at a4ae195 (Codex, BLOCK, P2): while a lower split's move
was under way, RightColumn.panes listed a `pending-below:` stand-in for the
pane still moving in, and LinkOpener.target passed it on. openFolder looked
it up, found no pane and opened its terminal in the source over the chat;
DurableTabLink's fallback lost its destination the same way.

The stand-in now never leaves RightColumn: panes(beside:in:) maps it back
to the right pane (realPane) before returning, so a pending split reads as
its right pane listed twice. RightColumnPlacement still sees a full column,
and the tab goes to the pane that holds the moving tab. Every caller
(LinkOpener.target for links, folders, DurableTabLink, PaneBrowserTabOpener
and New Tab; RightColumn.measure for agent opens) gets real panes only, so
the per-call realPane mapping in LinkOpener.open and locatePane goes.

RightColumnPendingTargetTests: in a real two-pane window with a lower split
pending, LinkOpener.target names the real right pane (.pane, not a split
below and not the stand-in), DurableTabLink's existingPane resolves, and
every pane the column and its measure name exists. The test checks the
placement answer that openFolder and the fallback read, not a daemon-made
split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* refactor(next): every open on the right takes its turn beside its source pane

Five reviews of #28044 each found two opens that read one right column
before either landed, in a path the last fix missed (links at c15e0d8,
folders at e954bd8), or a pending split's stand-in id leaking to a caller
(a4ae195). The reservation and its stand-in pane are gone. In their place,
one entry point: RightColumn.serially(beside:_:) runs the opens beside one
source pane one at a time. Each decides its place from the column as the
open before it left it, and holds the turn until its move has landed or
failed (30 s backstop). Placement only ever sees real panes.

Every opener on the right goes through it:
- LinkOpener.open: links, files, browser tabs, New Tab picks, the diff
  page, Needs you (openRight). It decides inside its turn (place), and
  moved/moveBeside/dockChat end the turn when their move lands.
- LinkOpener.openFolder: its turn too. A split's terminal is made in the
  pane it splits and moved there, as a New Tab page's terminal already
  was, instead of a splitRight/splitDown action the turn could not see
  land. New Column still makes a split, as before.
- AgentTabStore.openOnRight: subagents, side chats, /btw, Session Info
  sessions, transcript session cards. It decides again in its turn (the
  trackers' decision is from when they asked), passes that decision to
  `created`, and ends the turn in `finished`. Wired in AgentTabs.serially.

Tests: RightColumnTurnTests (the turn; every pair of the seven opener
kinds from an empty column and from one pane on the right, both asked
before either lands, never leaves more than two panes; all of them at
once fill one column) and RightColumnEntryPointTests (a LinkOpener open
and an AgentTabStore open each wait for the open under way, then decide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* chore(next): refresh the defaults snapshot for the screen-frames change

PaneHandlers.neighbor and PanePlacementRouting's autoLayoutTarget and route
now read the frames of the pane's own screen (navigationFrames(ofScreenContaining:));
the defaults guard hashes them as tunable sources. Nothing a person gets by
default changes on the active screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a pane with no width is unmeasured, not too narrow to split

Fleet run of #28044 at 8e52036 (scratch #28392):
- SideOpenStripTests failed: its pane has no window, so its width was 0,
  and the narrow-chat floor (5ade38f) took 0 pt for too narrow to split,
  opening the link in place. RightColumn.width(of:) answers nil for a pane
  with no width yet; LinkOpener.target and RightColumn.measure use it, so
  an unmeasured pane sets no floor, as before the floor.
- RightColumnScreenTests required the chat's screen to be hidden after the
  switch, but its fade ends on later frames a headless window never draws.
  It requires the screen to be inactive instead; a hidden screen view keeps
  the same geometry, so the lookup reads it either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a pane not in a window is unmeasured: its placeholder frame is no width

Fleet run of #28044 at caf8e69 (scratch #28392): SideOpenStripTests still
opened its link in place. A PaneContentView starts at a 400 pt placeholder
frame, so a pane not yet laid out read as 400 pt, under the 480 pt chat
floor. caf8e69 only treated a 0 pt width as unmeasured; RightColumn.width(of:)
now answers nil for any pane that is not in a window, then for a 0 pt one.
SideOpenStripTests.aPaneNotInAWindowIsUnmeasured covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a link's right side falls back to the measured column's top pane

Review of #28044 at 1cfd9d1 (P1): right came from the active screen while column came from the
source's. c15e0d8 already moved PaneHandlers.neighbor to the source screen's frames; this makes
an occupied column never read as empty, should the neighbour rule still find no pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

* refactor(next): right-column turns are an instance in CmuxNextAgentPane, not App statics

RightColumn.serially kept its queue in two static vars of a new App file (the arch ratchet's
global_mutable_statics row, and a new App file for the app-size rule). The queue is now
RightColumnTurns<Source> in CmuxNextAgentPane, next to RightColumnPlacement; the app's LinkOpener
owns the one instance, and AgentTabStore's serially hook reaches it through services.linkOpener.
The geometry helpers (measure, width, panes) move into LinkOpener.swift, their only App user
besides the wiring. No behaviour change; RightColumnTurnTests and RightColumnEntryPointTests use
an instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

* fix(next): a right-column pane is looked up only in the chat's own workspace

Review of the #28044 carry at d495c86 (P1): locatePane took the first pane with the column's key
across every window. A layout pane id without a resource id is `pane:<handle>`, which two daemons
or workspaces can share, so a split below or a tab could land in another workspace. The lookup now
starts from the chat tab's own pane, the same workspace rightColumn measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

* fix(next): right-side opens shown in place, folder terminals and deferred refusals end cleanly

Review of the #28044 carry at 1eb458c:
- P1: a file a tab already shows is selected where it is and never reports to `placed`, so its
  turn held the source pane until the 30 s backstop and the next open waited. FilePageService
  now says so (`shownInPlace`, through ViewerService and FilePageOpener), LinkOpener ends the turn
  (`openedInPlace`, shared `ended` for split, besideDock and dockChat opens).
  RightColumnEntryPointTests.anOpenShownWhereItAlreadyWasEndsItsTurn covers it.
- P2: a folder's terminal is made with `placementOwner` when it is moved, so it never shows in
  the source pane first (as base's split path and a link's browser tab do).
- P2: an agent open that waited its turn and was then refused calls `finished(false)`; its caller
  had already heard true, so the subagent or side-chat tracker no longer stays "opening".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

* fix(next): openFile builds its in-place hook outside the call

The fleet compile at e603a67 failed: "failed to produce diagnostic for expression" on the ternary
closure argument. The hook is now a typed local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

* arch-ratchet: reviewed app_lines exception for the right-column wiring (#28989)

The pure rule and turn queue live in CmuxNextAgentPane; the +232 App lines are LinkOpener and
AgentTabStore glue over App types. Receipt: codex gpt-6-astra SHIP at e603a67. Expires 2026-11-30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-d750cf10922c785efa64

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: cmux-bot[bot] <348531757+cmux-bot[bot]@users.noreply.github.com>
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
…-pane

Conflicts in LinkOpener.swift and AgentTabs+Header.swift: #28989 (the carry of
#28044) now places everything a chat opens in its right column (a split below
its one pane, else its lower pane), Leo's later ask: "go split instead of just
adding a third column". That supersedes this PR's 'a tab in the existing right
pane' path, so both files take the base's version, and the fromChat flag,
existingRight and rightPaneTab go. The rest of this PR (New Tab and dock picks
beside the chat, the typed-command rule, the link fixture) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-8451c317c43e92348de8
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
* fix(next): subagents fill one split below the first, then open as tabs

Leo, 2026-10-28: "But I don't know if you want to do more splits versus just
adding more to the top tab or space pane, the top pane. I mean the right top
pane, and then you split to the bottom once so that you get the three items.
But you want four items. I don't know what the fourth item should go."

Transcript subagent and Task cards (chat.subagentPane, AgentSubagentPanes)
stacked a new split below the newest subagent pane for every subagent, with
no cap and no width floor. They now follow leo-workspace
design/pane-placement.md: an open subagent's tab is shown; the first opens in
a split right of the chat, the second in one split below it, and the third
and later are tabs in the newest subagent's pane, never another split. No
split leaves the chat under 480 pt or the right column under 420 pt; that
open is a tab instead. An unfocused tab open leaves focus where it is.

The decision is one function, AgentSubagentPanes.placement(chat:hooks:), so
it can fold into #26582's RightPanePlacement later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): the lower subagent split splits the subagent pane; an unfocused subagent tab is shown in its pane

Review of #27789 at 1eed675 (Codex, BLOCK):
- openSubagent passed main: for every split, so in main pane layout the
  second subagent's split below became another tab in the right column.
  Only the first, chat-relative split goes through main pane layout now.
- An unfocused .tab open was created unselected, so the new subagent was
  hidden behind the one already showing. It is selected in its subagent
  pane without moving keyboard focus (selectWhenReported); in the chat's
  own pane (a narrow window) it waits in the strip so the chat stays shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): AgentSubagentPanes imports AppKit publicly (CGFloat in its public API)

Fleet compile at b17ce6d: 'cannot be declared public because its type
uses an internal type' for the width floors and the width hook, under
InternalImportsByDefault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* cmux NEXT: anything opened on the right goes to the lower pane when the right already holds something

Leo, 2026-10-30: "subagent stuff... or anything we expect to open on "the
right"... if there's already a thing on the right, there should be logic for
just putting it on the lower pane of the right. that kinda split."

One rule, RightColumnPlacement.decide (CmuxNextAgentPane), now places every
open on the right of a pane, from the right column the window shows
(RightColumn.measure / RightColumn.panes):
- nothing on the right: a split right of the source;
- one pane on the right: a split below it (it keeps what it shows);
- two or more: a tab in the lowest pane, never another split;
- no split leaves the chat under 480 pt or the right column under 420 pt.
Closing the lower pane's last tab removes the pane, so the split collapses
and the next open splits below again.

Paths routed through it:
- transcript subagent / Task cards (AgentSubagentPanes, #27789's function,
  now shared and read from the window's geometry);
- side chats, /btw, Session Info sessions and transcript session cards
  (AgentSidePanes; chat.sideChat and chat.offer.open);
- links opened to the right and chats opened from Needs you (LinkOpener /
  LinkOpenPlacement gain .splitBelow, for a source that takes tabs).
A chat that is its screen's main pane keeps main pane layout's own rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* cmux NEXT: main pane layout's strip column gets the same lower split

ux-owner, 2026-10-30, on Leo's "anything we expect to open on 'the right'":
in main pane layout the strip column right of the docked chat is that right
side. A chat there whose right side already holds a pane splits below it,
then fills the lower pane with tabs (links through LinkOpenPlacement.inColumn,
chat opens through openOnRight). With nothing on its right yet, main pane
layout still makes the column (dock the chat, or a tab in the strip pane).
Home's pinned dock is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): read the right column from pane frames, not focus recency

Review of #28044 at 20e433f (Codex, BLOCK, P1): RightColumn walked the
column with PaneHandlers.neighbor, whose horizontal step picks the adjacent
column's most recently focused pane, so a two-pane right column could read
as one and the next open split again or targeted the wrong pane.
RightColumnPlacement.column(beside:frames:gap:) now takes the panes whose
left edge is nearest the source's right edge and that overlap its height,
top to bottom, from the layout's navigation frames.

The review's other two P1s (main pane layout's chat opens and links skipped
the rule) were fixed at 36862fa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a link from a narrow pane with nothing on its right keeps the tab

Review of #28044 at 34122a1 (Codex, BLOCK, P1): with no pane on its right,
LinkOpenPlacement returned .splitRight without the chat floor, so a 900-959 pt
source split to under 480 pt. RightColumnPlacement.decide now judges that
split too: measured too narrow, the tab opens in the source pane. A new
column (layout.linkPlacement new-column) adds width to the strip, so it keeps
opening one; an unmeasured source keeps the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): the right column reads the source's own screen, and a link open again is found anywhere in it

Review of #28044 at bd2b918 (Codex, BLOCK, two P2s):
- RightColumn read navigationFrames, which holds the active screen only. An
  open still queued when the person switched screens read the chat's
  screen as empty, so it opened in the chat's own pane or split again
  beside panes already there. LayoutRootView.navigationFrames(ofScreenContaining:)
  reads the screen holding the pane, active or not (a hidden screen view
  keeps its geometry), and RightColumn.panes uses it.
- openWeb looked for a tab of the same page only in the pane this open
  would use. With A (upper) and B (lower) open, A again targets the lower
  pane and opened a second A. LinkOpenPlacement.reusePanes lists that pane
  first, then every pane of the right column, never the source.

Tests: RightColumnScreenTests (the chat's column survives a switch to
another screen; red with the active screen's frames) and
LinkOpenPlacementTests.aLinkOpenAgainIsFoundAnywhereInTheRightColumn
(A, B, A: A is selected in the upper pane).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): every placement reads the screen that holds the pane, not the active one

Review of #28044 at 1cfd9d1 (Codex, BLOCK, P1): LinkOpener.target measured
the right column on the chat's own screen but found the pane on its right
with PaneHandlers.neighbor, which read the active screen's frames. A link
opened from a chat on a screen the person left found no neighbour and
split again.

PaneHandlers.neighbor now reads navigationFrames(ofScreenContaining: pane),
as do the two PanePlacementRouting sites (New Pane auto layout and tiling
placement), the only other places that paired a pane's own screen layout
with the active screen's frames. On the active screen nothing changes.
RightColumnScreenTests covers the right-hand neighbour on a screen the
person left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): an open during a lower split's move is a tab; a Same Pane link reuses nothing on the right

Review of #28044 at c15e0d8 (Codex, BLOCK, two P2s):
- Two links opened before either's move landed both read a one-pane right
  column and both split below it: three panes on the right. A lower split
  under way is reserved (RightColumn.reserveBelow, released when its move
  lands or fails, 30 s backstop): until then the column reads as full, so
  an open meanwhile is a tab, in the right pane that holds the moving tab
  (RightColumn.realPane). LinkOpener and the agent split-below path
  (openOnRight) both reserve, so a link and a subagent can't race either.
  Subagent opens were already one at a time per tracker.
- LinkOpenPlacement.reusePanes reused a tab on the right even when the open
  lands in the source (Same Pane, or a source too narrow to split). It
  returns nothing then.

Tests: RightColumnPendingSplitTests (a second open during a split is a tab
in the right pane; overlapping reservations hold until the last ends) and
LinkOpenPlacementTests (Same Pane reuses nothing on the right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): placement hands out real panes only, never a pending split's stand-in

Review of #28044 at a4ae195 (Codex, BLOCK, P2): while a lower split's move
was under way, RightColumn.panes listed a `pending-below:` stand-in for the
pane still moving in, and LinkOpener.target passed it on. openFolder looked
it up, found no pane and opened its terminal in the source over the chat;
DurableTabLink's fallback lost its destination the same way.

The stand-in now never leaves RightColumn: panes(beside:in:) maps it back
to the right pane (realPane) before returning, so a pending split reads as
its right pane listed twice. RightColumnPlacement still sees a full column,
and the tab goes to the pane that holds the moving tab. Every caller
(LinkOpener.target for links, folders, DurableTabLink, PaneBrowserTabOpener
and New Tab; RightColumn.measure for agent opens) gets real panes only, so
the per-call realPane mapping in LinkOpener.open and locatePane goes.

RightColumnPendingTargetTests: in a real two-pane window with a lower split
pending, LinkOpener.target names the real right pane (.pane, not a split
below and not the stand-in), DurableTabLink's existingPane resolves, and
every pane the column and its measure name exists. The test checks the
placement answer that openFolder and the fallback read, not a daemon-made
split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* refactor(next): every open on the right takes its turn beside its source pane

Five reviews of #28044 each found two opens that read one right column
before either landed, in a path the last fix missed (links at c15e0d8,
folders at e954bd8), or a pending split's stand-in id leaking to a caller
(a4ae195). The reservation and its stand-in pane are gone. In their place,
one entry point: RightColumn.serially(beside:_:) runs the opens beside one
source pane one at a time. Each decides its place from the column as the
open before it left it, and holds the turn until its move has landed or
failed (30 s backstop). Placement only ever sees real panes.

Every opener on the right goes through it:
- LinkOpener.open: links, files, browser tabs, New Tab picks, the diff
  page, Needs you (openRight). It decides inside its turn (place), and
  moved/moveBeside/dockChat end the turn when their move lands.
- LinkOpener.openFolder: its turn too. A split's terminal is made in the
  pane it splits and moved there, as a New Tab page's terminal already
  was, instead of a splitRight/splitDown action the turn could not see
  land. New Column still makes a split, as before.
- AgentTabStore.openOnRight: subagents, side chats, /btw, Session Info
  sessions, transcript session cards. It decides again in its turn (the
  trackers' decision is from when they asked), passes that decision to
  `created`, and ends the turn in `finished`. Wired in AgentTabs.serially.

Tests: RightColumnTurnTests (the turn; every pair of the seven opener
kinds from an empty column and from one pane on the right, both asked
before either lands, never leaves more than two panes; all of them at
once fill one column) and RightColumnEntryPointTests (a LinkOpener open
and an AgentTabStore open each wait for the open under way, then decide).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* chore(next): refresh the defaults snapshot for the screen-frames change

PaneHandlers.neighbor and PanePlacementRouting's autoLayoutTarget and route
now read the frames of the pane's own screen (navigationFrames(ofScreenContaining:));
the defaults guard hashes them as tunable sources. Nothing a person gets by
default changes on the active screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a pane with no width is unmeasured, not too narrow to split

Fleet run of #28044 at 8e52036 (scratch #28392):
- SideOpenStripTests failed: its pane has no window, so its width was 0,
  and the narrow-chat floor (5ade38f) took 0 pt for too narrow to split,
  opening the link in place. RightColumn.width(of:) answers nil for a pane
  with no width yet; LinkOpener.target and RightColumn.measure use it, so
  an unmeasured pane sets no floor, as before the floor.
- RightColumnScreenTests required the chat's screen to be hidden after the
  switch, but its fade ends on later frames a headless window never draws.
  It requires the screen to be inactive instead; a hidden screen view keeps
  the same geometry, so the lookup reads it either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* fix(next): a pane not in a window is unmeasured: its placeholder frame is no width

Fleet run of #28044 at caf8e69 (scratch #28392): SideOpenStripTests still
opened its link in place. A PaneContentView starts at a 400 pt placeholder
frame, so a pane not yet laid out read as 400 pt, under the 480 pt chat
floor. caf8e69 only treated a 0 pt width as unmeasured; RightColumn.width(of:)
now answers nil for any pane that is not in a window, then for a 0 pt one.
SideOpenStripTests.aPaneNotInAWindowIsUnmeasured covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7

* Route chat dock terminal and browser opens down the right column

Use the serialized LinkOpener placement for dock tools, before terminal tiling, while preserving working directory, browser URL and profile. Keep the New Tab page route unchanged.

Validation: git diff --check passed. verify-local portable checks passed except untouched localization debt; Swift parsing unavailable because this host has no swiftc. Fleet ChatDockToolPlacementTests remains required.
Agent-Session: https://cmux.com/deeplink/agent?id=aid-aed5521ea76b3c8830d5

* fix(next): place chat header opens in lower right pane

Agent-Session: https://cmux.com/deeplink/agent?id=aid-7f14d5b336ee0cc28989

* docs(next): describe measured chat header placement

Agent-Session: https://cmux.com/deeplink/agent?id=aid-7f14d5b336ee0cc28989

* fix(next): preserve header terminal toggle anchoring

Agent-Session: https://cmux.com/deeplink/agent?id=aid-7f14d5b336ee0cc28989

* cmux NEXT: a link or terminal opened on the right goes to the pane that already holds its kind

Leo, 2026-10-31: "imo in this case, just open it on the existing. you only
need the rightmost botom auto split when there's a terminal that gets opened
when there's already a browser, and vice versa".

RightColumnPlacement.decide takes the kind being opened (terminal or browser)
and which kinds each right-column pane holds: a pane already holding that kind
gets the tab; only a different kind splits below. Links (openWeb, a browser
page's Cmd-click) open as .browser, folders as .terminal. Chats, subagents,
side sessions and file pages pass no kind and place as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-8451c317c43e92348de8

* LinkOpener: name the holds closure's arguments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-8451c317c43e92348de8

* refactor(next): keep link placement in the pane domain

Agent-Session: https://cmux.com/deeplink/agent?id=aid-5bd2469d5ee8ac333149

* Keep dock adapters together and move pure pane policy to AgentPane

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Preserve outbound selection and test tool-kind routing

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Keep dock geometry coverage advisory without a display

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Verify header documents route from the chat instead of the last panel

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Fix browser forwarding signature and serialize header pane moves

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* fix(next): link the pane placement release highlight

Agent-Session: https://cmux.com/deeplink/agent?id=aid-5bd2469d5ee8ac333149

* fix(next): link the pane placement release highlight

Agent-Session: https://cmux.com/deeplink/agent?id=aid-5bd2469d5ee8ac333149

* Avoid nesting Swift Testing macros in dock placement coverage

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Keep working-context URL and folder policy in AgentPane

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* Apply the pane helper namespace convention

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

* fix(next): import dock placement policy module explicitly

Agent-Session: https://cmux.com/deeplink/agent?id=aid-f81e89ac8a39b3f54b68

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Superseded by #28989 (merged as ac7194b), its carry with the review fixes. The lower-pane split for anything opened on the right is on feat-cmux-next, and #29684 refined it with the same-kind rule. Closing it.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #28989 (for its lane, ux-tabs-panes-r2).

This branch was previously deployed

1 inactive deployment
ci-routing-agent — e371587e Deployed Oct 30, 2026 by teamleaderleo via cmux-tui tree preflight #84633
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

media-tour Run the cmux-next UI tour (cmux-next-media.yml) on every push media-tour-mac risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant