diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift index b23cd435744c..9529e7a235a8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift @@ -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 { @@ -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) } then?(created.key) return nil } catch { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatColumnPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatColumnPlacement.swift index 6049b35edcb8..e01df929ec7d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatColumnPlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatColumnPlacement.swift @@ -51,7 +51,6 @@ extension ChatColumnPlacement { 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) - let agentTabs = services.agentTabs // A zoomed screen maps to its zoomed pane alone (LayoutMapping). let zoomed = content.workspace.screens.contains { screen in screen.zoomedPane != nil && screen.panes.contains { $0 === controller.pane } @@ -59,11 +58,15 @@ extension ChatColumnPlacement { return resolve(from: controller.layoutPaneID, columns: columns, recent: content.recentPanes, zoomed: zoomed) { column in guard column.root.panes.count == 1, let tabs = content.panes[column.root.panes[0]]?.pane.tabs, tabs.count == 1 else { return false } - // The New Tab page is an agent tab too, but not a chat. - return agentTabs.isAgentTab(tabs[0].id) && !agentTabs.isNewTabPage(tabs[0].id) + 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 lone chat moves into a new left dock diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockChrome.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockChrome.swift new file mode 100644 index 000000000000..fc65168e7fc1 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockChrome.swift @@ -0,0 +1,34 @@ +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 } + // Beside a chat dock a chat in the strip is an ordinary tab: it docks nothing. + return column.dock == nil && isLoneChat && columns.filter { $0.dock == nil }.count == 1 + && !columns.contains { $0.dock?.role == .agentChat } + } +} + +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) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/NewChatPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/NewChatPlacement.swift new file mode 100644 index 000000000000..229b433d09d5 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/NewChatPlacement.swift @@ -0,0 +1,131 @@ +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 + } +} + +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) + } + + /// 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.dockFinished(key) + guard moved, let content = controller.workspace else { return } + focusWhenShown(key, in: content, leaving: controller.layoutPaneID, services: services) + } + return nil + }) + } + + /// Focuses the pane that shows tab `key` once the layout has it outside + /// `source`, the pane it moved from (the store may still list it there). + @MainActor private static func focusWhenShown(_ key: String, in content: WorkspaceContentController, + leaving source: LayoutPaneID, services: AppServices) { + func pane() -> LayoutPaneID? { + guard let model = services.locateTab(key)?.1, + let id = services.paneController(for: model)?.layoutPaneID, id != source else { return nil } + return id + } + 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) } + return nil + }) + } +} + +extension PaneController { + /// The tab ids `pendingDock` hides: a provisional id also hides the tab + /// the store replaced it with, from the store's first snapshot on. + var dockBound: Set { + let aliases = services.agentTabs.aliases + return pendingDock.union(pendingDock.compactMap { aliases[$0] }) + } + + /// Chat `key` reached its dock (or the move failed): the strip may list + /// it again. Other chats still on their way stay hidden. + func dockFinished(_ key: String) { + let aliases = services.agentTabs.aliases + pendingDock = pendingDock.filter { $0 != key && aliases[$0] != key } + apply(snapshot()) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 27cfaeb624f4..288f8a43fc17 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -33,6 +33,15 @@ final class PaneContentView: NSView, PaneContentChrome { didSet { if barPosition != oldValue { updateBand() } } } private var placementObservation: Task? + /// No strip: the chat dock or a lone chat with one tab (``ChatDockChrome``). + var hidesStrip = false { + didSet { + guard hidesStrip != oldValue else { return } + stripView.isHidden = hidesStrip + refreshBandHeight() + needsLayout = true + } + } /// A browser's tab bar above or below its toolbar (`tabs.barOrder`, R109). var barOrder: TabBarOrder = .aboveToolbar { didSet { if barOrder != oldValue { updateBand() } } @@ -132,7 +141,7 @@ final class PaneContentView: NSView, PaneContentChrome { /// pane cell's top, through the pane padding) and below (to the content /// border), on this window's pixel grid (`PaneChromeMetrics`). var stripHeight: CGFloat { - PaneChromeMetrics.current.resolvedStripHeight(scale: window?.backingScaleFactor ?? 2) + hidesStrip ? 0 : PaneChromeMetrics.current.resolvedStripHeight(scale: window?.backingScaleFactor ?? 2) } override func viewDidChangeBackingProperties() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+AgentTabs.swift index 60c89aa0644d..d5a5dcaa5f2c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController+AgentTabs.swift @@ -23,12 +23,13 @@ extension PaneController { /// An agent chat tab in this pane on the workspace store, shown and selected at once /// (``AgentTabStore/openTab(in:session:seed:newTab:spare:linked:select:then:)``). False when - /// the pane cannot hold one (refused with the reason). + /// the pane cannot hold one (refused with the reason). New chats open in the chat dock (NewChatPlacement). @discardableResult func openAgentTab(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 { - services.agentTabs.openTab(in: self, session: session, seed: seed, newTab: newTab, spare: spare, linked: linked, - select: select, then: then) + let open = NewChatPlacement.opening(from: self, placed: newTab == nil, select: select, then: then) + return services.agentTabs.openTab(in: open.target, session: session, seed: seed, newTab: newTab, spare: spare, + linked: linked, select: open.select, hidden: open.hidden, then: open.then) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController.swift index fe4394b75d8e..a8b8bcf07032 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneController.swift @@ -32,6 +32,7 @@ final class PaneController: SurfacePresenter, PresentablePane { var isVisible: Bool { presence == .visible } /// Tabs closed locally while the daemon confirms, so a close looks instant. var pendingClosed: Set = [] + var pendingDock: Set = [] // chats bound for a new chat dock, never in this strip (NewChatPlacement) /// A tab this app just created here; selected once the daemon reports it (`selectWhenReported`). private(set) var pendingSelectSurface: SurfaceID? /// Same, named by tab resource id (a reopened tab's restored view). @@ -45,6 +46,7 @@ final class PaneController: SurfacePresenter, PresentablePane { var connected: Bool var generation: String? var surfaces: [UInt64] + var hidesStrip = false // the chat dock or a lone chat with one tab (ChatDockChrome) } init(pane: PaneModel, daemon: DaemonService, layoutPaneID: LayoutPaneID, services: AppServices, state: WindowState) { @@ -100,7 +102,9 @@ final class PaneController: SurfacePresenter, PresentablePane { // Terminals on another machine carry its name; browsers always run here. let machine = daemon.isLocal ? nil : services.machines.machineBadge(daemon.machineID) let workspaceID = store.workspace(containing: pane.handle)?.id - var items = pane.tabs.filter { !pendingClosed.contains($0.id) }.map { tab -> StripTabItem in + // A dock-bound chat stays hidden under the id the store gave it (`dockBound`). + let hidden = pendingClosed.union(dockBound) + var items = pane.tabs.filter { !hidden.contains($0.id) }.map { tab -> StripTabItem in // A new tab page is "New Tab", with the new-tab icon, until it // becomes a chat (then the chat's title and icon). let isNewTabPage = tab.agentSession != nil && services.agentTabs.pageTabs.ids.contains(tab.id) @@ -143,7 +147,7 @@ final class PaneController: SurfacePresenter, PresentablePane { TabItemMapping.shared.applyUserIcon(tab, to: &item) return item } - for local in state?.localBrowserTabs[paneKey] ?? [] where !pendingClosed.contains(local.id) { + for local in state?.localBrowserTabs[paneKey] ?? [] where !hidden.contains(local.id) { let page = services.cache.existingBrowser(local.id)?.tab.state let title = page?.title.flatMap { $0.isEmpty ? nil : $0 } ?? page?.url?.host() ?? Strings.untitledBrowser var item = StripTabItem(id: StripTabID(local.id), title: title, subtitle: page?.url?.absoluteString, @@ -152,7 +156,7 @@ final class PaneController: SurfacePresenter, PresentablePane { browserIcon(key: local.id, recordFavicon: nil).apply(to: &item) items.append(item) } - items += services.localTabItems(in: paneKey, hiding: pendingClosed) + items += services.localTabItems(in: paneKey, hiding: hidden) let saved = Set(store.savedTabGroups.compactMap(\.openGroup)) let groups = pane.tabGroups.map { group in TabGroupItem(id: TabGroupID(group.id.rawValue), name: group.name, @@ -161,7 +165,8 @@ final class PaneController: SurfacePresenter, PresentablePane { } let connected = if case .connected = store.connectionState { true } else { false } return Snapshot(items: items, groups: groups, defaultIndex: pane.defaultTabIndex, connected: connected, - generation: store.generation?.rawValue, surfaces: pane.tabs.map(\.surface.rawValue)) + generation: store.generation?.rawValue, surfaces: pane.tabs.map(\.surface.rawValue), + hidesStrip: ChatDockChrome.hidesStrip(self, tabCount: items.count)) } /// The page icon, favicon, throbber or globe of browser tab `key`: its live page's @@ -179,6 +184,7 @@ final class PaneController: SurfacePresenter, PresentablePane { /// state after a rejected command (order, membership, closes). func apply(_ snapshot: Snapshot, force: Bool = false) { if force { view.stripView.discardPendingReorder() } + if view.hidesStrip != snapshot.hidesStrip { view.hidesStrip = snapshot.hidesStrip } if stripModel.groups != snapshot.groups { stripModel.groups = snapshot.groups } if stripModel.tabs != snapshot.items { stripModel.tabs = snapshot.items } if !snapshot.items.isEmpty { LaunchReveal.shared.markReady(.tabs) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatDockChromeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatDockChromeTests.swift new file mode 100644 index 000000000000..c983926dea4b --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatDockChromeTests.swift @@ -0,0 +1,55 @@ +import CmuxNextBridge +import CmuxNextLayout +import Testing +@testable import CmuxNextApp + +/// The agent chat is a docked column with no tab bar by default +/// (lawrence-call-1006 D): its strip shows only once it holds two tabs. +@Suite struct ChatDockChromeTests { + private let chatDock = DockColumn(edge: .left, role: .agentChat) + + private func column(_ id: String, _ panes: [String], dock: DockColumn? = nil) -> LayoutColumn { + let leaves = panes.map { SplitNode.leaf(LayoutPaneID($0)) } + let root = leaves.dropFirst().reduce(leaves[0]) { a, b in + .split(SplitID("s-\(id)-\(a.panes.count)"), axis: .vertical, ratio: 0.5, a: a, b: b) + } + return LayoutColumn(id: LayoutColumnID(id), root: root, dock: dock) + } + + private func hides(_ pane: String, _ columns: [LayoutColumn], tabs: Int = 1, lone: Bool = false) -> Bool { + ChatDockChrome.hidesStrip(pane: LayoutPaneID(pane), columns: columns, tabCount: tabs, isLoneChat: lone) + } + + @Test func theChatDockShowsNoStripWithOneTab() { + let columns = [column("c1", ["chat"], dock: chatDock), column("c2", ["term"])] + #expect(hides("chat", columns)) + } + + @Test func theChatDockShowsItsStripWithTwoTabs() { + let columns = [column("c1", ["chat"], dock: chatDock), column("c2", ["term"])] + #expect(!hides("chat", columns, tabs: 2)) + } + + /// A chat alone on its screen docks when the first tool opens + /// (ChatColumnPlacement); until then it has no strip either. + @Test func aLoneChatShowsNoStrip() { + #expect(hides("chat", [column("c1", ["chat"])], lone: true)) + } + + @Test func otherPanesKeepTheirStrip() { + let columns = [column("c1", ["chat"], dock: chatDock), column("c2", ["term"]), + column("c3", ["notes"], dock: DockColumn(edge: .right))] + #expect(!hides("term", columns)) + #expect(!hides("notes", columns)) + #expect(!hides("solo", [column("c9", ["solo"])])) + #expect(!hides("gone", columns)) + } + + /// Cursor review (#18223): a chat alone in the strip beside the chat dock + /// (a New Tab page that became a chat) is not the screen's lone chat: it + /// docks nothing, so it keeps its strip. + @Test func aLoneChatBesideTheChatDockKeepsItsStrip() { + let columns = [column("c0", ["docked"], dock: chatDock), column("c1", ["chat"])] + #expect(!hides("chat", columns, lone: true)) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatDockFlashTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatDockFlashTests.swift new file mode 100644 index 000000000000..bc946039a311 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatDockFlashTests.swift @@ -0,0 +1,54 @@ +import CmuxNextActions +import CmuxNextDaemon +import Foundation +import Testing +@testable import CmuxNextApp + +/// A new chat bound for a new chat dock is made in the focused pane, then +/// moved into the dock. It must never show in that pane's strip on the way, +/// or the strip flashes a tab (Leo's smooth north star). +@MainActor @Suite(.serialized, .timeLimit(.minutes(1))) struct NewChatDockFlashTests { + private static func waitUntil(sourceLocation: SourceLocation = #_sourceLocation, _ condition: () -> Bool) async throws { + try await waitForCondition(timeout: .seconds(15), sourceLocation: sourceLocation, condition) + } + + @Test func aTabBoundForTheChatDockNeverShowsInTheStrip() async throws { + let daemon = try TopologyDaemon() + let services = ActionBindingCoverageTests.boundServices() + services.windows.ordersWindowsIn = false + services.daemon.start(makeConnection: { daemon.connection() }) + defer { + for controller in services.windows.controllers { controller.window?.close() } + services.daemon.shutdownConnection() + daemon.stop() + } + try await Self.waitUntil { + services.daemon.store.isLoaded && services.daemon.store.workspaces.count == 1 + } + let window = try #require(services.windows.openWindow(workspaces: [TopologyDaemon.firstKey])) + try await Self.waitUntil { window.content?.panes.isEmpty == false } + let pane = try #require(window.content?.panes.values.first) + try await Self.waitUntil { !pane.pane.tabs.isEmpty } + let tab = try #require(pane.pane.tabs.first) + + pane.pendingDock.insert(tab.id) + #expect(!pane.snapshot().items.contains { $0.id.rawValue == tab.id }) + pane.pendingDock.removeAll() + #expect(pane.snapshot().items.contains { $0.id.rawValue == tab.id }) + + // Cursor review (#18223): the store swaps the provisional tab for the + // created one before the creation reply lands; the created tab is + // hidden from that first snapshot on. + let provisional = "provisional:dock-bound" + pane.pendingDock.insert(provisional) + services.agentTabs.rekey(provisional, to: tab.id) + #expect(!pane.snapshot().items.contains { $0.id.rawValue == tab.id }, "the created chat showed in the strip") + + // Another chat reaching its dock leaves this one hidden. + pane.pendingDock.insert("other-chat") + pane.dockFinished("other-chat") + #expect(!pane.snapshot().items.contains { $0.id.rawValue == tab.id }, "another dock showed this chat") + pane.dockFinished(tab.id) + #expect(pane.snapshot().items.contains { $0.id.rawValue == tab.id }) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatPlacementTests.swift new file mode 100644 index 000000000000..77129e68a540 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewChatPlacementTests.swift @@ -0,0 +1,43 @@ +import CmuxNextBridge +import CmuxNextLayout +import Testing +@testable import CmuxNextApp + +/// Every new agent chat opens as the docked chat column, never as a tab in +/// the strip (lawrence-call-1006 D). +@Suite struct NewChatPlacementTests { + private let chatDock = DockColumn(edge: .left, role: .agentChat) + + private func column(_ id: String, _ panes: [String], dock: DockColumn? = nil) -> LayoutColumn { + let leaves = panes.map { SplitNode.leaf(LayoutPaneID($0)) } + let root = leaves.dropFirst().reduce(leaves[0]) { a, b in + .split(SplitID("s-\(id)-\(a.panes.count)"), axis: .vertical, ratio: 0.5, a: a, b: b) + } + return LayoutColumn(id: LayoutColumnID(id), root: root, dock: dock) + } + + private func resolve(from pane: String, _ columns: [LayoutColumn], lone: Bool = false) -> NewChatPlacement { + NewChatPlacement.resolve(from: LayoutPaneID(pane), columns: columns, isLoneChat: lone) + } + + @Test func aNewChatJoinsTheChatDock() { + let columns = [column("c1", ["chat"], dock: chatDock), column("c2", ["term", "logs"])] + #expect(resolve(from: "logs", columns) == .dock(LayoutPaneID("chat"))) + #expect(resolve(from: "chat", columns) == .dock(LayoutPaneID("chat"))) + } + + @Test func aNewChatBesideToolsMakesTheChatDock() { + #expect(resolve(from: "term", [column("c1", ["term"])]) == .newDock) + let columns = [column("c1", ["term"]), column("c2", ["web"]), column("c3", ["notes"], dock: DockColumn(edge: .right))] + #expect(resolve(from: "web", columns) == .newDock) + } + + /// A lone chat docks when the first tool opens; a second chat joins its pane. + @Test func aNewChatFromALoneChatStaysWithIt() { + #expect(resolve(from: "chat", [column("c1", ["chat"])], lone: true) == .here) + } + + @Test func aPaneOutsideTheColumnsStaysHere() { + #expect(resolve(from: "gone", [column("c1", ["term"])]) == .here) + } +}