Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pane: Equatable>(_ setting: LinkPlacement, chat: Pane, right: [[Pane]],
zoomed: Bool = false, inChatDock: Bool = false, movesTabs: Bool = true,
takesTabs: (Pane) -> Bool, recency: [Pane]) -> LinkOpenPlacement<Pane>.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<Pane: Equatable>(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) }
}
}
49 changes: 44 additions & 5 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<LayoutPaneID>.Target {
func target(from source: PaneController, cmdClick: Bool = false, mainPane: Bool = true,
chatLink: Bool = false) -> LinkOpenPlacement<LayoutPaneID>.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)
Expand All @@ -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<LayoutPaneID>.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:
Expand Down Expand Up @@ -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
Expand Down
Loading