Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CmuxNextActions
import CmuxNextAgentActivity
import CmuxNextBridge
import CmuxNextBrowser
import CmuxNextCompat
import Foundation

/// Opens `cmux://agent-activity` (plans/cmux-next/computer-use.md section 7)
Expand All @@ -12,6 +13,10 @@ import Foundation
@MainActor
final class AgentActivityPageService {
private unowned let services: AppServices
/// A jump waiting for its workspace's first pane (`open(_:in:)`); the next jump cancels it.
private var paneWait: Task<Void, Never>?
/// Counts jumps, so a workspace that mounts after a newer jump opens nothing.
private var jumps = 0

init(services: AppServices) {
self.services = services
Expand All @@ -35,11 +40,72 @@ final class AgentActivityPageService {
AgentActivityPageTab(id: BrowserTabID(rawValue: key), engine: engine, profile: profile, source: makeSource())
}

/// Reveals the tab that shows a lane's chat, else opens it in the focused pane (the
/// `cmux://session/<id>` path every session link takes).
/// Shows a lane's chat where ``AgentChatJump`` says it lives. Back returns
/// to where the person jumped from, so a pick is never a dead end.
func jump(to lane: AgentActivityLane) {
let jump = AgentChatJump.decide(lane: lane, shownIn: services.agentTabs.tab(showing: lane.id),
hasWorkspace: { [services] in services.workspace(id: $0) != nil })
// The next settled location is its own step, even within one workspace.
services.locationTrail.noteJump()
jumps += 1
paneWait?.cancel()
paneWait = nil
switch jump {
case .revealTab(let key):
if !services.revealTab(key, intent: .raise) { openInFocusedPane(lane.id) }
case .openInWorkspace(let workspaceID, let session):
open(session, inWorkspace: workspaceID)
case .openInFocusedPane(let session):
openInFocusedPane(session)
}
}

/// Shows the workspace where it lives, then opens the chat in a pane there,
/// so the jump never replaces what the person is looking at. A workspace no
/// window can show falls back to the focused pane.
private func open(_ session: String, inWorkspace workspaceID: String) {
guard let window = services.windows.reveal(workspaceID: workspaceID), let nsWindow = window.window else {
return openInFocusedPane(session)
}
services.showJumpWindow(nsWindow, .raise)
guard window.content?.workspace.id != workspaceID else { return open(session, in: window) }
// It has to mount that workspace's panes first.
let jump = jumps
services.windows.afterNextContent(in: window, showing: workspaceID) { [weak self, weak window] in
guard let self, let window, jumps == jump else { return }
open(session, in: window)
}
}

private func open(_ session: String, in window: WindowController) {
if let pane = window.focusedPane { return pane.openAgentSession(session) }
guard let workspaceID = window.content?.workspace.id else { return services.registry.refuse(MiscHandlerStrings.noPane) }
// The workspace shows before its panes mount: open the chat in the first one. The wait
// ends when a pane mounts, when the window closes or shows another workspace, or when
// the next pick replaces it. The registry and the window's state are observable, so
// closing the window or showing another workspace (even one already mounted, which
// mounts nothing) ends the wait at once.
let windows = services.windows, mounts = services.paneMounts, state = window.state, windowID = window.state.id
paneWait = Task { [weak self] in
for await next in ObservationStream({ () -> AgentChatJump.PaneWait in
_ = mounts.generation
let open = windows.registry.value.window(windowID)?.isOpen == true
let window = windows.controller(for: windowID)
let mounted = window?.content?.workspace.id == workspaceID && window?.focusedPane != nil
return .decide(showing: open ? state.workspaceID : nil, workspaceID: workspaceID, hasPane: mounted)
}) where next != .wait {
if next == .open, !Task.isCancelled { windows.controller(for: windowID)?.focusedPane?.openAgentSession(session) }
break
}
if !Task.isCancelled { self?.paneWait = nil }
}
}

/// The `cmux://session/<id>` path every session link takes: the focused pane
/// of the active window.
private func openInFocusedPane(_ session: String) {
do {
try DeepLinkNavigator(services: services).open(DeepLink(.session(lane.id, turn: nil)), background: false)
try DeepLinkNavigator(services: services).open(DeepLink(.session(session, turn: nil)), background: false)
} catch {
services.registry.refuse(String(describing: error))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import CmuxNextAgentActivity

/// Where picking a lane in the sidebar's Priority view puts its chat. It is a
/// value so the choice can be read without the app's services, which
/// ``AgentActivityPageService/jump(to:)`` then carries out.
enum AgentChatJump: Equatable {
/// A tab of this app already shows the chat: reveal it where it lives.
case revealTab(String)
/// No tab shows it and the lane names a workspace this app has: show that
/// workspace, then open the chat in a pane of its own.
case openInWorkspace(workspaceID: String, session: String)
/// Nothing says where the chat belongs (the session carries no workspace,
/// or this app does not have that one): the focused pane, the path every
/// `cmux://session/<id>` link takes.
case openInFocusedPane(session: String)

/// - Parameters:
/// - lane: The lane the person picked.
/// - tab: The tab already showing the lane's session, if any
/// (``AgentTabStore/tab(showing:host:)``).
/// - hasWorkspace: Whether this app has the workspace with that id.
static func decide(lane: AgentActivityLane, shownIn tab: String?,
hasWorkspace: (String) -> Bool) -> AgentChatJump {
if let tab { return .revealTab(tab) }
if let workspaceID = lane.workspaceID, hasWorkspace(workspaceID) {
return .openInWorkspace(workspaceID: workspaceID, session: lane.id)
}
return .openInFocusedPane(session: lane.id)
}

/// What an ``openInWorkspace(workspaceID:session:)`` jump does when its window shows the
/// workspace before any of that workspace's panes has mounted.
enum PaneWait: Equatable, Sendable {
/// A pane mounted: open the chat there.
case open
/// None yet: wait for the next mount.
case wait
/// The window closed or shows another workspace: the jump is over.
case stop

/// - Parameters:
/// - showing: The workspace the jump's window shows now, nil once it closed.
/// - workspaceID: The workspace the jump went to.
/// - hasPane: Whether that window has a pane yet.
static func decide(showing: String?, workspaceID: String, hasPane: Bool) -> PaneWait {
guard showing == workspaceID else { return .stop }
return hasPane ? .open : .wait
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CmuxNextAgentActivity
@testable import CmuxNextApp
import Foundation
import Testing

/// Picking a chat in the sidebar's Priority view goes to where that chat lives. Before, every pick
/// went to the active window's focused pane, so a session bound to another workspace replaced what
/// the person was looking at instead of switching to its own workspace (Leo 2026-10-21: it
/// "doesn't jump to another workspace, it overrides this workspace").
@Suite struct AgentChatJumpTests {
private static func lane(_ id: String, workspace: String?) -> AgentActivityLane {
AgentActivityLane(id: id, name: id, title: id, peer: nil, parent: nil, state: .input,
since: nil, error: nil, workspaceID: workspace)
}

@Test func aChatBoundToAnotherWorkspaceOpensInThatWorkspace() {
let jump = AgentChatJump.decide(lane: Self.lane("s1", workspace: "ws-1"), shownIn: nil,
hasWorkspace: { $0 == "ws-1" })
#expect(jump == .openInWorkspace(workspaceID: "ws-1", session: "s1"))
}

@Test func aTabAlreadyShowingTheChatIsRevealedWhereverItLives() {
let jump = AgentChatJump.decide(lane: Self.lane("s1", workspace: "ws-1"), shownIn: "tab-9",
hasWorkspace: { _ in true })
#expect(jump == .revealTab("tab-9"))
}

@Test func withNoWorkspaceOfItsOwnTheChatOpensInTheFocusedPane() {
#expect(AgentChatJump.decide(lane: Self.lane("s1", workspace: nil), shownIn: nil,
hasWorkspace: { _ in true }) == .openInFocusedPane(session: "s1"))
#expect(AgentChatJump.decide(lane: Self.lane("s1", workspace: "gone"), shownIn: nil,
hasWorkspace: { _ in false }) == .openInFocusedPane(session: "s1"))
}

/// The window can show the workspace before its panes mount. The jump used to give up there
/// ("no pane") and the chat never opened.
@Test func aWorkspaceWithNoPaneYetWaitsForOne() {
#expect(AgentChatJump.PaneWait.decide(showing: "ws-1", workspaceID: "ws-1", hasPane: false) == .wait)
#expect(AgentChatJump.PaneWait.decide(showing: "ws-1", workspaceID: "ws-1", hasPane: true) == .open)
}

@Test func theWaitEndsWhenTheWindowLeavesTheWorkspace() {
#expect(AgentChatJump.PaneWait.decide(showing: "ws-2", workspaceID: "ws-1", hasPane: true) == .stop)
#expect(AgentChatJump.PaneWait.decide(showing: nil, workspaceID: "ws-1", hasPane: false) == .stop)
}
}