Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 20 additions & 6 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,34 @@ extension AgentTabStore {

private func openSubagent(_ agent: String, of session: String, focus: Bool, beside provisional: String) -> Bool { // the tracker (AgentSubagentPanes) decides; the app opens and moves the tab
AgentSubagentPanes.session(session).open(agent, focus: focus, chat: provisional, hooks: .init(isOpen: { [weak self] in self?.locate($0) != nil }, focus: { [weak self] key, script in _ = self?.actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: self?.resolve(key) ?? key), arguments: script ? ["focus": .bool(true)] : [:], origin: script ? .script : .user)) },
place: { [weak self] anchor, down, created, finished in self?.openBeside(anchor, direction: down ? "down" : "right", origin: focus ? .user : .script, main: provisional, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: session) },
place: { [weak self] anchor, down, created, finished in self?.openBeside(anchor, direction: down ? "down" : "right", origin: focus ? .user : .script, main: provisional, sessionPlacement: true, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: session) },
created: { [weak self] in created($0); self?.revealTurn(AgentSubagentPanes.reveal(agent), in: $0) }, finished: finished) ?? false }, again: { [weak self] request in _ = self?.openSubagent(request.agent, of: session, focus: request.focus, beside: request.chat) })) }
/// A tab in chat tab `provisional`'s pane, then moved to a new split on its right when `split`; else a tab there, focused when `created` is set.
/// A split asked for chat `main` when that chat is its screen's main pane (``openInMainPane``) is a tab in the column on its right instead.
@discardableResult
func openBeside(_ provisional: String, split: Bool = true, direction: String = "right", origin: ActionOrigin = .user, main: String? = nil, open: (PaneID, DaemonService) throws -> AgentTabPending, created: (@MainActor (String) -> Void)? = nil, finished: (@MainActor (_ moved: Bool) -> Void)? = nil) -> Bool {
if split, let main, let placed = openInMainPane(resolve(main), origin == .user, open, created, finished) { return placed }
guard let (pane, daemon) = locate(resolve(provisional)), let pending = try? open(pane, daemon) else { return false }
func openBeside(_ provisional: String, split: Bool = true, direction: String = "right", origin: ActionOrigin = .user, main: String? = nil, sessionPlacement: Bool = false, open: (PaneID, DaemonService) throws -> AgentTabPending, created: (@MainActor (String) -> Void)? = nil, finished: (@MainActor (_ moved: Bool) -> Void)? = nil) -> Bool {
if split, !sessionPlacement, let main, let placed = openInMainPane(resolve(main), origin == .user, open, created, finished) { return placed }
guard let (source, daemon) = locate(resolve(provisional)) else { return false }
// Documents opened beside a chat reuse the existing top-right pane. The pane snapshot's
// selected tab remains the insertion anchor, so Session Info and header actions follow the
// same rule as a transcript link. A missing right pane keeps the existing split path.
let reuseSessionArea = sessionPlacement && main.map { resolve(provisional) != resolve($0) } ?? false
let existingRight = split && !reuseSessionArea ? rightPane(resolve(provisional)) : nil
let pane = reuseSessionArea ? source : existingRight ?? source
guard let pending = try? open(pane, daemon) else { return false }
guard split || created != nil else { return true }
// task-owner: one tab creation, then one move (or, not `split`, one focus) of that tab
Task { [weak self] in
guard let tab = try? await pending.value(), let self, await TabMirror.reaches(tab.key, in: locate) else { finished?(false); return }; created?(tab.key)
guard split else { _ = actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), origin: origin)); finished?(false); return }
_ = actionRegistry?.perform("tab.moveToNewSplit", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), arguments: ["direction": .string(direction), "keep_focus": .bool(origin == .script)], origin: origin)) // script: an unfocused subagent open
guard split, !reuseSessionArea && (existingRight == nil || sessionPlacement) else {
_ = actionRegistry?.perform("tab.focus", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), origin: origin))
finished?(false)
return
}
// A first session is opened in the top-right pane, then split below it. Later sessions
// use their newest bottom-right pane as the anchor, so this is the one session split.
let splitDirection = sessionPlacement && existingRight != nil ? "down" : direction
_ = actionRegistry?.perform("tab.moveToNewSplit", invocation: ActionInvocation(target: ActionTargetRef(kind: .tab, id: tab.key), arguments: ["direction": .string(splitDirection), "keep_focus": .bool(origin == .script)], origin: origin)) // script: an unfocused subagent open
if let finished { finished(await TabMirror.reaches(tab.key, in: { [weak self] in self?.locate($0).flatMap { $0.pane != pane ? $0 : nil } })) } // stacking waits for the move
}
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ extension AgentTabStore {
guard let id = try? services.cloud.localDeviceID() else { services.daemon.logger.error("agent tabs: no install id, new agent tabs are refused"); return nil }
return AgentSessionRef.host(installID: id)
}
tabs.rightPane = { [weak services] key in
guard let services, let (_, source) = services.locateTab(key), let controller = services.paneController(for: source),
case .pane(let id) = services.linkOpener.target(from: controller, mainPane: false), id != controller.layoutPaneID else { return nil }
return id
}
tabs.openProcess = { [weak services] chat, viewer in
guard let services else { return false }
return AgentProcessTerminal(services: services).open(viewer, beside: chat)
Expand Down
4 changes: 4 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ final class AgentTabStore {
var lookup: @MainActor (String) -> (record: AgentSessionRef, store: DaemonStore)? = { _ in nil }
/// The pane holding tab `key` and its daemon (New side chat opens its fork there first).
var locate: @MainActor (String) -> (pane: PaneID, daemon: DaemonService)? = { _ in nil }
/// The existing right pane for a chat, if one is present in the same workspace. Beside opens
/// use this before creating a split so header documents and inspector links share LinkOpener's
/// right-column target.
var rightPane: @MainActor (String) -> PaneID? = { _ in nil }
/// Main pane layout (``MainPaneLayout``): opens what chat tab `chat` opens beside it (through
/// `open`, focused with `focus`) in the column on the chat's right, then `created` and
/// `finished` as ``openBeside`` calls them. Nil when the chat is not its screen's main pane:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ enum AgentBesidePlacement {
if let services, let clicked { focus(surface, returningTo: from, in: clicked, generation: intent, services: services) }
}
}
// A right column already exists: keep document opens in that column and insert immediately
// after its active tab. This is the AgentBesidePlacement half of RightPanePlacement and is
// shared by header Changes/Files/Preview/Browser, inspector PR rows and Environment Files.
if let source = controller ?? services.paneController(for: anchor), let content = source.workspace,
case .pane(let id) = services.linkOpener.target(from: source, mainPane: false), id != source.layoutPaneID,
let right = content.panes[id] {
return { [weak right, weak services] surface in
guard let right, let services, let tab = services.locateTab(surface)?.0 else { then?(surface); return }
let index = insertionIndex(active: right.stripModel.selectedID?.rawValue, tabs: right.pane.tabs.map(\.id))
TabMoves.move(tab, to: right.pane, index: index, services: services) { moved in
then?(surface)
if moved, invocation.besideTakesFocus { right.selectWhenReported(surface: surface) }
}
}
}
// A resized window may not have room for a second area. Keep the new document as a tab in
// the chat's pane below the 480pt chat floor; a zero bounds value is an unlaid-out test view
// and keeps the normal daemon placement path.
if let source = controller ?? services.paneController(for: anchor), source.view.bounds.width > 0,
source.view.bounds.width < RightPanePlacement.minimumChatWidth {
return { [weak source] surface in
then?(surface)
source?.selectWhenReported(surface: surface)
}
}
// A lone chat docks left with the agent chat role and the new tab stays in its
// pane (ChatColumnPlacement.dockChat, the rule a person's new tab follows), so
// the column right of the chat is always in view; else the tab moves out.
Expand Down Expand Up @@ -163,6 +188,14 @@ enum AgentBesidePlacement {
}
}

