Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1bb00b4
cmux NEXT: opening an existing chat adopts the prewarmed, connected page
teamleaderleo Oct 30, 2026
dd53ecf
agent pane: adopted spare queues until connected, restores the scroll…
teamleaderleo Oct 30, 2026
9a4ee73
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/sessio…
teamleaderleo Oct 30, 2026
f05de46
agent pane: an adoption gives way to a newer pick, and keeps its plac…
teamleaderleo Oct 30, 2026
57209f4
agent pane: an adoption a connect already opened keeps its place and …
teamleaderleo Oct 30, 2026
28a592d
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/sessio…
teamleaderleo Oct 30, 2026
d1f6536
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/sessio…
teamleaderleo Oct 30, 2026
157bc91
Merge commit 'b623fbb2a51230bb8a1c26519446ddfde12233df' into lane/ses…
teamleaderleo Oct 30, 2026
c550532
Merge feat-cmux-next into lane/session-open-spare
inter-milan-vs-tottenham-spurs Nov 1, 2026
cffa960
fix(agent-pane): an adopted chat the page's list lacks opens on its a…
inter-milan-vs-tottenham-spurs Nov 1, 2026
85db66a
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ux-war…
teamleaderleo Nov 1, 2026
ebd97da
agent pane: an existing chat takes the spare only once its client is …
teamleaderleo Nov 1, 2026
0196dec
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ux-war…
teamleaderleo Nov 1, 2026
d2ce415
Trace why a sidebar open does or doesn't take the spare
teamleaderleo Nov 1, 2026
d0e4cda
Trace why the spare refused an existing chat
teamleaderleo Nov 1, 2026
926fb4f
The composer's audio report no longer marks the spare touched
teamleaderleo Nov 1, 2026
0381186
The spare's trace calls compile in Release, which has no ChatOpenTrace
teamleaderleo Nov 1, 2026
96e0660
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/ux-war…
teamleaderleo Nov 1, 2026
2f283ca
Reading the header state on mount no longer marks the spare touched
teamleaderleo Nov 1, 2026
25938ec
Merge branch 'feat-cmux-next' into lane/ux-warm-agent-page
cmux-bot[bot] Nov 1, 2026
098aec3
The New Tab page's project list on mount no longer marks the spare to…
teamleaderleo Nov 1, 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
Expand Up @@ -31,7 +31,7 @@ public nonisolated struct AgentPageOps {
/// Op suffix to the old bridge method that ``AgentPaneRequest`` parses.
static let methods: [String: String] = {
var methods = Dictionary(uniqueKeysWithValues: [
"pane.checkpointAvailability", "pane.commandAvailability", "pane.framePacing", "pane.painted", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setScrollToBottomOnSend", "pane.setDisplay", "pane.setAttachmentView", "pane.setRemoteControl", "tips.state",
"pane.checkpointAvailability", "pane.commandAvailability", "pane.framePacing", "pane.painted", "pane.clientReady", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setScrollToBottomOnSend", "pane.setDisplay", "pane.setAttachmentView", "pane.setRemoteControl", "tips.state",
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "readAloud.speak", "readAloud.stop", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "chief.face", "harness.symbol", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "machine.share.self", "chat.attach.choose", "chat.attach.dropped", "chat.attach.source", "attachment.sourceIcon", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ enum AgentPaneHandshakeReply {
(model.handedWake, model.pendingWake) = (model.pendingWake, nil)
}
model.hasHandshake = true
// A handshake starts a connection: the page says when its client is ready (`pane.clientReady`).
model.clientReady = false
model.acpmuxRestartOffered = nil
// A filled-in folder stays the workspace's, no root of its own (cx-9aps).
if let cwd = handshake.cwd, !filled { model.handshakeCwd = cwd }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public final class AgentPaneModel {
/// own give-up path, each of which keeps the fade.
public internal(set) var paintedFromKeptTail = false
@ObservationIgnored var paintWaiters: [() -> Void] = []
/// The page's acpmux client is connected and its page actions answer (`pane.clientReady`); false
/// while it connects or after it lost its connection. A prewarmed New Tab page serves a tab
/// opening an existing chat only while this holds (`NewTabSparePool.takeConnectedSpare`).
@ObservationIgnored public internal(set) var clientReady = false

/// Called when the page switches to or creates a session, so the App can keep it with the tab.
@ObservationIgnored public var onSessionChange: ((String) -> Void)?
Expand Down Expand Up @@ -321,6 +325,23 @@ public final class AgentPaneModel {
newTab = page
}

/// A tab bound to session `session` (an existing chat it opens) adopted this prewarmed, connected
/// New Tab page: the model is that tab's from here, as if its handshake had named the session
/// (``AgentPaneView/adoptSession(_:revealTurn:)``). Its first paint is ahead again: the pane
/// keeps its cover until the page draws the chat. False, changing nothing, for a page that is
/// not an untouched, connected New Tab page.
@discardableResult
public func adoptSpareSession(_ session: String) -> Bool {
guard sessionId == nil, newTab != nil, hasHandshake, !userTouched, !remoteChat, !neverPooled else { return false }
sessionId = session
transport.sessions.add(session)
newTab = nil
// The spare's start folder was no tab's; the chat's folder comes with its session.
handshakeCwd = nil
(hasPainted, paintedFromKeptTail) = (false, false)
return true
}

/// A new workspace's first tab (its view made before the store's reply
/// named it a new tab page): a chat that has no session yet becomes the
/// page. A chat with a session keeps it.
Expand All @@ -332,14 +353,20 @@ public final class AgentPaneModel {
public func respond(to request: AgentPaneRequest, userGesture: Bool = false) async -> [String: Any] {
switch request {
// Boot traffic, and a request the host refused (it changed nothing), leave it untouched.
case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .commandAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported,
case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .commandAvailability, .painted, .clientReady, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported,
.transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext,
.tabRowTools, .tabRowToolsSet, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .attachmentSourceIcon, .environment(.list), .cloudMachine(.list), .cloudMachine(.recoveryList),
.sandboxState, .subagentShown: break
// The composer reports whether its harness takes audio on every mount (dictation.ts), and the
// header, project notice and New Tab project list read their state on mount: boot traffic too (HQ#5914).
case .dictation(.recordAudio), .tabState, .projectInstructions, .listProjects: break
case .reply(let reply) where reply.isPassive: break
case .cloudEnvironment(let request) where request.action != "update": break
default:
if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) }
let name = String(String(describing: request).prefix { $0 != "(" })
if touchedBy?.split(separator: ",").contains(Substring(name)) != true, (touchedBy?.count ?? 0) < 200 {
touchedBy = touchedBy.map { $0 + "," + name } ?? name
}
userTouched = true
}
switch request {
Expand All @@ -359,6 +386,9 @@ public final class AgentPaneModel {
case .commandAvailability(let commands):
pageCommands = Set(commands)
return AgentPaneReply.success()
case .clientReady(let ready):
clientReady = ready
return AgentPaneReply.success()
case .framePacing(let intervals):
return AgentPaneReply.success(onFramePacing?(intervals) ?? [:])
case .renderRate(let full):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ extension AgentPaneRequest {
} else {
self = .unsupported(method)
}
case "pane.clientReady":
if let ready = params?["ready"] as? Bool {
self = .clientReady(ready)
} else {
self = .unsupported(method)
}
case "pane.painted":
self = .painted(fromKeptTail: params?["drewKeptTail"] as? Bool ?? false)
case "pane.renderRate":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl
case .newChatHere: "newChatHere"
case .checkpointAvailability: "checkpointAvailability"
case .commandAvailability: "commandAvailability"
case .clientReady(let ready): ready ? "clientReady" : "clientNotReady"
case .painted(let fromKeptTail): fromKeptTail ? "painted(keptTail)" : "painted"
case .dictation: "dictation"
case .openFile: "openFile"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// already on screen in that frame, or a chat read cold, which the pane's
/// loading state fades in. An older page says nothing, which means cold.
case painted(fromKeptTail: Bool)
/// `pane.clientReady {ready}`: the page's acpmux client finished connecting (its handshake,
/// machines and first chat list are in, and its page actions answer), or lost its connection.
/// A prewarmed page serves a tab opening an existing chat only while it is ready
/// (``AgentPaneModel/clientReady``): one still connecting would hold that open behind its list.
case clientReady(Bool)
/// The composer's mic: `dictation.toggle`, `.start`, `.stop`, `.cancel`,
/// `dictation.openSettings` with `{permission}`, or `dictation.audio` with `{enabled}`.
case dictation(AgentPaneDictationCommand)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
public import AppKit

/// A prewarmed New Tab page opens an existing chat (a sidebar click, Open in Tab, a subagent's
/// pane): the page is loaded, mounted and connected to acpmux already, so it switches to the
/// chat in place (the switch `chat.select` makes, drawing a kept tail from `chat.tail.get` when
/// the host has one) instead of a new page loading, connecting and attaching cold.
///
/// It never shows its New Tab screen in the chat's pane, not for one frame. The page leaves that
/// screen in the event's own render (`acpmux-session-adopt`, newtab/adoption.ts), and until it
/// reports the chat drawn (`pane.painted`) the pane shows its cover (the loading state, or the
/// tab's saved frame from the last quit) with the page laid out outside the window: a Chromium
/// page draws in its own window over the pane, which no cover under it and no alpha hides.
extension AgentPaneView {
/// How far outside the window a held page lies (as the New Tab spare's parking).
static let awayOffset: CGFloat = -100_000

static func awayFrame(of frame: NSRect) -> NSRect { frame.offsetBy(dx: awayOffset, dy: awayOffset) }

/// Adopts this connected New Tab page for the tab of chat `session`; `revealTurn` is a turn its
/// open asks to show (a subagent's pane: `subagent:<id>`). `moved` runs each time the held page
/// changes place (out of the window now, back once it drew), so a Chromium page's window follows.
/// False when the page is no untouched, connected New Tab page (``AgentPaneModel/adoptSpareSession(_:)``).
@discardableResult
public func adoptSession(_ session: String, revealTurn: String? = nil, moved: (@MainActor () -> Void)? = nil) -> Bool {
guard model.adoptSpareSession(session) else { return false }
holdPageAway(moved: moved)
beginLoadingState()
// A New Tab page drew without it (AgentTabStore.makeView); the chat's pane keeps it until it drew.
showsLoadingState = true
// The chat's surface (R55), not the New Tab page's; the loading state takes its color.
applyTheme()
var detail: [String: Any] = ["sessionId": session]
if let revealTurn { detail["revealTurn"] = revealTurn }
guard let data = try? JSONSerialization.data(withJSONObject: detail, options: [.withoutEscapingSlashes]),
let json = String(data: data, encoding: .utf8) else { return true }
evaluateScript("window.dispatchEvent(new CustomEvent('acpmux-session-adopt', {detail: \(json)}))")
return true
}

/// Lays the page outside the window now, before the pane shows this view, and back in place in
/// the turn it reports the chat drawn (or the loading state's give-up marks it painted).
private func holdPageAway(moved: (@MainActor () -> Void)?) {
holdsPageAway = true
pageContent.frame = Self.awayFrame(of: topBar.contentFrame(in: bounds))
moved?()
model.whenPainted { [weak self] in
guard let self, holdsPageAway else { return }
holdsPageAway = false
pageContent.frame = topBar.contentFrame(in: bounds)
needsLayout = true
moved?()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ public final class AgentPaneView: NSView {
public var uiPointerReport: (@MainActor (JSONValue) -> Void)?
/// The pane's first frame until its page paints (`AgentPaneView+Loading`).
public let loadingView = AgentPaneLoadingView()
/// The page lies outside the window until it draws the chat a tab adopted it for
/// (`AgentPaneView+SessionAdopt`): a Chromium page draws in its own window, over any cover.
var holdsPageAway = false
public private(set) lazy var redraw = page.map { AgentPageRedraw(page: $0) } ?? AgentPageRedraw(webView: webView) // a chat shown again draws first
public private(set) lazy var topBar = AgentPaneTopBar(pane: self) // the App's omnibar row over a New Tab page (cx-e2aa)
/// The process pool every agent page shares (R81: fonts are listed once per pool).
Expand Down Expand Up @@ -398,7 +401,9 @@ public final class AgentPaneView: NSView {

public override func layout() {
super.layout()
if let page { page.frame = topBar.contentFrame(in: bounds) } else { webView.frame = topBar.contentFrame(in: bounds) }
let frame = topBar.contentFrame(in: bounds)
let shown = holdsPageAway ? Self.awayFrame(of: frame) : frame
if let page { page.frame = shown } else { webView.frame = shown }
if loadingView.superview === self { loadingView.frame = topBar.contentFrame(in: bounds) }
redraw.laidOut(self)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxNextAgentPane
import CmuxNextBrowser
import CmuxNextCompat
import CmuxNextDaemon
import Foundation
Expand Down Expand Up @@ -236,6 +237,36 @@ extension AgentTabStore {
spare.adoptNewTab(page)
}

/// Local tab `key`, which shows this Mac's chat `session`, adopts the prewarmed, connected `spare`:
/// the page switches to the chat in place (``AgentPaneView/adoptSession(_:revealTurn:moved:)``).
/// A spare that refuses (no longer an untouched New Tab page) goes back to the pool or is retired,
/// and the tab gets nothing from it.
@discardableResult
func adoptSpare(_ spare: AgentPaneView, key: String, session: String, revealTurn: String?) -> Bool {
let moved: @MainActor () -> Void = { [weak spare] in
// A Chromium page's window follows its view only when told (CEFHostView.pagesNeedUpdate).
guard let window = spare?.window else { return }
NotificationCenter.default.post(name: Notification.Name.browserChildWindowPagesNeedUpdate, object: window)
}
guard spare.adoptSession(session, revealTurn: revealTurn, moved: moved) else {
NewTabSparePool.traceSpare(.refused, note: Self.spareRefusal(spare.model))
if let revealTurn { pendingTurns[key] = revealTurn }
if recycle?(spare) != true { retirer.retire(spare) }
return false
}
NewTabSparePool.traceSpare(.adopted)
standaloneViews.remove(spare)
(views[key], spare.model.allowsTabConversion) = (spare, true)
wire(spare.model, key: key)
return true
}

/// Why `model` refused to become an existing chat (``AgentPaneModel/adoptSpareSession(_:)``), for the open's trace.
static func spareRefusal(_ model: AgentPaneModel) -> String {
model.sessionId != nil ? "has a session" : model.newTab == nil ? "no new tab page" : !model.hasHandshake ? "no handshake"
: model.userTouched ? "touched by \(model.touchedBy ?? "?")" : model.remoteChat ? "remote chat" : model.neverPooled ? "never pooled" : "?"
}

/// The page of tab `key` reported `session` (a new chat started, or the user changed the chat
/// in this tab): a compare-and-swap from the session the tab shows, as a store intent
/// (`Intent.bindAgentSession`): the record shows the new session at once, and a refusal (a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CmuxNextDaemon

/// Which tab opening an existing chat adopts the prewarmed New Tab page (``AgentTabStore/view(for:)``,
/// `NewTabSparePool`). The spare is this Mac's page, connected to this Mac's acpmux, and its
/// handshake is past, so it serves only a chat whose open needs nothing that handshake carries:
/// - a chat of this Mac's acpmux: not another machine's (its own host), not a Chief home's
/// (`chief:` tabs, and an older subagent tab of this Mac's host the Chief home keeps);
/// - with a session: a tab with none is a New Tab page or a new chat (the spare's own path);
/// - no New Tab page, no seed (a new chat's folder, draft, prompt or surface);
/// - no kept or relaunch wake state (a composer or shell box its own handshake hands over).
enum SpareSessionAdoption {
/// The session the tab's page switches the spare to, nil when the tab gets a page of its own.
static func session(local: Bool, kind: AgentPaneHostKind, session: String?, newTabPage: Bool, seeded: Bool,
wake: Bool, chiefKeeps: (String) -> Bool) -> String? {
guard local, kind == .local, let session, !session.isEmpty, !newTabPage, !seeded, !wake, !chiefKeeps(session)
else { return nil }
return session
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ extension AgentTabStore {
AcpmuxHost(daemons: daemons, environment: $0, startsDaemons: $1, peerApprover: peerApprover)
}
tabs.localSessionHost = chief.localRouter(tabs.host)
// A chat that home keeps opens on its own page, never the spare connected to this Mac's acpmux.
tabs.chiefKeepsSession = { session in AcpmuxChiefSessionRouter.keeps(session: session, home: home.acpmuxHome) }
// This Mac's stable install id (the Cloud device id): only this host attaches to its acpmux.
tabs.blankChatHandler = { [weak services] key in
guard let services, let (tab, pane) = services.locateTabOnAnyMachine(key), let controller = services.paneController(for: pane) else { return nil }
Expand Down
18 changes: 18 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ final class AgentTabStore {
var recycle: ((AgentPaneView) -> Bool)?
/// The prewarmed New Tab page, for a first page that has no view yet (NewTabSparePool).
var takeSpare: (() -> AgentPaneView?)?
/// The prewarmed page once it is connected and drawn, for a tab opening an existing chat of
/// this Mac (``SpareSessionAdoption``); nil leaves the spare in the pool.
var takeConnectedSpare: (() -> AgentPaneView?)?
/// Whether this Mac's Chief home keeps session `session` (an older subagent tab recorded with
/// this Mac's host attaches there, ``AcpmuxChiefSessionRouter``): the spare is connected to
/// this Mac's acpmux, so such a chat gets its own page.
var chiefKeepsSession: @MainActor (String) -> Bool = { _ in false }
/// Tab `key` got a page (built or adopted from the spare), drawn or not: a page built while its
/// tab does not draw starts its hidden clock (`AgentPageHibernation.pageBuilt`).
var onPageBuilt: ((String) -> Void)?
Expand Down Expand Up @@ -428,6 +435,17 @@ final class AgentTabStore {
onPageBuilt?(key)
return spare
}
// An existing chat of this Mac (a sidebar click, Open in Tab, a subagent's pane) adopts the
// spare too: it is loaded and connected, and switches to the chat in place.
if let session = SpareSessionAdoption.session(local: local, kind: kind, session: sessions[key] ?? record.session,
newTabPage: newTabPages[key] != nil, seeded: seed != nil,
wake: wakeStates[key] != nil || relaunchWake.mayHold(key),
chiefKeeps: chiefKeepsSession),
let spare = takeConnectedSpare?(), adoptSpare(spare, key: key, session: session, revealTurn: pendingTurns.removeValue(forKey: key)) {
track(key, in: store)
onPageBuilt?(key)
return spare
}
let model = AgentPaneModel(
host: paneHost,
sessionId: sessions[key] ?? record.session,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ enum DebugChatOpenTrace {
"frame_count": .number(Double(record.frameCount)),
"first_content_ms": record.firstContentMilliseconds.map { JSON.number(rounded($0)) } ?? .null,
"longest_empty_ms": .number(rounded(record.longestEmptyMilliseconds)),
"note": record.note.map(JSON.string) ?? .null,
])
}
}
Expand Down
Loading
Loading