Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
51ebd8f
cmux-next: main pane layout, an agent chat's opens land in one column…
teamleaderleo Oct 21, 2026
c7fa6ea
cmux-next media: main pane tour (lone chat docks for a link, tabs joi…
teamleaderleo Oct 21, 2026
c67aab2
release notes: main pane highlight gets a New Agent Chat try-it action
teamleaderleo Oct 21, 2026
5ed37ec
cmux-next: escape the quotes in the main pane Debug help string
teamleaderleo Oct 21, 2026
bf31888
cmux-next: main pane keeps focus on agent opens, finishes after the d…
teamleaderleo Oct 21, 2026
410d713
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
ea6e682
chore(next): PR autofix (regenerated settings exports from run 395698…
teamleaderleo Oct 21, 2026
c04a225
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
ebe84dc
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
1f386e5
chore(next): PR autofix (regenerated settings exports from run 395886…
teamleaderleo Oct 21, 2026
8d0d6a2
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
1c78451
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
0d3b7c7
cmux-next: a lone chat docking for a main pane open keeps its place; …
teamleaderleo Oct 21, 2026
416026d
Keep base's test registry (the merge hook registered a base test; tha…
teamleaderleo Oct 21, 2026
48354b3
Merge remote-tracking branch 'origin/feat-cmux-next' into main-pane-l…
teamleaderleo Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ final class ChatsOpenCoordinator {
// A page of another machine never becomes a chat of this Mac (cx-k21v): the chat opens in a
// new workspace of this Mac instead, as before open-here.
guard pane.daemon.isLocal else { return false }
if NewTabPage.opensBeside(page, in: pane) {
if NewTabPage.opensBeside(page, in: pane, mainPane: false) {
let closePage = NewTabPage.closingPage(page, in: pane)
return services.linkOpener.open(from: pane) { [services] target, placed in
return services.linkOpener.open(from: pane, mainPane: false) { [services] target, placed in
services.agentTabs.openTab(in: target, seed: AgentPaneSeedSource(seed), select: placed == nil) { key in
if let placed, let surface = services.locateTab(key)?.0.surface { placed(surface) }
closePage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ extension AgentTabStore {
watch.read()
}, openSide: { [weak self] chat, fork in // the tracker (AgentSidePanes) picks the side pane's tab or a new split; the app opens there
guard let self else { return }; AgentSidePanes.chat(chat).open(fork, chat: resolve(provisional), pane: { [weak self] in self?.locate(self?.resolve($0) ?? $0).map { "\(ObjectIdentifier($0.daemon).hashValue):\($0.pane.rawValue)" } }) { [weak self] anchor, created, finished in
self?.openBeside(anchor ?? provisional, split: anchor == nil, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: fork) }, created: created, finished: { _ in finished() }) ?? false }
self?.openBeside(anchor ?? provisional, split: anchor == nil, main: provisional, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: fork) }, created: created, finished: { _ in finished() }) ?? false }
}, openTab: { [weak self] session, destination in self?.openFork(session, at: destination, beside: provisional) ?? false }, openSubagent: { [weak self] in self?.openSubagent($1, of: $0, focus: $2, beside: provisional) ?? false }, openProcess: { [weak self] viewer in guard let self else { return false }; return openProcess?(resolve(provisional), viewer) ?? false }, newChat: { [weak self] in self?.openNewTabColumn(beside: provisional, cwd: $0, split: false) ?? false }, sideTabs: { [weak self] in
guard let self else { return nil }
let chat = resolve(provisional)
Expand Down Expand Up @@ -63,11 +63,13 @@ extension AgentTabStore {

private func openSubagent(_ agent: String, of session: String, focus: Bool, beside provisional: String) -> Bool { // the tracker (AgentSubagentPanes) decides; the app opens and moves the tab
AgentSubagentPanes.session(session).open(agent, focus: focus, chat: provisional, hooks: .init(isOpen: { [weak self] in self?.locate($0) != nil }, focus: { [weak self] key, script in _ = self?.actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: self?.resolve(key) ?? key), arguments: script ? ["focus": .bool(true)] : [:], origin: script ? .script : .user)) },
place: { [weak self] anchor, down, created, finished in self?.openBeside(anchor, direction: down ? "down" : "right", origin: focus ? .user : .script, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: session) },
place: { [weak self] anchor, down, created, finished in self?.openBeside(anchor, direction: down ? "down" : "right", origin: focus ? .user : .script, main: provisional, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: session) },
created: { [weak self] in created($0); self?.revealTurn(AgentSubagentPanes.reveal(agent), in: $0) }, finished: finished) ?? false }, again: { [weak self] request in _ = self?.openSubagent(request.agent, of: session, focus: request.focus, beside: request.chat) })) }
/// A tab in chat tab `provisional`'s pane, then moved to a new split on its right when `split`; else a tab there, focused when `created` is set.
/// A split asked for chat `main` when that chat is its screen's main pane (``openInMainPane``) is a tab in the column on its right instead.
@discardableResult
func openBeside(_ provisional: String, split: Bool = true, direction: String = "right", origin: ActionOrigin = .user, open: (PaneID, DaemonService) throws -> AgentTabPending, created: (@MainActor (String) -> Void)? = nil, finished: (@MainActor (_ moved: Bool) -> Void)? = nil) -> Bool {
func openBeside(_ provisional: String, split: Bool = true, direction: String = "right", origin: ActionOrigin = .user, main: String? = nil, open: (PaneID, DaemonService) throws -> AgentTabPending, created: (@MainActor (String) -> Void)? = nil, finished: (@MainActor (_ moved: Bool) -> Void)? = nil) -> Bool {
if split, let main, let placed = openInMainPane(resolve(main), origin == .user, open, created, finished) { return placed }
guard let (pane, daemon) = locate(resolve(provisional)), let pending = try? open(pane, daemon) else { return false }
guard split || created != nil else { return true }
// task-owner: one tab creation, then one move (or, not `split`, one focus) of that tab
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import CmuxNextAgentPane
import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextHomeEngine
import struct CmuxNextLayout.MainPaneLayout
import Foundation

/// Agent chat tabs on the workspace store (cmux-tui/spec/commands.md, new-conversation-tab): the store
Expand Down Expand Up @@ -162,6 +163,12 @@ extension AgentTabStore {
guard let services, let (tab, pane) = services.locateTab(key) else { return nil }
return (pane: pane.handle, daemon: services.machines.daemon(forTab: tab))
}
tabs.openInMainPane = { [weak services] chat, focus, open, created, finished in
guard let services, let (_, pane) = services.locateTab(chat), let controller = services.paneController(for: pane),
let side = MainPaneLayout.side(from: controller, services: services) else { return nil }
return MainPaneLayout.openAgentTab(from: controller, side: side, focus: focus, services: services, open: open,
created: created, finished: finished)
}
// A redeemed Run attempt's terminal opens only on a Cloud machine (keyed by its provider VM
// id) whose daemon this Mac already has connected, with the session the ticket names; never
// on this Mac, an SSH host or a paired server. The page's ids are not verified against the
Expand Down
6 changes: 6 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ final class AgentTabStore {
var lookup: @MainActor (String) -> (record: AgentSessionRef, store: DaemonStore)? = { _ in nil }
/// The pane holding tab `key` and its daemon (New side chat opens its fork there first).
var locate: @MainActor (String) -> (pane: PaneID, daemon: DaemonService)? = { _ in nil }
/// Main pane layout (``MainPaneLayout``): opens what chat tab `chat` opens beside it (through
/// `open`, focused with `focus`) in the column on the chat's right, then `created` and
/// `finished` as ``openBeside`` calls them. Nil when the chat is not its screen's main pane:
/// the caller places the tab itself.
var openInMainPane: @MainActor (_ chat: String, _ focus: Bool, _ open: (PaneID, DaemonService) throws -> AgentTabPending,
_ created: (@MainActor (String) -> Void)?, _ finished: (@MainActor (Bool) -> Void)?) -> Bool? = { _, _, _, _, _ in nil }
/// Every agent tab the trees list, with its record.
var listTabs: @MainActor () -> [(key: String, record: AgentSessionRef)] = { [] }
/// Creates the store tab with idempotency key `key` (AppServices: `new-conversation-tab` on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import CmuxNextActions
import CmuxNextBridge
import CmuxNextCompat
import CmuxNextDaemon
import CmuxNextLayout
import CmuxNextSettings
import CmuxNextWakeups
import Foundation

/// Main pane layout in the app (``MainPaneLayout``, `layout.mainPane`): what an agent chat opens from its
/// pane goes beside it. ``LinkOpener`` (links, files, folders) and ``AgentTabStore``'s side and
/// subagent panes ask here first.
extension MainPaneLayout {
/// How long a dock waits for the store to list the new tab, then for the chat to move.
static let listLimit: Duration = .seconds(30)

/// Whether main pane layout is on now: the Debug override, else `layout.mainPane` (its default
/// before settings load).
@MainActor static func isOn(services: AppServices) -> Bool {
isOn(setting: (services.settings?.snapshot.mainPane ?? MainPaneSetting.fallback) == .agentChats, override: debugOverride.value)
}

/// Where what `controller`'s pane opens goes in main pane layout, or nil (off, or not a main pane).
@MainActor static func side(from controller: PaneController, services: AppServices) -> ChatColumnPlacement? {
guard isOn(services: services) else { return nil }
return side(of: ChatColumnPlacement.resolve(from: controller, services: services))
}

/// The lone chat in `controller`'s pane docks left with the agent chat role; the tab on
/// `surface`, just opened there unselected, stays and is selected, so its pane becomes the
/// column on the chat's right. With `focus` (a person's click) the tab takes keyboard focus;
/// else no focus moves to it, and focus follows the chat into its dock when the chat had it
/// (an agent's open stays in the background). A failed dock selects the tab where it is, so it
/// never stays hidden. `then` hears whether the chat docked, once the store lists it in its
/// dock, so a later open sees the column on its right.
@MainActor static func dockChat(of controller: PaneController, keeping surface: SurfaceID, focus: Bool, services: AppServices,
then: (@MainActor (Bool) -> Void)? = nil) {
let store = controller.daemon.store, anchor = controller.pane
let chatHadFocus = controller.workspace?.focusedPane === controller
let reveal: @MainActor (PaneController, TabModel) -> Void = { pane, tab in
if focus { pane.select(StripTabID(tab.id)) } else { pane.selectWhenReportedKeepingFocus(surface: surface) }
}
services.registry.track(Task { @MainActor [weak controller] () -> ActionWorkFailure? in
let listed = Task { @MainActor () -> TabModel? in
if let tab = store.tab(surface: surface) { return tab }
// `tabListRevision`: the tracked read hears the tab appear (AppServices.locateTab).
for await found in ObservationStream({ _ = store.tabListRevision; return store.tab(surface: surface) != nil }) where found {
return store.tab(surface: surface)
}
return nil
}
let bound = DemandTimer(owner: "MainPaneLayout.tabListed")
bound.schedule(after: listLimit) { @MainActor in listed.cancel() }
let found = await listed.value
bound.cancel()
guard let tab = found, let controller else {
then?(false)
return "main pane: the new tab did not open"
}
guard let chat = anchor.tabs.first(where: { $0.id != tab.id && ChatColumnPlacement.isChat($0, services: services) }) else {
reveal(controller, tab)
then?(false)
return nil
}
// The chat stays where it shows; only the tab's pane comes in, from the right (as
// AgentBesidePlacement docks a lone chat).
let layout = controller.workspace?.layoutModel
layout?.handOffContent(of: controller.layoutPaneID)
let docked = await withCheckedContinuation { (done: CheckedContinuation<Bool, Never>) in
TabMoves.toNewDockColumn(chat, anchor: anchor, edge: .left, role: .agentChat, services: services) { done.resume(returning: $0) }
}
reveal(controller, tab)
guard docked else {
layout?.cancelContentHandoff()
if focus { controller.focusContent() }
then?(false)
return "main pane: the chat did not dock (see the app log)"
}
// A hidden tab bar was the chat's choice: the pane it left shows its tab bar again.
if services.paneTabBars.choice(for: controller.paneKey) == false {
services.paneTabBars.set(nil, for: controller.paneKey)
controller.apply(controller.snapshot())
}
let dock = await movedPane(of: chat.id, from: anchor, services: services)
if let content = controller.workspace {
if focus {
PaneHandlers.focus(controller.layoutPaneID, in: content)
} else if chatHadFocus, let dock {
// Not a view change of the open: the chat moved out of the focused pane, and focus follows
// it back to where the person had it (FocusCoordinator.followMovedTab does so for a move).
ActionRunScope.carrying(ActionRunScope(origin: .script, allowsViewChange: true)) {
PaneHandlers.focus(LayoutPaneID(dock.id), in: content)
}
}
}
then?(true)
return nil
})
}

/// Opens an agent tab (a subagent's or a side chat's pane) that chat `controller` opens beside
/// itself, where main pane layout's `side` puts it: through `open` as a tab in the strip pane
/// right of the chat dock, or in the lone chat's pane, which then docks the chat left
/// (``dockChat(of:keeping:focus:services:)``). `created` hears the new tab's key once the
/// tree lists it, `finished` whether it landed beside the chat. With `focus` the tab takes
/// keyboard focus; else it is only selected there. False when nothing opened.
@MainActor static func openAgentTab(from controller: PaneController, side: ChatColumnPlacement, focus: Bool, services: AppServices,
open: (DaemonPaneID, DaemonService) throws -> AgentTabPending,
created: (@MainActor (String) -> Void)?, finished: (@MainActor (Bool) -> Void)?) -> Bool {
let target: PaneController
switch side {
case .tab(let id):
guard let strip = controller.workspace?.panes[id] else { return false }
target = strip
case .dockChat:
target = controller
case .here, .besideDock:
return false
}
guard let pending = try? open(target.pane.handle, target.daemon) else { return false }
let locate: TabMirror.Locate = { key in
services.locateTab(key).map { (pane: $0.1.handle, daemon: services.machines.daemon(forTab: $0.0)) }
}
// task-owner: one tab creation, then one selection (or the chat's dock) for it
Task { @MainActor [weak target] in
guard let tab = try? await pending.value(), await TabMirror.reaches(tab.key, in: locate), let target else {
finished?(false)
return
}
created?(tab.key)
if target === controller {
// `finished` once the chat is in its dock: a queued open then sees the column on its right.
dockChat(of: target, keeping: tab.surface, focus: focus, services: services) { _ in finished?(true) }
return
}
if focus, let model = services.locateTab(tab.key)?.0 {
target.select(StripTabID(model.id))
if let content = target.workspace { PaneHandlers.focus(target.layoutPaneID, in: content) }
} else {
// An agent's open: selected in the column, keyboard focus stays where it is.
target.selectWhenReportedKeepingFocus(surface: tab.surface)
}
finished?(true)
}
return true
}

/// The pane chat tab `id` moved into from `anchor`, once the store lists it there (nil after
/// ``listLimit``).
@MainActor private static func movedPane(of id: String, from anchor: PaneModel, services: AppServices) async -> PaneModel? {
let moved = Task { @MainActor () -> Bool in
for await moved in ObservationStream({ services.locateTab(id).map { $0.1 !== anchor } ?? false }) where moved {
return true
}
return false
}
let bound = DemandTimer(owner: "MainPaneLayout.chatDock")
bound.schedule(after: listLimit) { @MainActor in moved.cancel() }
let found = await moved.value
bound.cancel()
return found ? services.locateTab(id)?.1 : nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ struct LinkOpenPlacement<Pane: Equatable> {
/// the daemon opens it in a new column right of the dock (`pinned-dock-alone-v1`), so the
/// dock never holds the tab, not even for the moment a split or column move would take.
case besideDock(Pane)
/// Main pane layout (``MainPaneLayout``), from an agent chat alone on its screen: the tab opens
/// unselected in this pane, then the chat docks left and the tab's pane stays on its right.
case dockChat(Pane)
}

var setting: LinkPlacement
Expand Down
Loading