extension AgentBesidePlacement {
/// Final display index used when a beside document is inserted after the active tab in the
/// existing right area. Kept pure so tab ordering remains testable without a daemon.
static func insertionIndex(active: String?, tabs: [String]) -> Int {
active.flatMap { id in tabs.firstIndex(of: id) }.map { $0 + 1 } ?? tabs.count
}
}

extension AgentBesidePlacement {
/// A person's click (``ActionInvocation/besideTakesFocus``): the focus goes to `surface`'s tab once
/// it lands (once it left pane `awayFrom`, a tab made there and moved), unless they acted after
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ enum TabLifecycle {
// there it opens unselected in the chat's pane and moves into a new one (AgentBesidePlacement).
let beside = invocation.besideCaller ? ctx.services.paneController(for: pane) : nil
if invocation.besideCaller {
if let url, let beside, ctx.services.linkOpener.selectExistingWeb(url, from: beside) { return }
agentTab = AgentBesidePlacement.placed(invocation, pane: pane, controller: beside, services: ctx.services, then: agentTab)
}
// An agent never swaps out the page the person is working in: its tab opens unselected in the
Expand Down
12 changes: 12 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/Links/LinkOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,18 @@ final class LinkOpener {
}
}

/// Selects a matching browser tab anywhere in the chat's right column. Beside actions use this
/// before creating a new tab so a PR opened from Session Info and the same transcript link share
/// one tab (and one close/focus relation) in the workspace.
@discardableResult
func selectExistingWeb(_ url: URL, from source: PaneController) -> Bool {
guard case .pane(let id) = target(from: source, mainPane: false), id != source.layoutPaneID,
let pane = source.workspace?.panes[id], let shown = browserTab(showing: url, in: pane) else { return false }
pane.select(StripTabID(shown.id))
pane.focusContent()
return true
}

/// Records that closing `surface`'s tab gives the focus back to pane `pane` of `content`.
func returnFocus(of surface: SurfaceID, to pane: LayoutPaneID, in content: WorkspaceContentController) {
returns = returns.filter { $0.value.content != nil }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@testable import CmuxNextApp
import Testing

@Suite("Agent beside right pane routing")
struct AgentBesidePlacementRoutingTests {
@Test func existingRightAreaInsertsAfterItsActiveTab() {
#expect(AgentBesidePlacement.insertionIndex(active: "docs-1", tabs: ["docs-0", "docs-1", "docs-2"]) == 2)
}

@Test func missingActiveTabAppendsWithoutReorderingExistingTabs() {
#expect(AgentBesidePlacement.insertionIndex(active: "closed", tabs: ["docs-0", "docs-1"]) == 2)
}

@Test func sessionInfoAndTranscriptIdentitySelectTheSameRightTab() {
let docs = RightPanePlacement.Area(tabs: [.init(id: "pr:https://github.com/manaflow-ai/cmux/pull/1")], active: "pr:https://github.com/manaflow-ai/cmux/pull/1")
let fromSessionInfo = RightPanePlacement.decide(kind: .documents, identity: "pr:https://github.com/manaflow-ai/cmux/pull/1", chatWidth: 720, rightColumnWidth: 520, documents: docs)
let fromTranscript = RightPanePlacement.decide(kind: .documents, identity: "pr:https://github.com/manaflow-ai/cmux/pull/1", chatWidth: 720, rightColumnWidth: 520, documents: docs)
#expect(fromSessionInfo == .selectExisting("pr:https://github.com/manaflow-ai/cmux/pull/1"))
#expect(fromTranscript == fromSessionInfo)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ struct ClickedLinkFocusTests {
try await PinnedPageTests.until({ content.focusedPane === pane && right.stripModel.selectedID?.rawValue == otherID })

let before = Set(store.workspaces.flatMap(\.screens).flatMap(\.panes).flatMap(\.tabs).map(\.id))
#expect(services.linkOpener.selectExistingWeb(Self.link, from: pane), "the beside PR action did not reuse the right-column tab")
try await PinnedPageTests.until({ content.focusedPane === right && right.stripModel.selectedID?.rawValue == opened },
report: { Self.describe(content, opened) })
#expect(services.linkOpener.openWeb(Self.link, from: pane, background: false, reusesTab: true))
try await PinnedPageTests.until({ content.focusedPane === right && right.stripModel.selectedID?.rawValue == opened },
report: { Self.describe(content, opened) })
Expand Down
Loading