Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
513ffb1
test: the dock column keeps an agent chat role
teamleaderleo Oct 7, 2026
48d096c
feat: dock columns keep an agent chat role (dock-column-role-v1)
teamleaderleo Oct 7, 2026
763a51f
test(cmux-sdk): build ColumnPin with its optional role
teamleaderleo Oct 7, 2026
b85c214
test(cmux-next): tools opened from the docked chat go to the strip
teamleaderleo Oct 7, 2026
90518c9
feat(cmux-next): tools opened from the docked chat open in the strip
teamleaderleo Oct 7, 2026
93e6531
Merge remote-tracking branch 'origin/feat-cmux-next' into next-dock-c…
teamleaderleo Oct 7, 2026
e692b35
Merge branch 'next-dock-chat-role' into next-chat-dock-tools
teamleaderleo Oct 7, 2026
f6ece76
fix(cmux-next): a lone New Tab page is not a chat to dock
teamleaderleo Oct 7, 2026
5353e55
fix(cmux-next): a terminal from the docked chat lets the daemon pick …
teamleaderleo Oct 7, 2026
b998d19
test(cmux-next): a lone chat on a split-tree screen sees its implicit…
teamleaderleo Oct 7, 2026
977bcf8
fix(cmux-next): a lone chat on a split-tree screen docks
teamleaderleo Oct 7, 2026
be68974
test(cmux-next): the chat dock shows no tab strip until it holds two …
teamleaderleo Oct 7, 2026
f3e7ab3
feat(cmux-next): the chat dock shows no tab strip until it holds two …
teamleaderleo Oct 7, 2026
027e429
test(cmux-next): a new agent chat opens in the chat dock
teamleaderleo Oct 7, 2026
db5b4dc
feat(cmux-next): a new agent chat opens in the chat dock
teamleaderleo Oct 7, 2026
8588cb8
test(cmux-next): a new chat bound for the chat dock never shows in th…
teamleaderleo Oct 7, 2026
2dffd38
fix(cmux-next): a new chat moves into its dock without flashing in th…
teamleaderleo Oct 7, 2026
063fc3e
Merge remote-tracking branch 'origin/feat-cmux-next' into next-dock-c…
teamleaderleo Oct 7, 2026
cd215f8
Merge remote-tracking branch 'origin/feat-cmux-next' into next-chat-d…
teamleaderleo Oct 7, 2026
a89def7
Merge remote-tracking branch 'origin/feat-cmux-next' into next-chat-d…
teamleaderleo Oct 7, 2026
ddf463a
Merge remote-tracking branch 'origin/feat-cmux-next' into next-dock-c…
teamleaderleo Oct 7, 2026
25bda55
refactor(cmux-next): DockRole in its own file (god-file limit)
teamleaderleo Oct 7, 2026
0c2a49f
style(cmux-tui): rustfmt the merged permanent-dock test
teamleaderleo Oct 7, 2026
1820d96
refactor(cmux-next): new chat routing lives in NewChatPlacement
teamleaderleo Oct 7, 2026
271492b
Merge remote-tracking branch 'origin/next-dock-chat-role' into next-c…
teamleaderleo Oct 7, 2026
30502a3
Merge branch 'next-chat-dock-tools' into next-chat-dock-default
teamleaderleo Oct 7, 2026
d3a2b59
refactor(cmux-next): fold PaneController's chat dock notes into fewer…
teamleaderleo Oct 7, 2026
9470415
Merge remote-tracking branch 'origin/feat-cmux-next' into next-chat-d…
teamleaderleo Oct 7, 2026
a60c68f
Merge branch 'next-chat-dock-tools' into next-chat-dock-default
teamleaderleo Oct 7, 2026
ec64895
Merge remote-tracking branch 'origin/feat-cmux-next' into next-chat-d…
teamleaderleo Oct 7, 2026
c3ac201
Merge branch 'next-chat-dock-tools' into next-chat-dock-default
teamleaderleo Oct 7, 2026
7edcfbd
Merge origin/feat-cmux-next into next-chat-dock-tools
teamleaderleo Oct 8, 2026
52087f5
Merge origin/feat-cmux-next into next-chat-dock-default
teamleaderleo Oct 8, 2026
5d3bca2
Merge origin/feat-cmux-next into next-chat-dock-tools
teamleaderleo Oct 8, 2026
01c19d2
Merge origin/feat-cmux-next into next-chat-dock-default
teamleaderleo Oct 8, 2026
c16be25
ci: run cmux-next again (generated files never got a runner on 01c19d2d)
teamleaderleo Oct 8, 2026
f5b6444
test(cmux-next): a strip-less chat dock and a zoomed chat keep the ne…
teamleaderleo Oct 8, 2026
0a6fdc6
fix(cmux-next): chat dock routing drops the strip-less case, holds wh…
teamleaderleo Oct 8, 2026
c2efdf5
test(cmux-next): a dock-bound chat stays hidden across its rekey and …
teamleaderleo Oct 8, 2026
d9aedb0
fix(cmux-next): a dock-bound chat stays hidden across its rekey and o…
teamleaderleo Oct 8, 2026
f37a0bc
Merge next-chat-dock-tools (#18170) into next-chat-dock-default
teamleaderleo Oct 9, 2026
92ee982
Merge feat-cmux-next into next-chat-dock-default
teamleaderleo Oct 9, 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 @@ -89,10 +89,12 @@ extension AgentTabStore {
/// shows and is selected now (unless `select` is false); the store's tab keeps the selection
/// when it replaces it, then `then` gets its id. A refusal is reported through the registry.
/// Returns false when the pane cannot hold an agent tab.
/// `hidden`: the tab never shows in `pane`'s strip (a chat bound for a new chat dock).
@discardableResult
func openTab(in pane: PaneController, session: String? = nil, seed: AgentPaneSeedSource? = nil,
newTab: (page: AgentPaneNewTab, handler: NewTabPageHandler)? = nil, spare: AgentPaneView? = nil,
linked: Bool = false, select: Bool = true, then: (@MainActor (String) -> Void)? = nil) -> Bool {
linked: Bool = false, select: Bool = true, hidden: Bool = false,
then: (@MainActor (String) -> Void)? = nil) -> Bool {
let daemon = pane.daemon
let pending: AgentTabPending
do {
Expand All @@ -104,9 +106,14 @@ extension AgentTabStore {
}
// The selection follows the provisional tab to the created one (`moveSelection`).
if select { pane.selectWhenReported(surface: pending.surface) }
if hidden {
pane.pendingDock.insert(pending.key)
pane.apply(pane.snapshot())
}
pane.services.registry.track(Task {
do {
let created = try await pending.value()
if hidden { pane.pendingDock.insert(created.key) }
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
then?(created.key)
return nil
} catch {
Expand Down
10 changes: 7 additions & 3 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabMoves.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CmuxNextDaemon
import CmuxNextDesign
import enum CmuxNextLayout.DockEdge
import enum CmuxNextLayout.DockMode
import enum CmuxNextLayout.DockRole
import Foundation

/// Daemon commands for tab moves. With `tab-drag-v1` every outcome is one
Expand Down Expand Up @@ -131,9 +132,11 @@ enum TabMoves {
/// `dock` and make a plain column, so it is refused here). The column that held the edge scrolls again. Top and
/// bottom are edge docks; `mode` nil uses `layout.dockColumnMode`, and
/// `width` nil a third of the height for a band or the width of a new
/// column beside the anchor for a side.
/// column beside the anchor for a side. `role` marks the chat dock
/// (dock-column-role-v1; an older daemon drops it).
static func toNewDockColumn(_ tab: TabModel, anchor pane: PaneModel, edge: CmuxNextLayout.DockEdge,
mode: CmuxNextLayout.DockMode? = nil, width: Double? = nil, respawn: SplitRespawn? = nil,
mode: CmuxNextLayout.DockMode? = nil, role: CmuxNextLayout.DockRole? = nil,
width: Double? = nil, respawn: SplitRespawn? = nil,
services: AppServices,
transaction: ClientTransactionID = .generate(), completion: @escaping Completion = { _ in }) {
let daemon = services.machines.daemon(forTab: tab)
Expand All @@ -142,7 +145,8 @@ enum TabMoves {
!refusesIncognitoCrossing(tab, to: pane, services: services) else { return completion(false) }
let surface = tab.surface, paneHandle = pane.handle
let overlay = mode.map { $0 == .overlay } ?? (DesignSettings.shared.dockColumnMode == .overlay)
let pin = DockSnapshot(edge: DockSnapshot.Edge(rawValue: edge.rawValue) ?? .right, mode: overlay ? .overlay : .docked)
let pin = DockSnapshot(edge: DockSnapshot.Edge(rawValue: edge.rawValue) ?? .right, mode: overlay ? .overlay : .docked,
role: role.map { _ in .agentChat })
// A band's size is a share of the screen height; a side column takes
// the width a new column next to the anchor would take.
let spawn = edge.isBand || width != nil ? nil
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import CmuxNextBridge
import CmuxNextDaemon
import CmuxNextLayout

/// Where a person's new tab goes when it is opened from an agent chat
/// (lawrence-call-1006 D, two columns like the Codex app). The chat dock is
/// the left dock column with the agent chat role: it never gets tabs of
/// its own, so tools opened from it are tabs in the scrolling strip.
enum ChatColumnPlacement: Equatable {
/// A tab in the pane it was opened from.
case here
/// A tab in this pane of the scrolling strip.
case tab(in: LayoutPaneID)
/// The docked chat has no scrolling column: a new one beside it.
case newColumn
/// The chat is alone on its screen: it moves into a new left dock with
/// the agent chat role, and the new tab stays in its pane.
case dockChat

/// `columns` are the screen's, in strip order; `recent` orders panes by
/// last focus, newest first; `isLoneChat` says whether a column is one
/// pane holding one agent chat. Only the role makes a chat dock: chats
/// in a plain dock or beside other columns are normal panes.
static func resolve(from pane: LayoutPaneID, columns: [LayoutColumn], recent: [LayoutPaneID] = [],
isLoneChat: (LayoutColumn) -> Bool) -> ChatColumnPlacement {
guard let index = columns.firstIndex(where: { $0.root.contains(pane) }) else { return .here }
let column = columns[index]
let strip = columns.filter { $0.dock == nil }
if column.dock?.role == .agentChat {
guard let target = strip.first else { return .newColumn }
return .tab(in: recent.first { id in strip.contains { $0.root.contains(id) } } ?? target.root.panes[0])
}
guard column.dock == nil, strip.count == 1, !columns.contains(where: { $0.dock?.role == .agentChat }),
isLoneChat(column) else { return .here }
return .dockChat
}
}

extension ChatColumnPlacement {
/// `screen`'s columns in strip order. A screen stored as one split tree
/// is one implicit column.
static func columns(of screen: LayoutScreen, containing pane: LayoutPaneID) -> [LayoutColumn] {
screen.layout.columns.isEmpty
? screen.column(containing: pane).map { [$0] } ?? [] : screen.layout.columns
}

/// What a person's new tab from `controller`'s pane becomes.
@MainActor static func resolve(from controller: PaneController, services: AppServices) -> ChatColumnPlacement {
guard let content = controller.workspace,
content.daemon.supports(DaemonCapabilities.shared.dockColumnRole),
let screen = content.layoutModel.screen(containing: controller.layoutPaneID) else { return .here }
let columns = columns(of: screen, containing: controller.layoutPaneID)
return resolve(from: controller.layoutPaneID, columns: columns, recent: content.recentPanes) { column in
guard column.root.panes.count == 1, let tabs = content.panes[column.root.panes[0]]?.pane.tabs,
tabs.count == 1 else { return false }
return isChat(tabs[0], services: services)
}
}

/// Whether `tab` is an agent chat. The New Tab page is an agent tab too, but not a chat.
@MainActor static func isChat(_ tab: TabModel, services: AppServices) -> Bool {
services.agentTabs.isAgentTab(tab.id) && !services.agentTabs.isNewTabPage(tab.id)
}

/// Where a person's new tab from `controller`'s pane opens: the pane
/// to open it in (`controller` itself, or the strip pane, focused), or
/// nil when it is already open. A docked chat with no strip gets a new
/// column when `respawn` is a terminal (the column's first tab); a lone
/// chat moves into a new left dock with the agent chat role, leaving
/// `respawn` in its pane, in one daemon commit. Without `respawn` (a New
/// Tab page, which is not a daemon tab) both stay in `controller`.
@MainActor static func route(from controller: PaneController, respawn: SplitRespawn?,
services: AppServices) -> PaneController? {
guard let content = controller.workspace else { return controller }
switch resolve(from: controller, services: services) {
case .here:
return controller
case .tab(let target):
guard let strip = content.panes[target] else { return controller }
PaneHandlers.focus(target, in: content)
return strip
case .newColumn:
guard case .terminal = respawn else { return controller }
content.layoutModel.newColumn(after: controller.layoutPaneID)
return nil
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
case .dockChat:
guard let respawn, controller.daemon.supports(DaemonCapabilities.shared.tabColumnRespawn),
let chat = controller.pane.tabs.first else { return controller }
// The new tab left in the pane takes focus, not the docked chat.
let pane = controller.layoutPaneID
TabMoves.toNewDockColumn(chat, anchor: controller.pane, edge: .left, role: .agentChat,
respawn: respawn, services: services) { [weak content] moved in
if moved, let content { PaneHandlers.focus(pane, in: content) }
}
return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import CmuxNextBridge
import CmuxNextDaemon
import CmuxNextLayout

/// The agent chat's pane chrome (lawrence-call-1006 D): the chat dock (the
/// dock column with the agent chat role) shows no tab strip by default,
/// and neither does a chat alone on its screen, which docks when the first
/// tool opens (``ChatColumnPlacement``). The strip comes back once the pane
/// holds two tabs.
enum ChatDockChrome {
/// `columns` are the pane's screen's; `isLoneChat` says the pane's column
/// is one pane holding one agent chat.
static func hidesStrip(pane: LayoutPaneID, columns: [LayoutColumn], tabCount: Int, isLoneChat: Bool) -> Bool {
guard tabCount < 2, let column = columns.first(where: { $0.root.contains(pane) }) else { return false }
if column.dock?.role == .agentChat { return true }
return column.dock == nil && isLoneChat && columns.filter { $0.dock == nil }.count == 1
}
}

extension ChatDockChrome {
/// Whether `controller`'s pane, holding `tabCount` tabs, hides its strip.
@MainActor static func hidesStrip(_ controller: PaneController, tabCount: Int) -> Bool {
guard tabCount < 2, let content = controller.workspace,
content.daemon.supports(DaemonCapabilities.shared.dockColumnRole),
let screen = content.layoutModel.screen(containing: controller.layoutPaneID) else { return false }
let pane = controller.layoutPaneID
let columns = ChatColumnPlacement.columns(of: screen, containing: pane)
let lone = columns.first { $0.root.contains(pane) }?.root.panes.count == 1
&& controller.pane.tabs.count == 1 && ChatColumnPlacement.isChat(controller.pane.tabs[0], services: controller.services)
return hidesStrip(pane: pane, columns: columns, tabCount: tabCount, isLoneChat: lone)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import CmuxNextBridge
import CmuxNextDaemon
import CmuxNextLayout
import Observation

/// Where a new agent chat opens (lawrence-call-1006 D): the agent chat is a
/// docked column on the left (the dock column with the agent chat role),
/// never a tab in the strip.
enum NewChatPlacement: Equatable {
/// A tab in the pane it was opened from.
case here
/// A tab in the chat dock's pane.
case dock(LayoutPaneID)
/// A new left chat dock, made from the chat once it exists.
case newDock

/// `columns` are the pane's screen's; `isLoneChat` says the pane's column
/// is one pane holding one agent chat, the only scrolling column. A lone
/// chat docks when the first tool opens (``ChatColumnPlacement``), so a
/// second chat joins its pane.
static func resolve(from pane: LayoutPaneID, columns: [LayoutColumn], isLoneChat: Bool) -> NewChatPlacement {
guard columns.contains(where: { $0.root.contains(pane) }) else { return .here }
if let dock = columns.first(where: { $0.dock?.role == .agentChat }) { return .dock(dock.root.panes[0]) }
return isLoneChat ? .here : .newDock
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent chats create duplicate docks

Medium Severity

resolve treats any screen without an existing agentChat dock as .newDock. Nothing records an in-flight dock, so two chats opened before the first move-tab-to-column lands both create a left chat dock. The daemon then undocks the earlier column on that edge, leaving one chat in a scrolling column instead of both tabs in the chat dock.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92ee982. Configure here.

}

extension NewChatPlacement {
/// Where a new chat from `controller`'s pane opens; `.here` on a daemon
/// without dock roles.
@MainActor static func resolve(from controller: PaneController) -> NewChatPlacement {
guard let content = controller.workspace,
content.daemon.supports(DaemonCapabilities.shared.dockColumnRole),
let screen = content.layoutModel.screen(containing: controller.layoutPaneID) else { return .here }
let pane = controller.layoutPaneID
return resolve(from: pane, columns: ChatColumnPlacement.columns(of: screen, containing: pane),
isLoneChat: ChatColumnPlacement.resolve(from: controller, services: controller.services) == .dockChat)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zoomed panes still create chat docks

Medium Severity

NewChatPlacement uses ChatColumnPlacement.resolve == .dockChat as isLoneChat and never reads the zoomed flag. A zoomed screen returns .here from that helper, so a lone zoomed chat (or zoomed tools with no dock) is treated as .newDock and still builds a left chat dock. Tool routing already refuses to dock or move while zoomed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92ee982. Configure here.


/// How `controller`'s new chat opens: the pane it opens in, and for a new
/// dock hidden and unselected, docked by `then` (shown once, in its dock).
/// `placed` false (a New Tab page becoming a chat) keeps it in `controller`.
struct Opening {
var target: PaneController
var select: Bool
var hidden = false
var then: (@MainActor (String) -> Void)?
}

@MainActor static func opening(from controller: PaneController, placed: Bool, select: Bool,
then: (@MainActor (String) -> Void)?) -> Opening {
var opening = Opening(target: controller, select: select, then: then)
guard placed else { return opening }
switch resolve(from: controller) {
case .here:
break
case .dock(let pane):
guard let content = controller.workspace, let dock = content.panes[pane] else { break }
opening.target = dock
if select { PaneHandlers.focus(pane, in: content) }
case .newDock:
opening.hidden = true
opening.select = false
opening.then = { [weak controller] key in
if let controller { NewChatPlacement.dock(key, from: controller) }
then?(key)
}
}
return opening
}

/// Moves new chat `key`, opened hidden in `controller`'s pane, into a new
/// left chat dock once the store shows it, then focuses it there. A
/// failed move shows it where it is.
@MainActor static func dock(_ key: String, from controller: PaneController) {
let services = controller.services
services.registry.track(Task { @MainActor [weak controller] in
var found = services.locateTab(key)
if found == nil {
for await located in Observations({ services.locateTab(key) != nil }) where located {
found = services.locateTab(key)
break
}
}
guard let found else { return nil }
let (tab, pane) = found
TabMoves.toNewDockColumn(tab, anchor: pane, edge: .left, role: .agentChat, services: services) { moved in
guard let controller else { return }
controller.pendingDock.removeAll()
controller.apply(controller.snapshot())
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
guard moved, let content = controller.workspace else { return }
focusWhenShown(key, in: content, services: services)
}
return nil
})
}

/// Focuses the pane that shows tab `key` once the layout has it.
@MainActor private static func focusWhenShown(_ key: String, in content: WorkspaceContentController, services: AppServices) {
func pane() -> LayoutPaneID? {
guard let model = services.locateTab(key)?.1 else { return nil }
return services.paneController(for: model)?.layoutPaneID
}
services.registry.track(Task { @MainActor in
if pane() == nil {
for await shown in Observations({ pane() != nil }) where shown { break }
}
if let pane = pane() { PaneHandlers.focus(pane, in: content) }
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return nil
})
}
}
Loading
Loading