Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c4f079a
test: open-beside live check and a links fixture for the chat
teamleaderleo Oct 14, 2026
a71af7e
CmuxNext: a chat's clicks open beside it, in one pane (open beside)
teamleaderleo Oct 14, 2026
9e9c210
test: open-beside re-click check needs both pages open first
teamleaderleo Oct 14, 2026
260e7f3
CmuxNext: ChatBeside imports CmuxNextBridge for StripTabID
teamleaderleo Oct 14, 2026
70a8633
Merge commit '2e420aa7a9be6ac9b79d6a66a5d888f3f77e6716' into leo/open…
teamleaderleo Oct 14, 2026
4867699
test: open-beside re-click selects the tab; a file the chat's pane sh…
teamleaderleo Oct 14, 2026
73e5ac3
CmuxNext: open beside never no-ops, reads the click's Command key, ma…
teamleaderleo Oct 14, 2026
89e88ae
Merge commit '55e171d0607207fb3709e529ecca999e16ed426d' into leo/open…
teamleaderleo Oct 14, 2026
af7aa45
Merge commit '705b5d752d18f4fadefa6e67da6cc3642220de88' into leo/open…
teamleaderleo Oct 14, 2026
70c9413
CmuxNext: open beside skips a reconnecting daemon, so the click takes…
teamleaderleo Oct 14, 2026
12e48d7
CmuxNext: mark AgentPaneLinkClick a reviewed namespace for the conven…
teamleaderleo Oct 14, 2026
ad937bd
Merge commit 'cc182fe9cca56368d16f07e4233b80254b64de95' into leo/open…
teamleaderleo Oct 14, 2026
5b0c60d
chore(next): PR autofix at ad937bd6ee84
Oct 14, 2026
ab70660
test: open beside in a real chat (fake harness): the chat docks left …
teamleaderleo Oct 14, 2026
5dd07f6
Merge remote-tracking branch 'origin/leo/open-beside' into leo/open-b…
teamleaderleo Oct 14, 2026
dc3f0e2
Merge commit '879f790e0a24b0018a57ad69a952f824ae3f7f1b' into leo/open…
teamleaderleo Oct 14, 2026
24f8b66
Merge commit 'ec26b8979e749f872cf70631ecccccb3b2f6b0ee' into leo/open…
teamleaderleo Oct 14, 2026
266334c
test: real-chat e2e finds the build's acpmux home under ~/.acpmux/builds
teamleaderleo Oct 14, 2026
86825fa
Merge commit 'cca17ef20eb5df1f56a7e972cfc225901b594285' into leo/open…
teamleaderleo Oct 14, 2026
c5309e7
Merge commit '9e21a5f262bbb7b38fc4dbc63c135e1262fe2235' into leo/open…
teamleaderleo Oct 14, 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
@@ -1,4 +1,4 @@
import Foundation
public import AppKit

extension AgentPaneSource {
/// Whether `url` is the page document itself, the only one the pane's main frame shows: the
Expand Down Expand Up @@ -30,3 +30,11 @@ extension AgentPaneNavigation {
return true
}
}

