diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift index d8e5fd7acd7c..c4bd0ea58baa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/ChatDockRules.swift @@ -8,7 +8,8 @@ import CmuxNextLayout /// holds only agent chats, so tools dropped or moved into it are refused. enum ChatDockRules { - private static func isChatDock(_ pane: LayoutPaneID, _ columns: [LayoutColumn]) -> Bool { + /// Whether `pane` is in the chat dock of a screen with `columns`. + static func isChatDock(_ pane: LayoutPaneID, _ columns: [LayoutColumn]) -> Bool { columns.first { $0.root.contains(pane) }?.dock?.role == .agentChat } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift new file mode 100644 index 000000000000..5bdfaf358297 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/ChatLinkPlacement.swift @@ -0,0 +1,91 @@ +import CmuxNextLayout +import CmuxNextSettings + +/// Where a web link opened from an agent chat lands (a link in a reply, a pull request chip, the +/// hover card's Open), by `layout.linkPlacement` (``LinkPlacement``). Leo 2026-10-21: "WHY DID THIS +/// NOT OPEN TO THE SIDE?" (the link opened as a tab over the chat), "if the left side has a tab, any +/// link opened also goes on the left instead of opening a new split", and "open on right by default". +/// +/// - ``LinkPlacement/splitRight`` (default) and ``LinkPlacement/newColumn``: on the right. A pane +/// right of the chat gets a tab: the one the person focused last, else the rightmost. With no +/// pane there, a new split (or column) on the chat's right. A pane left of the chat never gets +/// it, and neither does a pane that shows an agent chat (the caller's `takesTabs` says no). +/// - ``LinkPlacement/samePane``: "in the agent pane": a tab in the chat's own pane, never main pane +/// layout's dock or column. In the chat dock, which holds only chats, nil: the rules for any page. +/// - A zoomed chat fills the screen and the panes beside it are hidden, so the link never goes to +/// one of them: on the right it opens a new split (or column), which ends the zoom. +/// - The chat dock never splits (``ChatDockRules``): from it, with no pane on the right to take the +/// link, a new column opens beside the dock. +/// - A tab that cannot be moved (`movesTabs` false: a session-local tab, made when the machine's +/// daemon serves no browser tabs) never reaches a new split or column, so the link goes by the +/// rules for any page (nil) rather than opening unseen. +/// +/// Command-click keeps its meaning: the tab opens in the same target, in the background. Tabs can +/// still be dragged anywhere afterwards. `Pane` is the caller's pane identity (`LayoutPaneID` in the +/// app), so the rule runs without a window or a daemon. +nonisolated enum ChatLinkPlacement { + /// Where a link from the chat in pane `chat` goes, or nil for ``LinkPlacement/samePane`` in the + /// chat dock (`inChatDock`) or when the target needs a move that `movesTabs` false rules out. + /// `zoomed`: the chat's pane is zoomed. `right` lists the panes right of the chat, one group per column left to right (the chat's own + /// column first, with only its panes right of the chat), each group's panes on the column's right + /// edge first, top to bottom (``rightSide(of:in:)``). `takesTabs` tells which panes may get the + /// link; `recency` lists panes newest first. + static func target(_ setting: LinkPlacement, chat: Pane, right: [[Pane]], + zoomed: Bool = false, inChatDock: Bool = false, movesTabs: Bool = true, + takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement.Target? { + switch setting { + case .samePane: + return inChatDock ? nil : .pane(chat) + case .splitRight, .newColumn: + if !zoomed, let pane = pane(from: chat, right: right, takesTabs: takesTabs, recency: recency) { return .pane(pane) } + guard movesTabs else { return nil } + return setting == .newColumn || inChatDock ? .newColumn(rightOf: chat) : .splitRight(of: chat) + } + } + + /// The pane right of the chat that gets the link: of those that take tabs and are not the chat, + /// the most recently focused, else the rightmost column's first (its right edge, top first). + /// Nil when there is none. + static func pane(from chat: Pane, right: [[Pane]], takesTabs: (Pane) -> Bool, recency: [Pane]) -> Pane? { + let groups = right.map { $0.filter { $0 != chat && takesTabs($0) } } + let candidates = groups.flatMap { $0 } + return recency.first(where: candidates.contains) ?? groups.last(where: { !$0.isEmpty })?.first + } + + /// The panes along `node`'s right edge, top to bottom: a side-by-side split's right side, both + /// sides of a stacked one. + static func rightEdge(of node: SplitNode) -> [PaneID] { + switch node { + case let .leaf(pane): [pane] + case let .split(_, .horizontal, _, _, right): rightEdge(of: right) + case let .split(_, .vertical, _, top, bottom): rightEdge(of: top) + rightEdge(of: bottom) + } + } + + /// The panes of `node` right of `pane`: the right side of every side-by-side split whose left + /// side holds `pane`, in visual order. Empty when `node` does not hold `pane`. + static func panes(rightOf pane: PaneID, in node: SplitNode) -> [PaneID] { + switch node { + case .leaf: + return [] + case let .split(_, axis, _, a, b): + if a.contains(pane) { return panes(rightOf: pane, in: a) + (axis == .horizontal ? b.panes : []) } + return b.contains(pane) ? panes(rightOf: pane, in: b) : [] + } + } + + /// `right` as ``target(_:chat:right:zoomed:inChatDock:movesTabs:takesTabs:recency:)`` reads it, from the chat's screen's + /// `columns` in the order the person sees them (`ScreenLayout.visualColumns`). Top and bottom + /// docks are bands across the screen, not beside the chat: they are left out. Empty when no + /// column holds `chat`. + static func rightSide(of chat: PaneID, in columns: [LayoutColumn]) -> [[PaneID]] { + let beside = columns.filter { $0.dock?.edge.isBand != true } + guard let index = beside.firstIndex(where: { $0.root.contains(chat) }) else { return [] } + func edgeFirst(_ panes: [PaneID], _ column: LayoutColumn) -> [PaneID] { + let edge = column.trees.flatMap(rightEdge(of:)).filter(panes.contains) + return edge + panes.filter { !edge.contains($0) } + } + let own = beside[index].tree(containing: chat).map { panes(rightOf: chat, in: $0) } ?? [] + return [edgeFirst(own, beside[index])] + beside[(index + 1)...].map { edgeFirst($0.trees.flatMap(\.panes), $0) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift index 06ad4e16f86c..5e5a2a09fc36 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift @@ -12,6 +12,8 @@ import CmuxNextWakeups /// here (``LinkRouter/openInCmux``, ``openFile(_:from:)``, ``openFolder(_:from:)``), so no surface picks its own pane. /// What a page opens (a chat and its pull request link, Home, New Tab, a Markdown page) is a tab in its pane, never an /// unasked split (Leo 2026-10-20); a terminal's or browser's Cmd-click (`cmdClick`) goes by `layout.linkPlacement`. +/// A web link from an agent chat goes by `layout.linkPlacement` too (``ChatLinkPlacement``): on the right by default (the +/// right pane focused last, else the rightmost, else a new split), never over a chat or into a pane on its left (Leo 2026-10-21). /// An agent chat that is its screen's main pane (``MainPaneLayout``, `layout.mainPane`) never holds what it opens: that is a /// tab in the column on its right, or the lone chat docks left to make that column. /// @@ -72,7 +74,9 @@ final class LinkOpener { /// A source that takes no tabs (Home's pinned dock) finds the pane on its right by the layout's /// column order (``LinkOpenPlacement/paneRight(of:columns:takesTabs:recency:)``), not by drawn /// frames, which miss a strip column that is scrolled or not laid out yet. - func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true) -> LinkOpenPlacement.Target { + func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true, + chatLink: Bool = false) -> LinkOpenPlacement.Target { + if chatLink, let target = chatLinkTarget(from: source, mainPane: mainPane) { return target } if mainPane, let services, let side = MainPaneLayout.side(from: source, services: services) { switch side { case .tab(let pane): return .pane(pane) @@ -95,13 +99,48 @@ final class LinkOpener { .target(source: source.layoutPaneID, takesTabs: takesTabs, right: right) } + /// Where a web link from `source` goes when `source` shows an agent chat (``ChatLinkPlacement``, by + /// `layout.linkPlacement`): the pane right of the chat the person focused last, else the rightmost, + /// else a new split (or column) on its right. A pane that shows an agent chat never gets it. Tab + /// in the Same Pane: a tab in the chat's pane, with no docking. A zoomed chat opens a new split, + /// which ends the zoom, never a tab in a pane the zoom hides; the chat dock, which never splits, + /// opens a new column. Nil when `source` shows no chat or takes no tabs, for Tab in the Same Pane + /// in the chat dock, or when the link would need a new split or column but its machine serves no + /// daemon browser tabs (a session-local tab cannot be moved there): the rules above. A lone chat + /// under main pane layout still docks left (``MainPaneLayout``) for a link on the right, so it + /// lands in the column that slides in on its right. + func chatLinkTarget(from source: PaneController, mainPane: Bool = true) -> LinkOpenPlacement.Target? { + guard let services, source.stripModel.showsNewTabButton, let tab = source.selectedTab, + ChatColumnPlacement.isChat(tab, services: services), let content = source.workspace, + let screen = content.layoutModel.screen(containing: source.layoutPaneID) else { return nil } + let setting = placement() + let zoomed = content.workspace.screens.contains { $0.zoomedPane == source.pane.handle } + if setting != .samePane, !zoomed, mainPane, case .dockChat? = MainPaneLayout.side(from: source, services: services) { + return .dockChat(source.layoutPaneID) + } + let columns = screen.layout.columns.isEmpty + ? ChatColumnPlacement.columns(of: screen, containing: source.layoutPaneID) : screen.layout.visualColumns + let takesTabs: (LayoutPaneID) -> Bool = { id in + guard let pane = content.panes[id], pane.stripModel.showsNewTabButton else { return false } + return pane.selectedTab.map { !ChatColumnPlacement.isChat($0, services: services) } ?? true + } + return ChatLinkPlacement.target(setting, chat: source.layoutPaneID, + right: ChatLinkPlacement.rightSide(of: source.layoutPaneID, in: columns), + zoomed: zoomed, inChatDock: ChatDockRules.isChatDock(source.layoutPaneID, columns), + movesTabs: services.cache.browserTabs.isAvailable(in: source.pane), + takesTabs: takesTabs, recency: content.recentPanes) + } + /// Opens through `make` where the rule puts it. `background`: the opened tab takes no focus. /// `mainPane` false: main pane layout does not apply (a chat, which belongs in the dock, not - /// the column on its right). False with no source pane (no window) or when `make` refused. + /// the column on its right). `chatLink`: a web link, which from an agent chat goes on its + /// right (``chatLinkTarget(from:mainPane:)``). False with no source pane (no window) or when + /// `make` refused. @discardableResult - func open(from source: PaneController?, background: Bool = false, cmdClick: Bool = false, mainPane: Bool = true, _ make: Make) -> Bool { + func open(from source: PaneController?, background: Bool = false, cmdClick: Bool = false, mainPane: Bool = true, + chatLink: Bool = false, _ make: Make) -> Bool { guard let source = source ?? services?.windows.active?.focusedPane else { return false } - switch target(from: source, cmdClick: cmdClick, mainPane: mainPane) { + switch target(from: source, cmdClick: cmdClick, mainPane: mainPane, chatLink: chatLink) { case .pane(let id): return make(id == source.layoutPaneID ? source : source.workspace?.panes[id] ?? source, nil) case .splitRight: @@ -134,7 +173,7 @@ final class LinkOpener { let from = source ?? services?.windows.active?.focusedPane // The person's intent: an agent's open in flight (AgentBesidePlacement) yields to it. if !background { _ = from?.workspace?.beginFocusIntent() } - return open(from: from, background: background, cmdClick: cmdClick) { [weak self, weak from] pane, placed in + return open(from: from, background: background, cmdClick: cmdClick, chatLink: true) { [weak self, weak from] pane, placed in let here = pane === from && placed == nil let opener = here ? from?.selectedTab?.surface : nil let back = background || here ? nil : from diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift new file mode 100644 index 000000000000..8d333691b9e2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChatLinkPlacementTests.swift @@ -0,0 +1,150 @@ +@testable import CmuxNextApp +import CmuxNextLayout +import CmuxNextSettings +import Testing + +/// Leo 2026-10-21: "WHY DID THIS NOT OPEN TO THE SIDE?" A GitHub link clicked in an agent chat +/// opened as a tab in the chat's own pane, covering the chat. And: "if the left side has a tab, any +/// link opened also goes on the left instead of opening a new split", then "open on right by +/// default". ``ChatLinkPlacement`` decides where a web link from a chat goes, by +/// `layout.linkPlacement`: on the right (the right pane focused last, else the rightmost, else a new +/// split), never the chat's pane, another chat, a pane on its left or a pane a zoom hides; Tab in +/// the Same Pane keeps it in the agent pane. +struct ChatLinkPlacementTests { + private typealias Target = LinkOpenPlacement.Target + + /// `right`: the panes right of "chat", one group per column, left to right. + private static func target(_ right: [[String]], _ setting: LinkPlacement = .fallback, zoomed: Bool = false, + inChatDock: Bool = false, movesTabs: Bool = true, takesTabs: (String) -> Bool = { _ in true }, + recency: [String] = []) -> Target? { + ChatLinkPlacement.target(setting, chat: "chat", right: right, zoomed: zoomed, inChatDock: inChatDock, + movesTabs: movesTabs, takesTabs: takesTabs, recency: recency) + } + + @Test func onTheRightIsTheDefault() { + #expect(LinkPlacement.fallback == .splitRight) + #expect(Self.target([]) == .splitRight(of: "chat")) + #expect(Self.target([["opened"]]) == .pane("opened")) + } + + /// No pane right of the chat (alone, or with a browser on its left, which the caller never lists): + /// a new split on its right, never the left pane. + @Test func noRightPaneMakesANewRightSplit() { + #expect(Self.target([]) == .splitRight(of: "chat")) + #expect(Self.target([[]], recency: ["browser-on-the-left"]) == .splitRight(of: "chat")) + #expect(Self.target([], .newColumn) == .newColumn(rightOf: "chat")) + } + + /// Several panes on the right: the one the person focused last wins over the rightmost. + @Test func theFocusedRightSplitWinsOverTheRightmost() { + #expect(Self.target([["middle"], ["right"]], recency: ["chat", "middle", "right"]) == .pane("middle")) + #expect(Self.target([["top", "bottom"]], recency: ["chat", "bottom"]) == .pane("bottom")) + } + + /// None of the right panes was focused: the rightmost column, its right edge first, top first. + @Test func withNoFocusedRightPaneTheRightmostWins() { + #expect(Self.target([["middle"], ["right"]], recency: ["chat", "browser-on-the-left"]) == .pane("right")) + #expect(Self.target([["middle"], ["edge-top", "edge-bottom"]]) == .pane("edge-top")) + } + + /// A pane that takes no tabs (Home's pinned dock) or shows another agent chat is skipped. + @Test func panesThatTakeNoTabsOrShowAChatAreSkipped() { + let takes: (String) -> Bool = { !["pinned", "other-chat"].contains($0) } + #expect(Self.target([["strip"], ["pinned"]], takesTabs: takes) == .pane("strip")) + #expect(Self.target([["other-chat"]], takesTabs: takes, recency: ["other-chat"]) == .splitRight(of: "chat")) + } + + /// Whatever the layout, a link on the right never covers the chat, even when the chat is listed. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func theChatIsNeverTheTarget(_ setting: LinkPlacement) { + for right in [[], [["chat"]], [["chat", "r"]], [["r"], ["chat"]]] as [[[String]]] { + #expect(Self.target(right, setting, recency: ["chat"]) != .pane("chat")) + } + } + + /// Tab in the Same Pane: "in the agent pane", a tab in the chat's own pane whatever is on its + /// right or how it is zoomed, never docked or sent to main pane layout's column. + @Test func samePaneKeepsTheLinkInTheAgentPane() { + #expect(Self.target([["opened"]], .samePane) == .pane("chat")) + #expect(Self.target([], .samePane) == .pane("chat")) + #expect(Self.target([["opened"]], .samePane, zoomed: true, recency: ["opened"]) == .pane("chat")) + } + + /// The chat dock holds only chats, so Tab in the Same Pane there falls back to the rules for + /// any page (nil here) rather than a web tab the dock would refuse. + @Test func samePaneInTheChatDockFallsBackToThePageRules() { + #expect(Self.target([["opened"]], .samePane, inChatDock: true) == nil) + #expect(Self.target([["opened"]], inChatDock: true) == .pane("opened")) + } + + /// The chat dock never splits: from it, with every pane on the right showing another chat (or + /// none there), the link opens in a new column beside the dock, never a refused split that + /// would leave the tab over the chat. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func fromTheChatDockANewColumnNeverASplit(_ setting: LinkPlacement) { + let takes: (String) -> Bool = { !$0.hasSuffix("chat") } + #expect(Self.target([["other-chat"], ["third-chat"]], setting, inChatDock: true, takesTabs: takes) == .newColumn(rightOf: "chat")) + #expect(Self.target([], setting, inChatDock: true) == .newColumn(rightOf: "chat")) + #expect(Self.target([["other-chat"], ["browser"]], setting, inChatDock: true, takesTabs: takes) == .pane("browser")) + #expect(Self.target([["hidden"]], setting, zoomed: true, inChatDock: true) == .newColumn(rightOf: "chat")) + } + + /// A tab that cannot be moved (a session-local tab: the machine's daemon serves no browser tabs) + /// never needs a new split or column: the rules for any page (nil here) open it where it shows. + /// A pane on the right, or the chat's own pane, needs no move and still gets it. + @Test func aTabThatCannotMoveNeverNeedsANewSplit() { + #expect(Self.target([], movesTabs: false) == nil) + #expect(Self.target([], .newColumn, movesTabs: false) == nil) + #expect(Self.target([["hidden"]], zoomed: true, movesTabs: false) == nil) + #expect(Self.target([], inChatDock: true, movesTabs: false) == nil) + #expect(Self.target([["opened"]], movesTabs: false) == .pane("opened")) + #expect(Self.target([], .samePane, movesTabs: false) == .pane("chat")) + } + + /// A zoomed chat fills the screen and hides the panes beside it: the link opens a new split (or + /// column) on its right, which ends the zoom, never a tab in a hidden pane. + @Test func aZoomedChatOpensANewSplitNotAHiddenPane() { + #expect(Self.target([["hidden"]], zoomed: true, recency: ["hidden"]) == .splitRight(of: "chat")) + #expect(Self.target([["hidden"], ["also-hidden"]], .newColumn, zoomed: true) == .newColumn(rightOf: "chat")) + #expect(Self.target([], zoomed: true) == .splitRight(of: "chat")) + } + + /// Right edges come from the split tree: a side-by-side split's right side, both halves of a + /// stacked one. + @Test func theRightEdgeOfASplitTree() { + let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.5, a: .leaf("a"), + b: .split("s2", axis: .vertical, ratio: 0.5, a: .leaf("b"), + b: .split("s3", axis: .horizontal, ratio: 0.5, a: .leaf("c"), b: .leaf("d")))) + #expect(ChatLinkPlacement.rightEdge(of: tree) == ["b", "d"]) + #expect(ChatLinkPlacement.rightEdge(of: .leaf("a")) == ["a"]) + } + + /// In the chat's own split tree, only panes beside it on its right count: not the left side, + /// not panes stacked above or below it. + @Test func panesRightOfTheChatInItsTree() { + // [left | [chat / below] | right] + let tree = SplitNode.split("s1", axis: .horizontal, ratio: 0.3, a: .leaf("left"), + b: .split("s2", axis: .horizontal, ratio: 0.5, + a: .split("s3", axis: .vertical, ratio: 0.5, a: .leaf("chat"), b: .leaf("below")), + b: .leaf("right"))) + #expect(ChatLinkPlacement.panes(rightOf: "chat", in: tree) == ["right"]) + #expect(ChatLinkPlacement.panes(rightOf: "right", in: tree).isEmpty) + #expect(ChatLinkPlacement.panes(rightOf: "left", in: tree) == ["chat", "below", "right"]) + } + + /// The screen's columns: a browser column left of the chat is never on its right; a bottom band + /// is not beside it; the chat's own column contributes only what is right of the chat. + @Test func theRightSideOfAScreen() { + let columns = [ + LayoutColumn(id: "browser", root: .leaf("browser")), + LayoutColumn(id: "chat", root: .split("s1", axis: .horizontal, ratio: 0.5, a: .leaf("chat"), b: .leaf("beside"))), + LayoutColumn(id: "band", root: .leaf("band"), dock: DockColumn(edge: .bottom)), + LayoutColumn(id: "strip", root: .split("s2", axis: .horizontal, ratio: 0.5, a: .leaf("inner"), b: .leaf("edge"))), + ] + #expect(ChatLinkPlacement.rightSide(of: "chat", in: columns) == [["beside"], ["edge", "inner"]]) + #expect(ChatLinkPlacement.rightSide(of: "missing", in: columns).isEmpty) + let right = ChatLinkPlacement.rightSide(of: "chat", in: columns) + #expect(ChatLinkPlacement.target(.splitRight, chat: "chat", right: right, takesTabs: { _ in true }, + recency: ["browser", "chat"]) == .pane("edge")) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift new file mode 100644 index 000000000000..f20a373ed80b --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/LinkOpenerChatLinkTests.swift @@ -0,0 +1,164 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextSettings +import Foundation +import Testing + +/// ``LinkOpener/target(from:cmdClick:mainPane:chatLink:)`` for a web link from an agent chat, on a +/// real workspace (Leo 2026-10-21: "WHY DID THIS NOT OPEN TO THE SIDE?", "open on right by +/// default"). On the right by default; Tab in the Same Pane keeps it in the chat's pane; a zoomed +/// chat opens a new split rather than a tab in the pane its zoom hides; the chat dock, which never +/// splits, opens a new column; a machine whose daemon serves no browser tabs (a session-local tab, +/// which cannot be moved) keeps the rules for any page, so the tab still opens selected. +@MainActor struct LinkOpenerChatLinkTests { + /// The chat's surface; every other pane shows `others`. + private static let chatSurface = SurfaceID(rawValue: 1) + + private static func chat(_ surface: SurfaceID, session: String) -> TabSnapshot { + var tab = TabSnapshot(surface: surface, kind: .conversation, cwd: nil) + tab.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: "install:test", session: session, harness: "codex")) + return tab + } + + /// The layouts under test: pane 1 shows the chat, pane 2 a terminal or another chat. + enum Shape { + /// chat | terminal. + case chatThenTerminal(zoomed: Bool) + /// terminal | chat: nothing on the chat's right. + case terminalThenChat + /// The chat in the chat dock, another chat in the column on its right. + case chatDockBesideAnotherChat + } + + private static func tree(_ shape: Shape) -> DaemonTree { + let chatPane = PaneSnapshot(id: PaneID(rawValue: 1), tabs: [chat(chatSurface, session: "chat-1")]) + let terminal = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [TabSnapshot(surface: SurfaceID(rawValue: 2), title: "~")]) + let screen: ScreenSnapshot + switch shape { + case .chatThenTerminal(let zoomed): + let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(chatPane.id), b: .leaf(terminal.id)) + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), zoomedPane: zoomed ? chatPane.id : nil, layout: layout, panes: [chatPane, terminal]) + case .terminalThenChat: + let layout: LayoutNode = .split(id: nil, resourceID: nil, direction: .right, ratio: 0.5, a: .leaf(terminal.id), b: .leaf(chatPane.id)) + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), layout: layout, panes: [terminal, chatPane]) + case .chatDockBesideAnotherChat: + let other = PaneSnapshot(id: PaneID(rawValue: 2), tabs: [chat(SurfaceID(rawValue: 2), session: "chat-2")]) + let dock = DockSnapshot(edge: .left, mode: .docked, role: .agentChat) + let columns = [ColumnSnapshot(id: 10, width: 0.4, layout: .leaf(chatPane.id), dock: dock), + ColumnSnapshot(id: 11, width: 0.6, layout: .leaf(other.id))] + screen = ScreenSnapshot(id: ScreenID(rawValue: 1), layout: .leaf(chatPane.id), columns: columns, panes: [chatPane, other]) + } + return DaemonTree(workspaces: [WorkspaceSnapshot(id: WorkspaceHandle(rawValue: 1), key: nil, name: "w1", screens: [screen])]) + } + + private static func connected() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"link-opener-chat-link-test","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + /// The workspace shown in a borderless window, laid out. `servesTabs`: the daemon serves + /// browser tabs (`frontend-browser-tabs-v1`), so a new tab can be moved. + private static func shown(_ shape: Shape, servesTabs: Bool = true) throws -> (AppServices, NSWindow, WorkspaceContentController) { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + services.cache.browserTabs.serves = { _ in servesTabs } + let store = services.daemon.store + store.connectionState = .connected(try Self.connected()) + store.apply(snapshot: Self.tree(shape)) + let workspace = try #require(store.workspaces.first) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"w1","workspace_key":"w1"}"#.utf8)) + let state = WindowState(record: record) + let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 1200, height: 700), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let content = WorkspaceContentController(workspace: workspace, daemon: services.daemon, services: services, state: state) + content.contentView.frame = NSRect(x: 0, y: 0, width: 1200, height: 700) + window.contentView = content.contentView + window.orderFrontRegardless() + content.applyCurrent() + content.contentView.layoutSubtreeIfNeeded() + return (services, window, content) + } + + /// The chat's pane and the other pane, by the surface each shows. + private static func panes(_ content: WorkspaceContentController) throws -> (chat: PaneController, other: PaneController?) { + let controllers = Array(content.panes.values) + let chat = try #require(controllers.first { $0.selectedTab?.surface == Self.chatSurface }, "the chat's pane is not shown") + return (chat, controllers.first { $0 !== chat }) + } + + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func aChatLinkOpensInThePaneOnItsRight(_ placement: LinkPlacement) throws { + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: false)) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, terminal) = try Self.panes(content) + let right = try #require(terminal) + services.linkOpener.placement = { placement } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .pane(right.layoutPaneID)) + // The terminal shows no chat: its links keep the rules for any pane. + #expect(services.linkOpener.chatLinkTarget(from: right) == nil) + } + + @Test func tabInTheSamePaneKeepsAChatLinkInTheChatsPane() throws { + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: false)) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .samePane } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .pane(chat.layoutPaneID)) + } + + /// The zoomed chat hides the terminal: the link opens a new split on its right (which ends the + /// zoom), never a tab in the hidden terminal. + @Test func aZoomedChatLinkOpensANewSplitNotTheHiddenPane() throws { + let (services, window, content) = try Self.shown(.chatThenTerminal(zoomed: true)) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .splitRight } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .splitRight(of: chat.layoutPaneID)) + services.linkOpener.placement = { .newColumn } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .newColumn(rightOf: chat.layoutPaneID)) + } + + /// The chat dock never splits (TabMoves refuses it, and the tab would stay over the chat): with + /// only another chat on its right, the link opens a new column beside the dock. + @Test(arguments: [LinkPlacement.splitRight, .newColumn]) + func fromTheChatDockWithOnlyChatsOnTheRightANewColumn(_ placement: LinkPlacement) throws { + let (services, window, content) = try Self.shown(.chatDockBesideAnotherChat) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { placement } + #expect(services.linkOpener.target(from: chat, chatLink: true) == .newColumn(rightOf: chat.layoutPaneID)) + } + + /// Nothing on the chat's right needs a new split; with no daemon browser tabs the session-local + /// tab could not be moved there and would open unseen, so the link keeps the rules for any page. + @Test func withoutDaemonBrowserTabsAChatLinkKeepsThePageRules() throws { + do { + let (services, window, content) = try Self.shown(.terminalThenChat) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + #expect(services.linkOpener.target(from: chat, chatLink: true) == .splitRight(of: chat.layoutPaneID)) + } + let (services, window, content) = try Self.shown(.terminalThenChat, servesTabs: false) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + #expect(services.linkOpener.chatLinkTarget(from: chat) == nil) + #expect(services.linkOpener.target(from: chat, chatLink: true) == services.linkOpener.target(from: chat)) + } + + /// The real open on that machine: the session-local tab is made and selected, as before chat + /// links went to the right, not left unselected in the chat's pane waiting for a move. + @Test func withoutDaemonBrowserTabsAChatLinkOpensSelected() throws { + let (services, window, content) = try Self.shown(.terminalThenChat, servesTabs: false) + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + let (chat, _) = try Self.panes(content) + services.linkOpener.placement = { .splitRight } + let url = try #require(URL(string: "https://example.com/pull/1")) + #expect(services.linkOpener.openWeb(url, from: chat, background: false)) + let state = try #require(chat.state) + let local = try #require(state.localBrowserTabs[chat.paneKey].first, "no session-local tab opened") + #expect(chat.stripModel.selectedID == StripTabID(local.id)) + } +} diff --git a/release-notes/next/highlights/agent-chat-links-open-to-the-side.md b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md new file mode 100644 index 000000000000..3aa5702361e2 --- /dev/null +++ b/release-notes/next/highlights/agent-chat-links-open-to-the-side.md @@ -0,0 +1,5 @@ +title: Links from an agent chat open beside it +category: improved +docs: https://cmux.com/docs/configuration + +A link you click in an agent chat opens on the right, in the right pane you used last or a new split, never over the chat or on its left. Command-click opens it in the background, and Open Links and Files changes where it goes.