Repository navigation
cmux-next: agent chat is a docked column with no tab bar by default #18223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 33 commits
513ffb1
48d096c
763a51f
b85c214
90518c9
93e6531
e692b35
f6ece76
5353e55
b998d19
977bcf8
be68974
f3e7ab3
027e429
db5b4dc
8588cb8
2dffd38
063fc3e
cd215f8
a89def7
ddf463a
25bda55
0c2a49f
1820d96
271492b
30502a3
d3a2b59
9470415
a60c68f
ec64895
c3ac201
7edcfbd
52087f5
5d3bca2
01c19d2
c16be25
f5b6444
0a6fdc6
c2efdf5
d9aedb0
f37a0bc
92ee982
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
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) | ||
|
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 | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Concurrent chats create duplicate docksMedium Severity
Additional Locations (1)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) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Zoomed panes still create chat docksMedium Severity
Additional Locations (1)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()) | ||
|
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) } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| return nil | ||
| }) | ||
| } | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.