/// The modifiers of the link click being opened: `AgentPaneView.openURL` runs inside the click, so
/// the open can tell a Command-click from a plain one (ChatBeside). Nil outside a click (Open Link,
/// a page's own open).
// lint:allow namespace: the click's context, set and cleared around one open, read across modules.
@MainActor public enum AgentPaneLinkClick {
public internal(set) static var modifiers: NSEvent.ModifierFlags?
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ final class AgentPaneNavigation: NSObject, WKNavigationDelegate {
case .allow:
decisionHandler(.allow)
case .openOutside(let url):
AgentPaneLinkClick.modifiers = action.modifierFlags
_ = Self.openOutside(url, gestures: view.model.transport.gestures, open: view.openURL)
AgentPaneLinkClick.modifiers = nil
decisionHandler(.cancel)
case .cancel:
decisionHandler(.cancel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ final class AgentChatSplitToggles {
.filter { self.opened[$0].id != Self.splitDown }
.compactMap { splits[$0] }
.first { Self.beside(chat, $0, in: store) }
// A diff with no split of the header's beside the chat joins the pane a chat's clicks open in (ChatBeside).
?? (id == Self.changes ? store.tab(id: chat).flatMap { store.pane(containing: $0.surface) }
.flatMap { ChatBeside.besidePane(of: $0, in: store)?.tabs.first } : nil)
// Open mode adds one per click: keep the recent ones, enough to find the last split beside the chat.
opened = Array(opened.suffix(Self.remembered - 1))
opened.append((id: id, baseline: Set(tabs.map(\.id))))
Expand Down
10 changes: 10 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CmuxNextActions
import AppKit
import CmuxNextAgentPane
import CmuxNextCompat
import CmuxNextWakeups
import Foundation

Expand Down Expand Up @@ -34,8 +35,17 @@ struct AgentTabContent {
// Set on each show, so a tab moved to another pane opens files there.
view.model.onOpenFile = { [weak pane] url, target in
guard let pane else { return false }
// A file ref's page opens beside the chat (ChatBeside); Command-click, a new tab here.
if target == .tab, !ChatBeside.keepsOldPlace, ChatBeside(services: pane.services).open(.file(url), beside: key) { return true }
return pane.services.registry.openAgentFile(path: url.path, target: target, pane: pane.paneKey)
}
// A link, a PR's among them, opens in the in-app browser beside the chat; Command-click, the
// default browser, as every link did before.
view.openURL = { [weak pane] url in
if ChatBeside.keepsOldPlace || pane.map({ ChatBeside(services: $0.services).open(.url(url), beside: key) }) != true {
NSWorkspace.shared.openInBackground(url)
}
}
view.model.onOpenPreview = { [weak pane] url in
guard let pane else { return false }
return pane.services.registry.openAgentPreview(url, pane: pane.paneKey)
Expand Down
93 changes: 93 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/ChatBeside.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import AppKit
import CmuxNextActions
import CmuxNextAgentPane
import CmuxNextBridge
import CmuxNextDaemon

/// Open beside (Leo 2026-10-14): what a click in a chat opens goes to the pane right of the chat,
/// the in-app way. The first click with no pane there opens one (a new column right of the chat,
/// as an agent's own browser does, AgentBesidePlacement); every later click adds a tab to that same
/// pane, never another split; a click on what a tab there already shows selects that tab. Keyboard
/// focus stays in the chat. Command-click keeps the old place (``keepsOldPlace``).
///
/// The one entry point for the chat's surfaces: links and PR links (`AgentPaneView.openURL`) and
/// file refs (`onOpenFile`); the header's Changes diff joins the same pane (AgentChatSplitToggles).
@MainActor
struct ChatBeside {
/// What a click opens.
enum Entity: Equatable {
/// A web page, in the in-app browser.
case url(URL)
/// A file, on its file page.
case file(URL)
}

let services: AppServices

/// Whether the click that is being handled asks for the old place: Command held. A link click
/// carries its own modifiers; a file ref's arrives later, so the keys held now stand in.
static var keepsOldPlace: Bool { (AgentPaneLinkClick.modifiers ?? NSEvent.modifierFlags).contains(.command) }

/// Opens `entity` beside chat tab `chat` (a provisional key resolves). False when nothing opened
/// or was selected (the chat is in no pane this app knows, or the page cannot go beside it); the
/// caller then opens it the old way.
@discardableResult
func open(_ entity: Entity, beside chat: String) -> Bool {
let chat = services.agentTabs.resolve(chat)
guard let (_, chatPane) = services.locateTab(chat) else { return false }
let beside = Self.besidePane(of: chatPane, in: services.daemon(for: chatPane).store)
// A tab of the pane right of the chat names it; with none, the chat's tab with a new column.
let target = beside.flatMap { $0.tabs.first?.id }
var invocation = ActionInvocation(target: ActionTargetRef(kind: .tab, id: target ?? chat), origin: .user)
invocation.besideCaller = true
invocation.newColumnBeside = target == nil
switch entity {
case .url(let url):
if let beside, let shown = beside.tabs.first(where: { Self.shows($0, url) }) {
// Shown already: select it there; the chat keeps focus.
guard let pane = services.paneController(for: beside) else { return false }
pane.select(StripTabID(shown.id))
return true
}
invocation.arguments = ["url": .string(url.absoluteString)]
return services.registry.perform("openBrowser", invocation: invocation)
case .file(let url):
// A file the browser previews (an image, a PDF) keeps its old place.
guard let kind = FilePageOpener.kind(for: url) else { return false }
let pages = kind == .markdown ? services.viewers.markdownPages : services.viewers.editorPages
if let shown = beside.flatMap(services.paneController(for:)), pages.selectShown(url, in: shown) { return true }
// Only a store tab moves beside the chat; else the old place.
guard let pane = services.paneController(for: beside ?? chatPane), pages.opensStoreTabs(in: pane) else { return false }
let placement = invocation
return services.viewers.openFile(url, in: pane, userChose: true,
beside: { AgentBesidePlacement.placed(placement, in: pane, then: nil) }) == nil
}
}

/// The first pane of the scrolling column right of `chat`'s column: where its clicks open. A dock
/// never takes them, and a chat in the last column has none (its first click makes one).
static func besidePane(of chat: PaneModel, in store: DaemonStore) -> PaneModel? {
guard let screen = store.workspace(containing: chat.handle)?.screens.first(where: { $0.panes.contains { $0 === chat } }),
let index = screen.columns.firstIndex(where: { $0.layout.paneIDs.contains(chat.handle) }),
index + 1 < screen.columns.count else { return nil }
let next = screen.columns[index + 1]
guard next.dock == nil, let first = next.layout.paneIDs.first else { return nil }
return screen.panes.first { $0.handle == first }
}

/// Whether browser tab `tab` shows `url`: the same page at the same anchor (a link to another
/// line of a file opens its own tab), whatever the scheme's and host's case or a trailing slash.
static func shows(_ tab: TabModel, _ url: URL) -> Bool {
guard tab.kind == .browser, let shown = tab.url.flatMap(URL.init(string:)).flatMap(comparable) else { return false }
return shown == comparable(url)
}

private static func comparable(_ url: URL) -> String? {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
parts.scheme = parts.scheme?.lowercased()
parts.host = parts.host?.lowercased()
while parts.path.count > 1, parts.path.hasSuffix("/") { parts.path.removeLast() }
if parts.path.isEmpty, parts.host != nil { parts.path = "/" }
return parts.string
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import WebKit
/// one). Never changes app or window focus; `open_menu` moves focus inside
/// the page to the menu's button, as a click does.
///
/// `action`: `seed_rows` (`count`, default 5000; `fixture: "worked-turn"`
/// `action`: `seed_rows` (`count`, default 5000; `fixture: "links"` seeds a reply with two links and a path; `fixture: "worked-turn"`
/// seeds a turn that edits three files instead, for the changes view), `fling` (`seconds`,
/// default 3; `nominal_ms`; `wait` returns the stats when the fling ends),
/// `fling_stats`, `perf_stats` (`raw` adds every frame), `load_seed` (`turns`, default 5000:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import CmuxNextAgentPane
import CmuxNextBrowser
import typealias CmuxNextDaemon.SurfaceID

/// The file viewer seam (R89 ``FileOpening``) for the file pages: every file open (Open File...,
/// the cmux picker, `file.open` and `cmux file open`, Finder drops on a file page, a followed link
Expand Down Expand Up @@ -38,15 +39,16 @@ final class FilePageOpener: FileOpening {
}

/// `userChose` false for an agent's or a script's open (`cmux file open`): the document shows,
/// and is writable only inside a root the user chose.
func open(_ file: URL, in pane: PaneController?, userChose: Bool) -> String? {
/// and is writable only inside a root the user chose. `beside`: a chat's click opening the page
/// beside it (ChatBeside): the tab opens unfocused and `beside` places it.
func open(_ file: URL, in pane: PaneController?, userChose: Bool, beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String? {
guard let url = AgentPaneFileOpen.resolve(file.path) else { return FilePageStrings.notAFile }
guard let pane = pane ?? services.windows.active?.focusedPane else { return FilePageStrings.noPane }
switch Self.kind(for: url) {
case .markdown?:
services.viewers.markdownPages.open(url, in: pane, focus: true, userChose: userChose)
services.viewers.markdownPages.open(url, in: pane, focus: beside == nil, userChose: userChose, beside: beside)
case .editor?:
services.viewers.editorPages.open(url, in: pane, focus: true, userChose: userChose)
services.viewers.editorPages.open(url, in: pane, focus: beside == nil, userChose: userChose, beside: beside)
case nil:
guard AgentPaneFileOpen.showsInTab(url) else { return FilePageStrings.notAFile }
pane.newBrowserTab(url: url, engine: Self.tabEngine(for: url))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import CmuxNextBridge
import CmuxNextDaemon
import Foundation

/// A chat's click on a file opens its page beside the chat (ChatBeside): in the pane right of the
/// chat, where a tab that already shows the file is selected and no keyboard focus moves.
extension FilePageService {
/// Selects the tab of `pane` that shows `file`, moving no keyboard focus. False when no tab of
/// `pane` shows it.
func selectShown(_ file: URL, in pane: PaneController) -> Bool {
let real = file.standardizedFileURL.resolvingSymlinksInPath()
let pages = pane.services.pages
let keys = pages.tabIDs(in: pane.paneKey) + pane.pane.tabs.compactMap { pages.storeKey(of: $0.id) }
if let shown = keys.first(where: { self.file($0) == real }) {
pane.select(pages.stripID(showing: shown, in: pane) ?? StripTabID(shown))
} else if let unshown = pane.pane.tabs.first(where: { $0.page == kind.page.rawValue && $0.pageRoute == real.path }) {
pane.select(StripTabID(unshown.id))
} else {
return false
}
return true
}

/// Whether a new tab of this page in `pane` is a store tab, the only kind a placement beside a
/// chat can move. A reconnecting daemon makes none, so the click takes the old path.
func opensStoreTabs(in pane: PaneController) -> Bool {
guard case .connected = pane.daemon.store.connectionState,
pane.daemon.supports(DaemonCapabilities.shared.pageTabs) else { return false }
return pane.services.windowController(showing: pane) != nil && pane.daemon.isLocal && pane.services.pages.keepsRoutes(pane.daemon)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CmuxNextPages
import CmuxNextSettings
import Foundation
import CmuxNextCompat
import typealias CmuxNextDaemon.SurfaceID

/// Owns one file page's tabs (diff-host S6 `cmux.markdown`, S7 `cmux.editor`): pane tabs, one
/// ``FilePageProvider`` and page view per tab, one shared look per page (its settings section,
Expand Down Expand Up @@ -94,12 +95,15 @@ final class FilePageService: InternalPageProvider {
func file(_ key: String) -> URL? { tabs[key]?.provider?.file ?? tabs[key]?.file }

/// Opens `file` in a tab after `pane`'s selected tab, or selects the tab of `pane`'s window that
/// already shows it. A user run (`focus`) selects and focuses it.
/// already shows it. A user run (`focus`) selects and focuses it. `beside` makes the placement
/// of a new store tab beside a chat (ChatBeside, AgentBesidePlacement): that open always makes
/// a tab, as ChatBeside looks for one in the pane beside the chat itself (``selectShown(_:in:)``).
@discardableResult
func open(_ file: URL, in pane: PaneController, focus: Bool, userChose: Bool = true, recoveredText: String? = nil) -> String {
func open(_ file: URL, in pane: PaneController, focus: Bool, userChose: Bool = true, recoveredText: String? = nil,
beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String {
let real = file.standardizedFileURL.resolvingSymlinksInPath()
if userChose { services.viewers.recents.record(real, as: kind.recents) }
let key = show(in: pane, focus: focus, Tab(file: real, userChose: userChose, recoveredText: recoveredText)) { key in
let key = show(in: pane, focus: focus, Tab(file: real, userChose: userChose, recoveredText: recoveredText), beside: beside) { key in
self.file(key) == real
}
// The user opened a file a tab already shows (perhaps one an agent opened): it is theirs now.
Expand Down Expand Up @@ -161,9 +165,10 @@ final class FilePageService: InternalPageProvider {
/// (page-tabs-v1), on its file as the route (page-tab-route-v1), so it
/// moves like any daemon tab and Reopen Closed brings it back on that
/// file; else as an app-only tab.
private func show(in pane: PaneController, focus: Bool, _ tab: Tab, matching: (String) -> Bool) -> String {
private func show(in pane: PaneController, focus: Bool, _ tab: Tab, beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil,
matching: (String) -> Bool) -> String {
let window = services.windowController(showing: pane)
for candidate in window?.content?.panes.values.map({ $0 }) ?? [pane] {
for candidate in beside != nil ? [] : window?.content?.panes.values.map({ $0 }) ?? [pane] {
let keys = services.pages.tabIDs(in: candidate.paneKey) + candidate.pane.tabs.compactMap { services.pages.storeKey(of: $0.id) }
if let shown = keys.first(where: { tabs[$0] != nil && matching($0) }) {
if focus { reveal(shown, in: candidate) }
Expand All @@ -185,7 +190,7 @@ final class FilePageService: InternalPageProvider {
// A store tab only on this Mac's daemon that keeps the route: a file path of this Mac never
// goes to another machine's daemon, and a tab without its route could not come back.
if let window, pane.daemon.isLocal, services.pages.keepsRoutes(pane.daemon),
services.pages.openStoreTab(kind.page, in: pane, window: window, focus: focus, key: key) != nil {
services.pages.openStoreTab(kind.page, in: pane, window: window, focus: focus, key: key, created: beside?()) != nil {
return key
}
services.pages.open(kind.page, in: pane.paneKey, of: pane.daemon.store, after: pane.stripModel.selectedID?.rawValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ final class ViewerService {

/// Opens `file` through ``fileOpener`` (the markdown page, a previewing tab, or the editor).
/// `userChose` false for a page's or an agent's open. Returns why it did not open.
func openFile(_ file: URL, in pane: PaneController?, userChose: Bool) -> String? {
if let opener = fileOpener as? FilePageOpener { return opener.open(file, in: pane, userChose: userChose) }
func openFile(_ file: URL, in pane: PaneController?, userChose: Bool,
beside: (@MainActor () -> @MainActor (SurfaceID) -> Void)? = nil) -> String? {
if let opener = fileOpener as? FilePageOpener { return opener.open(file, in: pane, userChose: userChose, beside: beside) }
return fileOpener.open(file, in: pane)
}

Expand Down
Loading
Loading