From 1bb00b4a7a507a2cbe336c554a892d2fc6b17f50 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:31:24 +0000 Subject: [PATCH 01/12] cmux NEXT: opening an existing chat adopts the prewarmed, connected page A sidebar click, Open in Tab, a subagent's pane or any tab that shows an existing chat of this Mac now adopts the NewTabSparePool spare when it is connected and drew, and switches it to the chat in place (chat.select: the host's kept tail, then the attach), instead of building a page that loads, connects and attaches cold. The pool warms the next spare as after a Cmd-T. The spare never shows its New Tab screen in the chat's pane: the page leaves it in the adopt event's own render, and until it reports the chat drawn the pane keeps its cover (loading state or saved frame) with the page laid out outside the window, since a Chromium page draws in its own window over any cover. Excluded, with their own page as before: another machine's chats, Chief home chats (and an older subagent tab that home keeps), tabs with a seed, a New Tab page, or a kept or relaunch wake state, and a spare not yet connected. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextAgentPane/AgentPaneModel.swift | 17 ++ .../AgentPaneView+SessionAdopt.swift | 54 +++++ .../CmuxNextAgentPane/AgentPaneView.swift | 7 +- .../CmuxNextApp/AgentTabs+Lifecycle.swift | 23 ++ .../CmuxNextApp/AgentTabs+SpareSession.swift | 19 ++ .../CmuxNextApp/AgentTabs+Wiring.swift | 2 + .../Sources/CmuxNextApp/AgentTabs.swift | 17 ++ .../CmuxNextApp/NewTab/NewTabSparePool.swift | 7 + .../SpareSessionAdoptionTests.swift | 136 +++++++++++ webviews/src/agent-session/acpmux/App.tsx | 45 +++- webviews/src/agent-session/acpmux/direct.ts | 9 + .../agent-session/acpmux/newtab/adoption.ts | 28 +++ webviews/test/spare-session-adopt.test.ts | 225 ++++++++++++++++++ 13 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+SessionAdopt.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+SpareSession.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift create mode 100644 webviews/test/spare-session-adopt.test.ts diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 5d24a37d0abd..44607e4d4af8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -273,6 +273,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. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+SessionAdopt.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+SessionAdopt.swift new file mode 100644 index 000000000000..2f691a7188af --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+SessionAdopt.swift @@ -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:`). `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?() + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index af8fa3d3da7a..2a2a0f50fcf3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -110,6 +110,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). @@ -357,7 +360,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) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift index fbf525dbc12e..0356c4585b64 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift @@ -1,4 +1,5 @@ import CmuxNextAgentPane +import CmuxNextBrowser import CmuxNextCompat import CmuxNextDaemon import Foundation @@ -230,6 +231,28 @@ 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 { + if let revealTurn { pendingTurns[key] = revealTurn } + if recycle?(spare) != true { retirer.retire(spare) } + return false + } + standaloneViews.remove(spare) + (views[key], spare.model.allowsTabConversion) = (spare, true) + wire(spare.model, key: key) + return true + } + /// 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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+SpareSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+SpareSession.swift new file mode 100644 index 000000000000..116da69ffa42 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+SpareSession.swift @@ -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 + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index efb8ab6d76d1..36aff2c2112b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -133,6 +133,8 @@ extension AgentTabStore { tabs.chiefHomes = ChiefHomeDirectory(parents: ChiefHomeDirectory.homeParents(of: home), work: services.work, environment: { ChiefHomeAcpmux.environment(home: $0) }) 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.locateTab(key), let controller = services.paneController(for: pane) else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift index abaaf6efae9b..7640528ff40b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift @@ -120,6 +120,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 } /// Closed pages leave the view tree at once; their teardown waits (R81). let retirer = AgentPageRetirer() /// The session each tab's page reported, ahead of the store's echo of the bind and across a @@ -376,6 +383,16 @@ final class AgentTabStore { track(key, in: store) 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) + return spare + } let model = AgentPaneModel( host: paneHost, sessionId: sessions[key] ?? record.session, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift index dcc0365c7b6e..cb354c139b32 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift @@ -107,6 +107,13 @@ final class NewTabSparePool { self.record(.init(spare: spare != nil, crossWindow: spare?.crossWindow == true, milliseconds: Self.milliseconds(since: start))) return spare?.view } + // A tab opening an existing chat of this Mac adopts it only once it is connected and drew + // (SpareSessionAdoption): it switches to the chat in place. One still loading stays here. + services.agentTabs.takeConnectedSpare = { [weak self] in + guard let self, let spare = slot.peek, spare.model.hasHandshake, spare.model.hasPainted, + let window = NSApp.keyWindow.flatMap(self.mainWindow) ?? self.target else { return nil } + return take(for: window, size: nil)?.view + } observeWindows() if let window = NSApp.keyWindow.flatMap(mainWindow) ?? services.windows.controllers.compactMap(\.window).first(where: \.isVisible) { retarget(window) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift new file mode 100644 index 000000000000..37ed9887334f --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift @@ -0,0 +1,136 @@ +import CmuxNextActions +@testable import CmuxNextAgentPane +@testable import CmuxNextApp +import CmuxNextDaemon +import Foundation +import Testing + +/// Leo 2026-10-29: "i also like the way it just loads in the subagent stuff instantly :(" / "as in +/// i want ours to load fast too :(". Opening an existing chat (a sidebar click, Open in Tab, a +/// subagent's pane) built a new page, which loaded, connected and attached cold, while the +/// prewarmed, connected New Tab page waited for the next Cmd-T. Such a tab now adopts that page +/// and switches it to the chat in place; the page stays out of sight behind the pane's cover +/// until it drew the chat, so its New Tab screen never shows in the chat's pane. +@MainActor @Suite(.serialized) +struct SpareSessionAdoptionTests { + static let thisMac = "install:spare-session-adoption-test" + + /// A tab store as the App wires it: tab records in a tree, pages on a loopback port nobody serves. + static func run(records: [String: AgentSessionRef]) -> AgentTabStore { + let tabs = AgentTabStore(build: nil, registry: ActionRegistry(), + environment: [AgentPaneSource.devURLVariable: "http://127.0.0.1:9/"], showcase: true) + tabs.localHost = thisMac + let tree = DaemonStore() + tabs.lookup = { key in records[key].map { ($0, tree) } } + return tabs + } + + static func chat(_ session: String) -> AgentSessionRef { + AgentSessionRef(host: thisMac, hostName: "This Mac", session: session, harness: "claude") + } + + /// The pool's spare once it answered its handshake and drew its New Tab page. + static func connectedSpare(of tabs: AgentTabStore) async throws -> AgentPaneView { + let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) + let reply = await spare.model.respond(to: AgentPaneRequest(body: ["method": "ready"])) + try #require(reply["value"] is [String: Any], "\(reply)") + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.painted"])) + try #require(spare.model.hasHandshake && spare.model.hasPainted) + return spare + } + + @Test func anExistingChatOpensOnTheConnectedSpare() async throws { + let tabs = Self.run(records: ["tab_chat": Self.chat("s-chat")]) + let spare = try await Self.connectedSpare(of: tabs) + var taken = 0 + tabs.takeConnectedSpare = { + taken += 1 + return spare + } + var scripts: [String] = [] + spare.evaluateScript = { scripts.append($0) } + // A subagent's pane: the parent chat's tab, scrolled to the subagent's turn. + tabs.revealTurn("subagent:sa-1", in: "tab_chat") + + let view = try #require(tabs.view(for: "tab_chat")) + #expect(view === spare, "the chat's tab built a page of its own") + #expect(taken == 1) + #expect(tabs.existingView("tab_chat") === spare) + #expect(view.model.sessionId == "s-chat") + #expect(view.model.newTab == nil, "the adopted page still counts as a New Tab page") + #expect(view.model.allowsTabConversion) + #expect(tabs.pendingTurns["tab_chat"] == nil, "the subagent's turn stayed behind") + let adopt = try #require(scripts.first { $0.contains("acpmux-session-adopt") }, "\(scripts)") + #expect(adopt.contains("\"sessionId\":\"s-chat\"")) + #expect(adopt.contains("\"revealTurn\":\"subagent:sa-1\"")) + // Until it draws the chat: the pane's cover, and the page outside the window. + #expect(!view.model.hasPainted, "the New Tab page's paint counted as the chat's") + #expect(view.awaitsFirstPaint) + #expect(view.showsLoadingState) + #expect(view.holdsPageAway) + #expect(view.pageContent.frame.maxX < 0 && view.pageContent.frame.maxY < 0, "\(view.pageContent.frame)") + + // The page drew the chat from the host's kept tail: it is back in place, in one frame. + _ = await view.model.respond(to: AgentPaneRequest(body: ["method": "pane.painted", "params": ["drewKeptTail": true]])) + #expect(view.model.hasPainted) + #expect(view.model.paintedFromKeptTail) + #expect(!view.holdsPageAway) + #expect(view.pageContent.frame.minX >= 0 && view.pageContent.frame.minY >= 0, "\(view.pageContent.frame)") + #expect(view.loadingView.superview == nil, "the cover stayed over the drawn chat") + #expect(view.loadingView.leftFading == false, "a chat drawn from a kept tail faded in") + } + + /// Each exclusion keeps the tab's own page, and the spare stays in the pool. + @Test func chatsWhoseOpenNeedsTheirOwnHandshakeKeepTheirOwnPage() async throws { + let tabs = Self.run(records: ["tab_wake": Self.chat("s-wake"), "tab_chief": Self.chat("s-chief")]) + let spare = try await Self.connectedSpare(of: tabs) + var taken = 0 + tabs.takeConnectedSpare = { + taken += 1 + return spare + } + // A composer kept from a hibernated page reaches the page with its handshake. + tabs.wakeStates["tab_wake"] = AgentPaneWakeState(shell: true, shellText: "make") + let wake = try #require(tabs.view(for: "tab_wake")) + #expect(wake !== spare) + // A chat the Chief home keeps runs in that home's acpmux, not the spare's. + tabs.chiefKeepsSession = { $0 == "s-chief" } + let chief = try #require(tabs.view(for: "tab_chief")) + #expect(chief !== spare) + #expect(chief.model.sessionId == "s-chief") + #expect(taken == 0, "an excluded chat took the spare") + #expect(spare.model.newTab != nil) + } + + /// A spare still loading (no handshake yet) is no connected page: the tab gets its own page, + /// and the spare goes back to the pool untouched. + @Test func aSpareThatHasNotConnectedIsNotAdopted() async throws { + let tabs = Self.run(records: ["tab_chat": Self.chat("s-chat")]) + let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) + tabs.takeConnectedSpare = { spare } + var recycled: [AgentPaneView] = [] + tabs.recycle = { recycled.append($0); return true } + let view = try #require(tabs.view(for: "tab_chat")) + #expect(view !== spare) + #expect(view.model.sessionId == "s-chat") + #expect(recycled.count == 1 && recycled.first === spare) + #expect(spare.model.sessionId == nil && spare.model.newTab != nil) + } + + @Test func theDecision() { + func adopts(local: Bool = true, kind: AgentPaneHostKind = .local, session: String? = "s", newTabPage: Bool = false, + seeded: Bool = false, wake: Bool = false, chief: Bool = false) -> String? { + SpareSessionAdoption.session(local: local, kind: kind, session: session, newTabPage: newTabPage, seeded: seeded, + wake: wake, chiefKeeps: { _ in chief }) + } + #expect(adopts() == "s") + #expect(adopts(local: false, kind: .remote) == nil) + #expect(adopts(kind: .chief) == nil) + #expect(adopts(session: nil) == nil) + #expect(adopts(session: "") == nil) + #expect(adopts(newTabPage: true) == nil) + #expect(adopts(seeded: true) == nil) + #expect(adopts(wake: true) == nil) + #expect(adopts(chief: true) == nil) + } +} diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 9c05a92c714c..bad65022a697 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -85,7 +85,9 @@ import { useEarlyNewTabContext, useNewTabAdoption, useNewTabPlaces, + useSessionAdoption, useNewTabWeather, + type SessionAdoption, } from "./newtab/adoption"; import { markMockPage, publishSessions, sharesChatList, useSharedSessions } from "./newtab/chatStore"; import { heldPick, withDraftFolder } from "./newtab/draftFolder"; @@ -7092,6 +7094,9 @@ function AcpmuxPane() { // may never come although the first one drew (cx-zjtn.6; PagePaintProbe in CmuxNextPages waits // the same way). const paintReported = useRef(false); + // A tab opening an existing chat adopted this prewarmed New Tab page (useSessionAdoption, below): + // the chat it switches to, until that chat is drawn. Until then the page has not drawn what it is. + const [adoptingSession, setAdoptingSession] = useState(); // A New Tab page is what it is from its first render: its frame draws before the handshake // (cx-0rpl.10), and the host shows no loading mark over it. A chat the handshake named is what it // is once acpmux attached it: before, the page drew an empty transcript with stand-in chips, and @@ -7102,16 +7107,52 @@ function AcpmuxPane() { clientSnapshot.connection.startsWith("connecting:") || clientSnapshot.connection === "disconnected" || clientSnapshot.connection === "failed"; - const chatDrawn = !awaitsChat || clientSnapshot.chatRead === true || openFailed; + const adoptionDrawn = + adoptingSession === undefined || + openFailed || + (clientSnapshot.sessionId === adoptingSession && clientSnapshot.chatRead === true); + const chatDrawn = (!awaitsChat || clientSnapshot.chatRead === true || openFailed) && adoptionDrawn; const showsWhatItIs = (handshaken && chatDrawn) || hostError !== undefined || newTab !== undefined; // No chat screen before the page knows it is no New Tab page (`contextKnown`). const knowsWhatItIs = contextKnown || newTab !== undefined || handshaken || hostError !== undefined; useEffect(() => { if (!handshaken || chatDrawn) return; // A slow attach: the client draws what it has after the open's hold (direct.ts OPEN_HOLD_MS). - const timer = window.setTimeout(() => setAwaitsChat(false), CHAT_DRAW_HOLD_MS); + const timer = window.setTimeout(() => { + setAwaitsChat(false); + setAdoptingSession(undefined); + }, CHAT_DRAW_HOLD_MS); return () => window.clearTimeout(timer); }, [handshaken, chatDrawn]); + useEffect(() => { + if (adoptingSession !== undefined && adoptionDrawn) setAdoptingSession(undefined); + }, [adoptingSession, adoptionDrawn]); + // The host's spare page, adopted by a tab opening an existing chat (AgentPaneView+SessionAdopt.swift): + // it is connected already, so it switches to the chat in place, as a sidebar pick does (`chat.select`, + // drawing the host's kept tail when there is one). The New Tab screen goes in the event's own render, + // and the chat's first drawn frame is reported as this page's first paint again: the host keeps + // the page out of sight until then. + const adoptSession = useCallback(({ sessionId, revealTurn }: SessionAdoption) => { + const client = directClient.current; + const select = window.cmuxAcpmuxActions?.["chat.select"]; + if (!client || !select) return; + client.bindToSession(sessionId); + paintReported.current = false; + setAdoptingSession(sessionId); + setAwaitsChat(true); + setNewTab(undefined); + setNewSession(false); + setPastTurns(undefined); + setProjectDraft(undefined); + void select({ sessionId }).then( + () => { + if (revealTurn) revealRef.current(revealTurn); + }, + // A chat that could not be read says so in the chat view (a missing chat, a failed open). + () => setAdoptingSession((current) => (current === sessionId ? undefined : current)), + ); + }, []); + useSessionAdoption(adoptSession); // What this frame drew, not only that it drew: a chat taken from a kept tail is on screen in the // frame the open landed on, so the host shows the page instead of fading it in (tailCache.ts, // AgentPaneView+Loading.swift). A kept tail is drawn before the frame that shows what the pane diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 051bbad67cdb..f384e7df89bb 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1554,6 +1554,15 @@ export class AcpmuxDirectClient { this.rememberCurrentTail(false); }; + /// This prewarmed New Tab page now stands for a tab bound to chat `sessionId` (the host's + /// `acpmux-session-adopt`, AgentPaneView+SessionAdopt.swift): from here it is that tab's page as if + /// its handshake had named the session, so a session the daemon lacks, or one that goes away, says + /// so instead of falling back to another chat, and a reconnect reopens it. The switch itself is + /// `chat.select`'s. + bindToSession(sessionId: string): void { + this.host = { ...this.host, sessionId, newSession: false, sessionMustExist: true }; + } + /// Hands the chat on screen to the host now, for a tab about to open it (`chat.openTab`). The /// page keeps it selected, so nothing else would hand it over and the new tab would open cold — /// the flow this cache exists for. diff --git a/webviews/src/agent-session/acpmux/newtab/adoption.ts b/webviews/src/agent-session/acpmux/newtab/adoption.ts index b9c051f3759f..a221b840ce82 100644 --- a/webviews/src/agent-session/acpmux/newtab/adoption.ts +++ b/webviews/src/agent-session/acpmux/newtab/adoption.ts @@ -14,6 +14,34 @@ export const NEW_TAB_PLACES_EVENT = "acpmux-newtab-places"; /// The weather widget's reading arrived or changed while the page shows (AgentPaneView.refreshNewTabWeather). export const NEW_TAB_WEATHER_EVENT = "acpmux-newtab-weather"; +/// A tab opening an existing chat adopted this prewarmed page (AgentPaneView.adoptSession): the +/// page leaves its New Tab screen and switches to that chat in place. +export const SESSION_ADOPT_EVENT = "acpmux-session-adopt"; + +/// The chat a session adopt event names, and the turn its open asks to show (a subagent's pane). +export type SessionAdoption = { sessionId: string; revealTurn?: string }; + +/// The adoption an event's detail names; undefined when it names no session. +export function sessionAdoption(detail: unknown): SessionAdoption | undefined { + if (typeof detail !== "object" || detail === null) return undefined; + const { sessionId, revealTurn } = detail as { sessionId?: unknown; revealTurn?: unknown }; + if (typeof sessionId !== "string" || !sessionId) return undefined; + return typeof revealTurn === "string" && revealTurn ? { sessionId, revealTurn } : { sessionId }; +} + +/// Calls `onAdopt` with each session adopt event, rendered inside the event: the page's next +/// frame never draws its New Tab screen again. +export function useSessionAdoption(onAdopt: (adoption: SessionAdoption) => void): void { + useEffect(() => { + const handle = (event: Event) => { + const adoption = sessionAdoption((event as CustomEvent).detail); + if (adoption) flushSync(() => onAdopt(adoption)); + }; + window.addEventListener(SESSION_ADOPT_EVENT, handle); + return () => window.removeEventListener(SESSION_ADOPT_EVENT, handle); + }, [onAdopt]); +} + /// The context the host gave as the document committed (AgentPaneNavigation), for the first /// render: the page draws New Tab with no round trip (cx-z9kz0). Undefined when it came too late /// or the page is not a New Tab page; `useEarlyNewTabContext` asks then. diff --git a/webviews/test/spare-session-adopt.test.ts b/webviews/test/spare-session-adopt.test.ts new file mode 100644 index 000000000000..dc4612cffb37 --- /dev/null +++ b/webviews/test/spare-session-adopt.test.ts @@ -0,0 +1,225 @@ +// l10n-allow-file: test fixture prompts, replies and titles, not shipped UI. +// +// Leo 2026-10-29: "i also like the way it just loads in the subagent stuff instantly :(" / "as in i +// want ours to load fast too :(". A tab opening an existing chat (a sidebar click, Open in Tab, a +// subagent's pane) now adopts the app's prewarmed New Tab page, which is loaded and connected +// already (AgentPaneView+SessionAdopt.swift). The page switches to the chat in place. These hold it: +// +// - the adopt event's detail is read defensively (newtab/adoption.ts `sessionAdoption`); +// - the New Tab page's client, bound to the chat, draws the chat the host kept before its attach +// answers, and reports it read for that chat (the page's first paint, App.tsx `adoptionDrawn`); +// - bound, it is that tab's page: a chat the daemon drops is called missing, never replaced by +// another chat, as a page whose handshake named the session (`sessionMustExist`). +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { AcpmuxDirectClient } from "../src/agent-session/acpmux/direct"; +import { sessionAdoption } from "../src/agent-session/acpmux/newtab/adoption"; +import { resetKeptTailsForTests } from "../src/agent-session/acpmux/tailCache"; +import type { AcpmuxSnapshot } from "../src/agent-session/acpmux/model"; + +const START = Date.UTC(2026, 9, 29, 9, 0, 0); +const dom = new JSDOM("", { url: "http://localhost/" }); +const scope = globalThis as Record; +const saved = { window: scope.window, document: scope.document, had: "window" in scope }; + +beforeAll(() => { + scope.window = dom.window; + scope.document = dom.window.document; +}); + +afterAll(() => { + if (saved.had) Object.assign(scope, { window: saved.window, document: saved.document }); + else { + delete scope.window; + delete scope.document; + } +}); + +type Message = { id?: number; method: string; params?: Record }; + +const summary = (sessionId: string) => ({ + sessionId, + cwd: "/home/dev/project", + title: `Chat ${sessionId}`, + harness: "claude", + status: "idle", + turnCount: 1, + updatedAt: START, +}); + +const chatEvents = (sessionId: string, text: string) => [ + { sessionId, seq: 1, at: START + 1000, dir: "mux", kind: "user_message", msg: { text: "Where is the retry?" } }, + { + sessionId, + seq: 2, + at: START + 2000, + dir: "in", + kind: "agent_message_chunk", + msg: { + method: "session/update", + params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } }, + }, + }, +]; + +/// acpmux listing `listed`; an attach is held until `release` when `hold` names it. +class Daemon { + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((message: { data: string }) => void) | null = null; + hold?: string; + attaches: string[] = []; + private held?: Message; + private holding?: () => void; + asked = new Promise((resolve) => (this.holding = resolve)); + + constructor(public listed: string[]) { + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + + send(raw: string): void { + const message = JSON.parse(raw) as Message; + if (message.id === undefined) return; + const sessionId = String(message.params?.sessionId ?? ""); + if (message.method === "_acpmux/attach") this.attaches.push(sessionId); + if (message.method === "_acpmux/attach" && sessionId === this.hold) { + this.held = message; + this.hold = undefined; + this.holding?.(); + return; + } + queueMicrotask(() => this.answer(message)); + } + + close(): void { + this.readyState = 3; + } + + release(): void { + const message = this.held; + this.held = undefined; + if (message) this.answer(message); + } + + private answer(message: Message): void { + const sessionId = String(message.params?.sessionId ?? ""); + let result: unknown = {}; + if (message.method === "initialize") + result = { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + else if (message.method === "_acpmux/watch") result = { sessions: this.listed.map(summary) }; + else if (message.method === "_acpmux/attach") { + const events = chatEvents(sessionId, `Reply of ${sessionId}.`); + result = { session: summary(sessionId), lastSeq: events.length, events, hasMore: false }; + } else if (message.method === "_acpmux/events") result = { events: [], hasMore: false, lastSeq: 0 }; + this.onmessage?.({ data: JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) }); + } + + /// acpmux's watch: it dropped chat `sessionId` (closed on another device). + purge(sessionId: string): void { + this.listed = this.listed.filter((id) => id !== sessionId); + this.onmessage?.({ + data: JSON.stringify({ + jsonrpc: "2.0", + method: "_acpmux/session_changed", + params: { kind: "purged", session: { sessionId } }, + }), + }); + } +} + +const flush = async () => { + for (let pass = 0; pass < 8; pass += 1) await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const text = (snapshot: AcpmuxSnapshot | undefined) => + (snapshot?.rows ?? []) + .map((row) => JSON.stringify(row)) + .join(" ") + .replace(/\s+/g, " "); + +let live: AcpmuxDirectClient[] = []; +afterEach(() => { + for (const client of live) client.close(); + live = []; + delete (dom.window as unknown as { cmuxAcpmuxActions?: unknown }).cmuxAcpmuxActions; + resetKeptTailsForTests(); +}); + +/// The prewarmed New Tab page's client: a new chat page, with no session (the spare's handshake). +async function spare(daemon: Daemon) { + const drawn: AcpmuxSnapshot[] = []; + const client = await AcpmuxDirectClient.connect( + { protocolVersion: 1, transport: "acpmux-websocket", endpoint: "ws://spare.invalid/", token: "t", newSession: true }, + (snapshot) => { + if (snapshot.type === "snapshot") drawn.push(snapshot); + }, + undefined, + () => daemon as unknown as WebSocket, + ); + live.push(client); + return { client, drawn, last: () => drawn.at(-1) }; +} + +test("an adopt event names its chat and its turn, and anything else names none", () => { + expect(sessionAdoption({ sessionId: "s-1" })).toEqual({ sessionId: "s-1" }); + expect(sessionAdoption({ sessionId: "s-1", revealTurn: "subagent:a" })).toEqual({ + sessionId: "s-1", + revealTurn: "subagent:a", + }); + expect(sessionAdoption({ sessionId: "s-1", revealTurn: 4 })).toEqual({ sessionId: "s-1" }); + for (const detail of [undefined, null, "s-1", {}, { sessionId: "" }, { sessionId: 7 }]) + expect(sessionAdoption(detail)).toBeUndefined(); +}); + +test("the adopted New Tab page draws the chat the host kept before its attach answers", async () => { + const chat = "adopt-kept"; + const kept = { + events: chatEvents(chat, "Retry added in the kept tail."), + lastSeq: 2, + historyExhausted: true, + summary: summary(chat), + }; + (dom.window as unknown as { cmuxAcpmuxActions: Record Promise> }).cmuxAcpmuxActions = + { + "chat.tail.get": async (params: any) => (String(params.sessionId) === chat ? kept : null), + "chat.tail": async () => null, + }; + const daemon = new Daemon([chat, "other"]); + const page = await spare(daemon); + // The spare is a New Tab page: it opened no chat. + expect(page.last()?.sessionId).toBeUndefined(); + expect(daemon.attaches).toEqual([]); + + daemon.hold = chat; + page.client.bindToSession(chat); + const selected = page.client.select(chat); + await daemon.asked; + // Drawn from the host's tail, with the attach still out: read for this chat, so the page reports it. + expect(page.last()?.sessionId).toBe(chat); + expect(page.last()?.chatRead).toBe(true); + expect(page.last()?.drewKeptTail).toBe(true); + expect(text(page.last())).toContain("Retry added in the kept tail."); + daemon.release(); + expect(await selected).toBe(chat); + expect(daemon.attaches).toEqual([chat]); +}); + +test("bound to its chat, the adopted page never falls back to another one", async () => { + const chat = "adopt-bound"; + const daemon = new Daemon([chat, "newest"]); + const page = await spare(daemon); + page.client.bindToSession(chat); + expect(await page.client.select(chat)).toBe(chat); + expect(page.last()?.sessionId).toBe(chat); + + // The chat goes away while shown (closed on another device). + daemon.purge(chat); + await flush(); + expect(page.last()?.sessionId).toBeUndefined(); + expect(daemon.attaches).not.toContain("newest"); +}); From dd53ecf4e74a85ad045a75ce27136aff293ac637 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 01:55:12 +0000 Subject: [PATCH 02/12] agent pane: adopted spare queues until connected, restores the scroll anchor, says a missing chat is missing Review (codex gpt-6-astra) findings on the adoption path: - an adoption that arrives before the page client connects runs once its actions are in; - the chat opens where the reader left it (chat.scrollAnchor.get, as a cold open); - a chat the daemon does not list is the tab's missing chat, and a failed open with nothing drawn says it failed. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/App.tsx | 63 ++++++++++++++++----- webviews/src/agent-session/acpmux/direct.ts | 42 ++++++++++++++ webviews/test/spare-session-adopt.test.ts | 26 ++++++++- 3 files changed, 115 insertions(+), 16 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index bad65022a697..1230082c2d3d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7132,26 +7132,48 @@ function AcpmuxPane() { // drawing the host's kept tail when there is one). The New Tab screen goes in the event's own render, // and the chat's first drawn frame is reported as this page's first paint again: the host keeps // the page out of sight until then. - const adoptSession = useCallback(({ sessionId, revealTurn }: SessionAdoption) => { - const client = directClient.current; - const select = window.cmuxAcpmuxActions?.["chat.select"]; - if (!client || !select) return; - client.bindToSession(sessionId); - paintReported.current = false; - setAdoptingSession(sessionId); - setAwaitsChat(true); - setNewTab(undefined); - setNewSession(false); - setPastTurns(undefined); - setProjectDraft(undefined); - void select({ sessionId }).then( - () => { - if (revealTurn) revealRef.current(revealTurn); + // An adoption that came before this page's client connected (the host takes a spare whose New Tab + // frame is drawn, which can be before its connect answers) runs once the client's actions are in. + const pendingAdoption = useRef(undefined); + const runAdoption = useCallback(async ({ sessionId, revealTurn }: SessionAdoption) => { + // Where the reader left this chat, before its rows draw, as a tab opened on it reads it. + const anchor = await fetchScrollAnchor(sessionId); + const adopt = window.cmuxAcpmuxActions?.["chat.adopt"]; + if (!adopt) { + pendingAdoption.current = { sessionId, ...(revealTurn ? { revealTurn } : {}) }; + return; + } + setScrollAnchor(anchor); + await adopt({ sessionId }).then( + (selected) => { + if (selected && revealTurn) revealRef.current(revealTurn); + // A chat the daemon does not list draws why, which is this page's first paint too. + if (!selected) setAdoptingSession((current) => (current === sessionId ? undefined : current)); }, // A chat that could not be read says so in the chat view (a missing chat, a failed open). () => setAdoptingSession((current) => (current === sessionId ? undefined : current)), ); }, []); + const runPendingAdoption = useRef(() => {}); + runPendingAdoption.current = () => { + const pending = pendingAdoption.current; + pendingAdoption.current = undefined; + if (pending && directClient.current?.selectedSession !== pending.sessionId) void runAdoption(pending); + }; + const adoptSession = useCallback( + (adoption: SessionAdoption) => { + directClient.current?.bindToSession(adoption.sessionId); + paintReported.current = false; + setAdoptingSession(adoption.sessionId); + setAwaitsChat(true); + setNewTab(undefined); + setNewSession(false); + setPastTurns(undefined); + setProjectDraft(undefined); + void runAdoption(adoption); + }, + [runAdoption], + ); useSessionAdoption(adoptSession); // What this frame drew, not only that it drew: a chat taken from a kept tail is on screen in the // frame the open landed on, so the host shows the page instead of fading it in (tailCache.ts, @@ -8141,6 +8163,15 @@ function AcpmuxPane() { const ids = Array.isArray(sessionIds) ? sessionIds : [sessionId]; for (const id of ids) if (id) client.prefetch(String(id)); }, + // The host's spare page taking a tab's chat (adoptSession, above): opened as a pick is. + "chat.adopt": async ({ sessionId }) => { + newTabStartGeneration.current += 1; + harnessSwitch.cancel(); + const selected = await client.adoptSession(String(sessionId)); + if (!selected || client.attachedSession() !== selected) return undefined; + await persistSession(selected); + return selected; + }, "chat.select": async ({ sessionId }) => { newTabStartGeneration.current += 1; harnessSwitch.cancel(); @@ -8507,6 +8538,8 @@ function AcpmuxPane() { }; }, }; + // An adoption that came while this page was still connecting runs on this client. + runPendingAdoption.current(); // The native context menu's Copy Message and Fork from Here act on the row under the pointer. setMessageMenuSource((rowId) => snapshotRef.current ? messageMenuTarget(snapshotRef.current, rowId, opensSideChat.current) : undefined, diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index f384e7df89bb..d88f8ebf3f58 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1563,6 +1563,48 @@ export class AcpmuxDirectClient { this.host = { ...this.host, sessionId, newSession: false, sessionMustExist: true }; } + /// The adopted page's switch to its chat: bound to it (`bindToSession`), then opened as + /// `chat.select` opens a chat. A chat the daemon does not list is the tab's missing chat, said why + /// in the pane (chatUnavailable.ts) as a tab whose handshake named it says it, never an empty + /// chat; an open that fails with nothing drawn says it failed. Its answer is `select`'s. + async adoptSession(sessionId: string): Promise { + this.bindToSession(sessionId); + const listed = () => this.sessions.some((session) => session.sessionId === sessionId); + // The list this page holds may predate the chat (it started after the page connected). + if (!listed()) await this.refreshSessions().catch(() => undefined); + if (this.closed) return undefined; + if (!listed()) { + this.rememberCurrentTail(); + this.selectedSessionId = undefined; + this.selectionGeneration += 1; + this.resetSessionState(); + this.noteMissing(sessionId); + this.missingReason = "notListedAtOpen"; + // The machine the chat was on, from what the app host kept of it (another tab read it). + if (this.missingPeer === undefined) { + await readHostTail(sessionId).catch(() => undefined); + if (this.missingSession === sessionId) this.missingPeer = keptPeer(sessionId); + } + this.emit(); + void this.refreshPeers() + .catch(() => undefined) + .then(() => { + if (!this.closed && !this.snapshotHold) this.emit(); + }); + return undefined; + } + const generation = this.selectionGeneration + 1; + try { + return await this.select(sessionId); + } catch (error) { + // select() drops a kept tail it drew and says so itself; with nothing drawn the pane says + // the open failed here, for the chat this adoption still shows. + if (!this.closed && generation === this.selectionGeneration && this.selectedSessionId === sessionId && !this.drewKeptTail) + this.emit(this.socket?.readyState === WebSocket.OPEN ? "failed" : "disconnected"); + throw error; + } + } + /// Hands the chat on screen to the host now, for a tab about to open it (`chat.openTab`). The /// page keeps it selected, so nothing else would hand it over and the new tab would open cold — /// the flow this cache exists for. diff --git a/webviews/test/spare-session-adopt.test.ts b/webviews/test/spare-session-adopt.test.ts index dc4612cffb37..0c14aece0aae 100644 --- a/webviews/test/spare-session-adopt.test.ts +++ b/webviews/test/spare-session-adopt.test.ts @@ -9,7 +9,9 @@ // - the New Tab page's client, bound to the chat, draws the chat the host kept before its attach // answers, and reports it read for that chat (the page's first paint, App.tsx `adoptionDrawn`); // - bound, it is that tab's page: a chat the daemon drops is called missing, never replaced by -// another chat, as a page whose handshake named the session (`sessionMustExist`). +// another chat, as a page whose handshake named the session (`sessionMustExist`); +// - a chat the daemon does not list when the page adopts it is the tab's missing chat (the pane +// says why, chatUnavailable.ts), never an empty chat, and nothing is attached for it. import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; import { AcpmuxDirectClient } from "../src/agent-session/acpmux/direct"; @@ -223,3 +225,25 @@ test("bound to its chat, the adopted page never falls back to another one", asyn expect(page.last()?.sessionId).toBeUndefined(); expect(daemon.attaches).not.toContain("newest"); }); + +test("a chat the daemon does not list is the adopted tab's missing chat, never an empty one", async () => { + const chat = "adopt-gone"; + const daemon = new Daemon(["newest"]); + const page = await spare(daemon); + expect(await page.client.adoptSession(chat)).toBeUndefined(); + await flush(); + expect(page.last()?.sessionId).toBeUndefined(); + expect(page.last()?.missingSession).toBe(chat); + expect(page.last()?.missingReason).toBe("notListedAtOpen"); + expect(daemon.attaches).toEqual([]); +}); + +test("a listed chat the page adopts opens as a pick opens it", async () => { + const chat = "adopt-listed"; + const daemon = new Daemon([chat, "newest"]); + const page = await spare(daemon); + expect(await page.client.adoptSession(chat)).toBe(chat); + expect(page.last()?.sessionId).toBe(chat); + expect(page.last()?.missingSession).toBeUndefined(); + expect(daemon.attaches).toEqual([chat]); +}); From f05de46fb4e1ef8dfa7d5d10fa5cd8c39abbd9dc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 05:17:13 +0000 Subject: [PATCH 03/12] agent pane: an adoption gives way to a newer pick, and keeps its place and turn after a reconnect Second review (codex gpt-6-astra): an adoption whose anchor read finished after another chat was picked opened over it; one that a reconnect had already opened lost its subagent turn and scroll anchor. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/App.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 410d5e3dc4f0..bd9a21b19eda 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7164,9 +7164,19 @@ function AcpmuxPane() { // An adoption that came before this page's client connected (the host takes a spare whose New Tab // frame is drawn, which can be before its connect answers) runs once the client's actions are in. const pendingAdoption = useRef(undefined); + // The newest adoption: an older one still reading its anchor never opens over it. + const adoptionTicket = useRef(0); const runAdoption = useCallback(async ({ sessionId, revealTurn }: SessionAdoption) => { + const ticket = ++adoptionTicket.current; + const before = directClient.current?.selectedSession; // Where the reader left this chat, before its rows draw, as a tab opened on it reads it. const anchor = await fetchScrollAnchor(sessionId); + // Another chat was picked while the anchor was read (a page action, automation): it stands. + if (ticket !== adoptionTicket.current || directClient.current?.selectedSession !== before) { + if (ticket === adoptionTicket.current) + setAdoptingSession((current) => (current === sessionId ? undefined : current)); + return; + } const adopt = window.cmuxAcpmuxActions?.["chat.adopt"]; if (!adopt) { pendingAdoption.current = { sessionId, ...(revealTurn ? { revealTurn } : {}) }; @@ -7187,7 +7197,17 @@ function AcpmuxPane() { runPendingAdoption.current = () => { const pending = pendingAdoption.current; pendingAdoption.current = undefined; - if (pending && directClient.current?.selectedSession !== pending.sessionId) void runAdoption(pending); + if (!pending) return; + if (directClient.current?.selectedSession !== pending.sessionId) { + void runAdoption(pending); + return; + } + // A reconnect opened the chat already (the host bound the tab to it): what the adoption still + // carries is its place and its turn (a subagent's pane). + void fetchScrollAnchor(pending.sessionId).then((anchor) => { + if (directClient.current?.selectedSession === pending.sessionId) setScrollAnchor(anchor); + }); + if (pending.revealTurn) revealRef.current(pending.revealTurn); }; const adoptSession = useCallback( (adoption: SessionAdoption) => { From 57209f4a3b5e73c152b1f21c169b40bc27762b9c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 06:16:05 +0000 Subject: [PATCH 04/12] agent pane: an adoption a connect already opened keeps its place and turn Third review (codex gpt-6-astra): the anchor-read fence took a connect that opened the adopted chat itself for a newer pick, and dropped the adoption's subagent turn and anchor. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/App.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index bd9a21b19eda..42fac39a07b4 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7171,10 +7171,17 @@ function AcpmuxPane() { const before = directClient.current?.selectedSession; // Where the reader left this chat, before its rows draw, as a tab opened on it reads it. const anchor = await fetchScrollAnchor(sessionId); - // Another chat was picked while the anchor was read (a page action, automation): it stands. - if (ticket !== adoptionTicket.current || directClient.current?.selectedSession !== before) { - if (ticket === adoptionTicket.current) - setAdoptingSession((current) => (current === sessionId ? undefined : current)); + if (ticket !== adoptionTicket.current) return; + const now = directClient.current?.selectedSession; + if (now !== before) { + // A connect or reconnect opened this chat meanwhile (the host bound the tab to it): what the + // adoption still carries is its place and its turn (a subagent's pane). + if (now === sessionId) { + setScrollAnchor(anchor); + if (revealTurn) revealRef.current(revealTurn); + } + // Another chat was picked while the anchor was read (a page action, automation): it stands. + else setAdoptingSession((current) => (current === sessionId ? undefined : current)); return; } const adopt = window.cmuxAcpmuxActions?.["chat.adopt"]; From cffa960af123dd7f62638b79816c24d14fdb613b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 04:32:14 +0000 Subject: [PATCH 05/12] fix(agent-pane): an adopted chat the page's list lacks opens on its attach Adoption awaited a full list reread before select() for a chat the spare's list lacked, so under a busy acpmux an older chat waited seconds for every list page. It now opens on its attach, as a cold open does (attachedBeforeList), with the list read again behind it; the list decides the chat is missing only when the attach fails. Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/direct.ts | 57 ++++++++++++--------- webviews/test/spare-session-adopt.test.ts | 32 ++++++++++-- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 823f6fad9fbd..00e818a2a6f3 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1901,41 +1901,50 @@ export class AcpmuxDirectClient { async adoptSession(sessionId: string): Promise { this.bindToSession(sessionId); const listed = () => this.sessions.some((session) => session.sessionId === sessionId); - // The list this page holds may predate the chat (it started after the page connected). - if (!listed()) await this.refreshSessions().catch(() => undefined); - if (this.closed) return undefined; - if (!listed()) { - this.rememberCurrentTail(); - this.selectedSessionId = undefined; - this.selectionGeneration += 1; - this.resetSessionState(); - this.noteMissing(sessionId); - this.missingReason = "notListedAtOpen"; - // The machine the chat was on, from what the app host kept of it (another tab read it). - if (this.missingPeer === undefined) { - await readHostTail(sessionId).catch(() => undefined); - if (this.missingSession === sessionId) this.missingPeer = keptPeer(sessionId); - } - this.emit(); - void this.refreshPeers() - .catch(() => undefined) - .then(() => { - if (!this.closed && !this.snapshotHold) this.emit(); - }); - return undefined; - } + // A chat this page's list lacks (it started after the page connected, or its row is on a page + // still being read) opens without waiting for the list: its attach proves it is there, as a + // cold open's does (`attachedBeforeList`), since a busy acpmux paces its whole list for seconds. + // The list is read again behind the open, and decides only when the attach fails. + const relisting = listed() ? undefined : this.refreshSessions().catch(() => false); const generation = this.selectionGeneration + 1; try { return await this.select(sessionId); } catch (error) { + if (relisting) await relisting; + if (this.closed) return undefined; + if (relisting && !listed() && generation === this.selectionGeneration && this.selectedSessionId === sessionId) + return this.adoptedMissing(sessionId); // select() drops a kept tail it drew and says so itself; with nothing drawn the pane says // the open failed here, for the chat this adoption still shows. - if (!this.closed && generation === this.selectionGeneration && this.selectedSessionId === sessionId && !this.drewKeptTail) + if (generation === this.selectionGeneration && this.selectedSessionId === sessionId && !this.drewKeptTail) this.emit(this.socket?.readyState === WebSocket.OPEN ? "failed" : "disconnected"); throw error; } } + /// The adopted tab's chat is one the daemon neither attaches nor lists: the pane says it is + /// missing (chatUnavailable.ts) as a tab whose handshake named it says it, never an empty chat. + private async adoptedMissing(sessionId: string): Promise { + this.rememberCurrentTail(); + this.selectedSessionId = undefined; + this.selectionGeneration += 1; + this.resetSessionState(); + this.noteMissing(sessionId); + this.missingReason = "notListedAtOpen"; + // The machine the chat was on, from what the app host kept of it (another tab read it). + if (this.missingPeer === undefined) { + await readHostTail(sessionId).catch(() => undefined); + if (this.missingSession === sessionId) this.missingPeer = keptPeer(sessionId); + } + this.emit(); + void this.refreshPeers() + .catch(() => undefined) + .then(() => { + if (!this.closed && !this.snapshotHold) this.emit(); + }); + return undefined; + } + /// Hands the chat on screen to the host now, for a tab about to open it (`chat.openTab`). The /// page keeps it selected, so nothing else would hand it over and the new tab would open cold — /// the flow this cache exists for. diff --git a/webviews/test/spare-session-adopt.test.ts b/webviews/test/spare-session-adopt.test.ts index 0c14aece0aae..a3390eb79ce0 100644 --- a/webviews/test/spare-session-adopt.test.ts +++ b/webviews/test/spare-session-adopt.test.ts @@ -10,8 +10,10 @@ // answers, and reports it read for that chat (the page's first paint, App.tsx `adoptionDrawn`); // - bound, it is that tab's page: a chat the daemon drops is called missing, never replaced by // another chat, as a page whose handshake named the session (`sessionMustExist`); -// - a chat the daemon does not list when the page adopts it is the tab's missing chat (the pane -// says why, chatUnavailable.ts), never an empty chat, and nothing is attached for it. +// - a chat the page's list lacks opens on its attach, without waiting for the list to be read +// again (a busy acpmux paces its whole list for seconds); +// - a chat the daemon neither attaches nor lists when the page adopts it is the tab's missing chat +// (the pane says why, chatUnavailable.ts), never an empty chat. import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; import { AcpmuxDirectClient } from "../src/agent-session/acpmux/direct"; @@ -72,6 +74,8 @@ class Daemon { onclose: (() => void) | null = null; onmessage: ((message: { data: string }) => void) | null = null; hold?: string; + /// Lists asked while set are never answered: a list acpmux is still pacing. + holdLists = false; attaches: string[] = []; private held?: Message; private holding?: () => void; @@ -89,6 +93,7 @@ class Daemon { if (message.id === undefined) return; const sessionId = String(message.params?.sessionId ?? ""); if (message.method === "_acpmux/attach") this.attaches.push(sessionId); + if (message.method === "_acpmux/watch" && this.holdLists) return; if (message.method === "_acpmux/attach" && sessionId === this.hold) { this.held = message; this.hold = undefined; @@ -114,7 +119,13 @@ class Daemon { if (message.method === "initialize") result = { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; else if (message.method === "_acpmux/watch") result = { sessions: this.listed.map(summary) }; - else if (message.method === "_acpmux/attach") { + else if (message.method === "_acpmux/attach" && !this.listed.includes(sessionId)) { + // acpmux refuses an attach of a chat it lacks. + this.onmessage?.({ + data: JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "unknown session" } }), + }); + return; + } else if (message.method === "_acpmux/attach") { const events = chatEvents(sessionId, `Reply of ${sessionId}.`); result = { session: summary(sessionId), lastSeq: events.length, events, hasMore: false }; } else if (message.method === "_acpmux/events") result = { events: [], hasMore: false, lastSeq: 0 }; @@ -235,7 +246,20 @@ test("a chat the daemon does not list is the adopted tab's missing chat, never a expect(page.last()?.sessionId).toBeUndefined(); expect(page.last()?.missingSession).toBe(chat); expect(page.last()?.missingReason).toBe("notListedAtOpen"); - expect(daemon.attaches).toEqual([]); + expect(daemon.attaches).toEqual([chat]); +}); + +test("a chat the page's list lacks opens on its attach, without waiting for the list", async () => { + const chat = "adopt-later"; + const daemon = new Daemon(["newest"]); + const page = await spare(daemon); + // The chat started after the page read its list, and acpmux is slow to list again. + daemon.listed.push(chat); + daemon.holdLists = true; + expect(await page.client.adoptSession(chat)).toBe(chat); + expect(page.last()?.sessionId).toBe(chat); + expect(page.last()?.missingSession).toBeUndefined(); + expect(daemon.attaches).toEqual([chat]); }); test("a listed chat the page adopts opens as a pick opens it", async () => { From ebd97da5d2a4ec107761753332ac5fcae6412179 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 14:48:42 +0000 Subject: [PATCH 06/12] agent pane: an existing chat takes the spare only once its client is connected The spare served a tab opening an existing chat as soon as it had a handshake and a drawn New Tab frame, which come before its acpmux client connects. An adoption then queued behind the spare's whole first chat list (a busy acpmux paces it for seconds), where a cold page's open attaches the chat before its list (askEarly). Review of #27759 at cffa960a (BLOCK, P1). - The page reports its client connected or lost (`pane.clientReady`); a new handshake starts it over. NewTabSparePool.servesChat requires it. - No chat takes the spare in Low Power Mode or at serious thermal state: each would make another page. The tab opens cold, and the spare stays for New Tab. - A spare an existing chat took is replaced once input is quiet (750 ms), not on the next main-actor turn, so the new page's load does not land in that chat's open or the next sidebar clicks. New Tab and new workspace still refill at once. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../CmuxNextAgentPane/AgentPageProvider.swift | 2 +- .../AgentPaneHandshakeReply.swift | 2 + .../CmuxNextAgentPane/AgentPaneModel.swift | 9 +++- .../AgentPaneRequest+Decoding.swift | 6 +++ .../AgentPaneRequest+SafeDescription.swift | 1 + .../CmuxNextAgentPane/AgentPaneRequest.swift | 5 +++ .../CmuxNextApp/NewTab/NewTabSparePool.swift | 38 +++++++++++++---- .../SpareSessionAdoptionTests.swift | 41 ++++++++++++++++++- webviews/src/agent-session/acpmux/App.tsx | 5 +++ webviews/src/dev-host/host.ts | 1 + 10 files changed, 100 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index 4540c1de5bbb..263728226d9e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -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", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift index e28010c73aaa..96ce7aba130d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift @@ -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 } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index eec22d13626a..4205f8ba8381 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -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)? @@ -340,7 +344,7 @@ 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 @@ -366,6 +370,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): diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 65b92ebdae10..a64e6b830956 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -171,6 +171,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": diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift index 9395b00c1e60..650f7b78f0cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift @@ -89,6 +89,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" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index cf8fb32feab3..7d91ba48fdc8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -240,6 +240,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) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift index 0fb1f4a8dbfa..b5de3146784c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift @@ -151,12 +151,17 @@ final class NewTabSparePool { self.record(.init(spare: spare != nil, crossWindow: spare?.crossWindow == true, milliseconds: Self.milliseconds(since: start))) return spare?.view } - // A tab opening an existing chat of this Mac adopts it only once it is connected and drew - // (SpareSessionAdoption): it switches to the chat in place. One still loading stays here. + // A tab opening an existing chat of this Mac adopts it only once its client is connected + // (SpareSessionAdoption): it switches to the chat in place. One still connecting stays here, + // and the tab opens cold as before: adopted, it would hold the open behind its chat list. services.agentTabs.takeConnectedSpare = { [weak self] in - guard let self, let spare = slot.peek, spare.model.hasHandshake, spare.model.hasPainted, + guard let self, let spare = slot.peek, + Self.servesChat(handshake: spare.model.hasHandshake, painted: spare.model.hasPainted, + clientReady: spare.model.clientReady, + lowPower: ProcessInfo.processInfo.isLowPowerModeEnabled, + thermal: ProcessInfo.processInfo.thermalState), let window = NSApp.keyWindow.flatMap(self.mainWindow) ?? self.target else { return nil } - return take(for: window, size: nil)?.view + return take(for: window, size: nil, refill: .whenQuiet)?.view } observeWindows() if let window = services.keyWindowSource().flatMap(mainWindow) ?? services.windows.controllers.compactMap(\.window).first(where: \.isVisible) { @@ -249,12 +254,31 @@ final class NewTabSparePool { fitTimer.schedule(after: Self.fitSettle) { @MainActor [weak self] in self?.fitParked() } } + /// When the next spare is made after one is handed out. + enum Refill { + /// On the next main-actor turn: a New Tab or new workspace, so the next Cmd-T finds one. + case now + /// Once input is quiet (``idleInput``): an existing chat took it, and its page is still + /// attaching that chat. A new page's load on the main thread would land in that open, + /// and in the next clicks of someone going down the sidebar. + case whenQuiet + } + + /// Whether the parked spare may serve a tab opening an existing chat (``SpareSessionAdoption``): + /// it answered its handshake, drew, and its client is connected (`pane.clientReady`). Not in + /// Low Power Mode or at serious thermal state: each such open would take the spare and make + /// another page; the tab opens cold instead, and the spare stays for New Tab. + nonisolated static func servesChat(handshake: Bool, painted: Bool, clientReady: Bool, lowPower: Bool, + thermal: ProcessInfo.ThermalState) -> Bool { + handshake && painted && clientReady && !lowPower && thermal.rawValue < ProcessInfo.ThermalState.serious.rawValue + } + /// The spare for a new tab page in `window`, or nil (the page loads cold). /// A spare parked in another window is adopted all the same (a reparent). - /// The caller adopts it at once; the next spare follows when input is quiet. + /// The caller adopts it at once; the next spare follows as `refill` says. /// `refit` is true when the spare waited at another size than `size`, the one it shows at /// (nil: not known yet, Cmd-N): its adoption resizes it. - func take(for window: NSWindow?, size: NSSize?) -> (view: AgentPaneView, crossWindow: Bool, refit: Bool)? { + func take(for window: NSWindow?, size: NSSize?, refill: Refill = .now) -> (view: AgentPaneView, crossWindow: Bool, refit: Bool)? { usedThisSession = true observeWindows() if target == nil, let window { retarget(window) } @@ -262,7 +286,7 @@ final class NewTabSparePool { // Hand the parked page out now, then start making its replacement without waiting for // the user's input to go quiet. The replacement is background work on the next turn; // a later New Tab click therefore does not fall back to a cold mount. - defer { scheduleWarmImmediately() } + defer { if refill == .now { scheduleWarmImmediately() } else { scheduleWarm() } } snapshotGeneration &+= 1 view.redraw.newTabFrameCaptureChanged = nil view.isHidden = false diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift index 37ed9887334f..782356a63237 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift @@ -35,10 +35,49 @@ struct SpareSessionAdoptionTests { let reply = await spare.model.respond(to: AgentPaneRequest(body: ["method": "ready"])) try #require(reply["value"] is [String: Any], "\(reply)") _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.painted"])) - try #require(spare.model.hasHandshake && spare.model.hasPainted) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.clientReady", "params": ["ready": true]])) + try #require(spare.model.hasHandshake && spare.model.hasPainted && spare.model.clientReady) return spare } + /// The page says when its acpmux client is connected (`pane.clientReady`); a new handshake (a + /// reload, a reconnect) starts it over. Reporting it touches nothing: the spare stays untouched. + @Test func thePageReportsItsClientReady() async throws { + let tabs = Self.run(records: [:]) + let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) + #expect(AgentPaneRequest(body: ["method": "pane.clientReady", "params": ["ready": true]]) == .clientReady(true)) + #expect(AgentPaneRequest(body: ["method": "pane.clientReady", "params": [:]]) == .unsupported("pane.clientReady")) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "ready"])) + #expect(!spare.model.clientReady) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.clientReady", "params": ["ready": true]])) + #expect(spare.model.clientReady) + #expect(!spare.model.userTouched, "a ready report counted as the person's touch") + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.clientReady", "params": ["ready": false]])) + #expect(!spare.model.clientReady) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.clientReady", "params": ["ready": true]])) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "ready"])) + #expect(!spare.model.clientReady, "a new handshake kept the old connection's ready") + } + + /// HQ#5914 finding 1, review of #27759: a spare whose client is still connecting is not taken + /// for an existing chat (the open would wait behind its chat list), nor in Low Power Mode or + /// at serious thermal state (each open would make another page). + @Test func whichSpareServesAChat() { + func serves(handshake: Bool = true, painted: Bool = true, clientReady: Bool = true, lowPower: Bool = false, + thermal: ProcessInfo.ThermalState = .nominal) -> Bool { + NewTabSparePool.servesChat(handshake: handshake, painted: painted, clientReady: clientReady, + lowPower: lowPower, thermal: thermal) + } + #expect(serves()) + #expect(serves(thermal: .fair)) + #expect(!serves(handshake: false)) + #expect(!serves(painted: false)) + #expect(!serves(clientReady: false)) + #expect(!serves(lowPower: true)) + #expect(!serves(thermal: .serious)) + #expect(!serves(thermal: .critical)) + } + @Test func anExistingChatOpensOnTheConnectedSpare() async throws { let tabs = Self.run(records: ["tab_chat": Self.chat("s-chat")]) let spare = try await Self.connectedSpare(of: tabs) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 562bccca1b3b..939043182d4d 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -9788,6 +9788,8 @@ function AcpmuxPane() { catalogClientId.current += 1; directClient.current = undefined; delete window.cmuxAcpmuxActions; + // Until a client connects again, an open handed this page would wait for that connect. + if (!mock) void postNative("pane.clientReady", { ready: false }).catch(() => undefined); // Quiet while it comes back: nothing shows until the handshakes fail past the deadline. failingSince = Date.now(); retryTimer = window.setTimeout(() => void connectHost(), retryDelay); @@ -10609,6 +10611,9 @@ function AcpmuxPane() { }; // An adoption that came while this page was still connecting runs on this client. runPendingAdoption.current(); + // The host may now hand this page (a prewarmed New Tab page) to a tab opening an existing + // chat: its actions answer, so the switch attaches at once (NewTabSparePool.servesChat). + if (!mock) void postNative("pane.clientReady", { ready: true }).catch(() => undefined); // The native context menu's Copy Message and Fork from Here act on the row under the pointer. setMessageMenuSource((rowId) => snapshotRef.current ? messageMenuTarget(snapshotRef.current, rowId, opensSideChat.current) : undefined, diff --git a/webviews/src/dev-host/host.ts b/webviews/src/dev-host/host.ts index 8f71d925d08a..e3db4f066651 100644 --- a/webviews/src/dev-host/host.ts +++ b/webviews/src/dev-host/host.ts @@ -179,6 +179,7 @@ export function devHostReply(host: DevHostParams, request: Request): DevHostRepl case "chat.persistSession": case "pane.checkpointAvailability": case "pane.commandAvailability": + case "pane.clientReady": case "pane.renderRate": case "pane.tabRowTools": case "newTab.remember": From d2ce415040b5d1cccc67f11954d49d79ffdfe6f8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 15:44:29 +0000 Subject: [PATCH 07/12] Trace why a sidebar open does or doesn't take the spare Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../CmuxNextApp/AgentTabs+Lifecycle.swift | 3 +++ .../CmuxNextApp/NewTab/NewTabSparePool.swift | 22 +++++++++++++++---- .../CmuxNextWakeups/ChatOpenTrace.swift | 10 ++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift index 91a23d4d5fab..55ec5bff5ede 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift @@ -2,6 +2,7 @@ import CmuxNextAgentPane import CmuxNextBrowser import CmuxNextCompat import CmuxNextDaemon +import CmuxNextWakeups import Foundation /// Opening agent chat tabs on the workspace store with zero wait, and letting their view state @@ -249,10 +250,12 @@ extension AgentTabStore { NotificationCenter.default.post(name: Notification.Name.browserChildWindowPagesNeedUpdate, object: window) } guard spare.adoptSession(session, revealTurn: revealTurn, moved: moved) else { + NewTabSparePool.traceSpare(.spareRefused) if let revealTurn { pendingTurns[key] = revealTurn } if recycle?(spare) != true { retirer.retire(spare) } return false } + NewTabSparePool.traceSpare(.spareAdopted) standaloneViews.remove(spare) (views[key], spare.model.allowsTabConversion) = (spare, true) wire(spare.model, key: key) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift index b5de3146784c..16c25df2ec0c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift @@ -155,12 +155,19 @@ final class NewTabSparePool { // (SpareSessionAdoption): it switches to the chat in place. One still connecting stays here, // and the tab opens cold as before: adopted, it would hold the open behind its chat list. services.agentTabs.takeConnectedSpare = { [weak self] in - guard let self, let spare = slot.peek, - Self.servesChat(handshake: spare.model.hasHandshake, painted: spare.model.hasPainted, + guard let self else { return nil } + guard let spare = slot.peek, + let window = NSApp.keyWindow.flatMap(self.mainWindow) ?? self.target else { + Self.traceSpare(.spareEmpty) + return nil + } + guard Self.servesChat(handshake: spare.model.hasHandshake, painted: spare.model.hasPainted, clientReady: spare.model.clientReady, lowPower: ProcessInfo.processInfo.isLowPowerModeEnabled, - thermal: ProcessInfo.processInfo.thermalState), - let window = NSApp.keyWindow.flatMap(self.mainWindow) ?? self.target else { return nil } + thermal: ProcessInfo.processInfo.thermalState) else { + Self.traceSpare(.spareUnready) + return nil + } return take(for: window, size: nil, refill: .whenQuiet)?.view } observeWindows() @@ -254,6 +261,13 @@ final class NewTabSparePool { fitTimer.schedule(after: Self.fitSettle) { @MainActor [weak self] in self?.fitParked() } } + /// The chat-open trace's account of what an existing chat's open found in the pool (bench builds). + static func traceSpare(_ mark: ChatOpenTrace.Mark) { + #if DEBUG || CMUX_BENCH_HOOKS + ChatOpenTrace.shared.mark(mark) + #endif + } + /// When the next spare is made after one is handed out. enum Refill { /// On the next main-actor turn: a New Tab or new workspace, so the next Cmd-T finds one. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift index 723db34f7c72..0e054f87700e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift @@ -36,7 +36,11 @@ public final class ChatOpenTrace { public enum Mark: Int, CaseIterable, Sendable { case action, paneMade, holdBegin, chromiumStart, cefWindowCreate, cefWindowCreated, browserCreated, setupDone, navigationStart, committed, firstContentfulPaint, pagePainted, panePainted, - loadingLeave, loadingLeft, holdEnd + loadingLeave, loadingLeft, holdEnd, + // Whether the open's tab took the prewarmed page (`AgentTabStore.view(for:)`, + // NewTabSparePool.servesChat): adopted it; found none parked; found one not ready (still + // connecting, Low Power Mode, thermal); or the page refused (a person touched it). + spareAdopted, spareEmpty, spareUnready, spareRefused /// The mark's name in `debug.chat_open_trace` results. public var name: String { @@ -57,6 +61,10 @@ public final class ChatOpenTrace { case .loadingLeave: "loading_leave" case .loadingLeft: "loading_left" case .holdEnd: "hold_end" + case .spareAdopted: "spare_adopted" + case .spareEmpty: "spare_empty" + case .spareUnready: "spare_unready" + case .spareRefused: "spare_refused" } } } From d0e4cdaa0211fb20ba0565194e695dde37fd89e6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 17:09:16 +0000 Subject: [PATCH 08/12] Trace why the spare refused an existing chat Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../Sources/CmuxNextApp/AgentTabs+Lifecycle.swift | 8 +++++++- .../Sources/CmuxNextApp/Control/DebugChatOpenTrace.swift | 1 + .../Sources/CmuxNextApp/NewTab/NewTabSparePool.swift | 5 +++-- .../CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift | 8 ++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift index 55ec5bff5ede..16c09ebcba33 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift @@ -250,7 +250,7 @@ extension AgentTabStore { NotificationCenter.default.post(name: Notification.Name.browserChildWindowPagesNeedUpdate, object: window) } guard spare.adoptSession(session, revealTurn: revealTurn, moved: moved) else { - NewTabSparePool.traceSpare(.spareRefused) + NewTabSparePool.traceSpare(.spareRefused, note: Self.spareRefusal(spare.model)) if let revealTurn { pendingTurns[key] = revealTurn } if recycle?(spare) != true { retirer.retire(spare) } return false @@ -262,6 +262,12 @@ extension AgentTabStore { 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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugChatOpenTrace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugChatOpenTrace.swift index 6e06f903ef47..1b64300ac0b8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugChatOpenTrace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugChatOpenTrace.swift @@ -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, ]) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift index 16c25df2ec0c..cda70b4ec1ec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift @@ -157,7 +157,7 @@ final class NewTabSparePool { services.agentTabs.takeConnectedSpare = { [weak self] in guard let self else { return nil } guard let spare = slot.peek, - let window = NSApp.keyWindow.flatMap(self.mainWindow) ?? self.target else { + let window = self.services.keyWindowSource().flatMap(self.mainWindow) ?? self.target else { Self.traceSpare(.spareEmpty) return nil } @@ -262,9 +262,10 @@ final class NewTabSparePool { } /// The chat-open trace's account of what an existing chat's open found in the pool (bench builds). - static func traceSpare(_ mark: ChatOpenTrace.Mark) { + static func traceSpare(_ mark: ChatOpenTrace.Mark, note: @autoclosure () -> String? = nil) { #if DEBUG || CMUX_BENCH_HOOKS ChatOpenTrace.shared.mark(mark) + if let note = note() { ChatOpenTrace.shared.note(note) } #endif } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift index 0e054f87700e..3f6872585a03 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/ChatOpenTrace.swift @@ -104,6 +104,8 @@ public final class ChatOpenTrace { /// Each change of the bound pane's surface: uptime, surface. var frames: [(TimeInterval, Surface)] = [] public internal(set) var frameCount = 0 + /// Free text a step of the open left (why the prewarmed spare was not adopted, say). + public internal(set) var note: String? /// When sampling stopped (finish or ``frameCap``): an empty run still on screen then ends here. var endedAt: TimeInterval? @@ -181,6 +183,12 @@ public final class ChatOpenTrace { if mark == .loadingLeft || mark == .holdEnd { resumeIfShown() } } + /// Records `text` as the running trace's note, the first one only. + public func note(_ text: String) { + guard isArmed, current?.note == nil else { return } + current?.note = text + } + /// Records `mark` of `page` (an agent page): the first one to mark binds the trace. public func mark(_ mark: Mark, page: AnyObject) { guard isArmed else { return } From 926fb4f9519078218ccb175df68501a003669fb8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 17:53:14 +0000 Subject: [PATCH 09/12] The composer's audio report no longer marks the spare touched Every sidebar open refused the prewarmed spare as touched by dictation: the composer reports dictation.audio on mount, which is boot traffic. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../Sources/CmuxNextAgentPane/AgentPaneModel.swift | 2 ++ .../CmuxNextAppTests/SpareSessionAdoptionTests.swift | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 4205f8ba8381..77c19e463a0b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -348,6 +348,8 @@ public final class AgentPaneModel { .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): boot traffic too. + case .dictation(.recordAudio): break case .reply(let reply) where reply.isPassive: break default: if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift index 782356a63237..4a00eeaacec5 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift @@ -59,6 +59,17 @@ struct SpareSessionAdoptionTests { #expect(!spare.model.clientReady, "a new handshake kept the old connection's ready") } + /// HQ#5914 finding 1: every sidebar open refused the spare as "touched by dictation": the + /// composer's mount reports whether its harness takes audio, which is no touch of the person's. + @Test func theComposersAudioReportLeavesTheSpareUntouched() async throws { + let tabs = Self.run(records: [:]) + let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "dictation.audio", "params": ["enabled": true]])) + #expect(!spare.model.userTouched, "touched by \(spare.model.touchedBy ?? "?")") + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "dictation.toggle"])) + #expect(spare.model.userTouched, "starting dictation is the person's touch") + } + /// HQ#5914 finding 1, review of #27759: a spare whose client is still connecting is not taken /// for an existing chat (the open would wait behind its chat list), nor in Low Power Mode or /// at serious thermal state (each open would make another page). From 0381186bd966c2db14552f463ba5fa920f72fab4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 19:45:11 +0000 Subject: [PATCH 10/12] The spare's trace calls compile in Release, which has no ChatOpenTrace Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../CmuxNextApp/AgentTabs+Lifecycle.swift | 5 ++--- .../CmuxNextApp/NewTab/NewTabSparePool.swift | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift index 16c09ebcba33..73d3158ad1de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Lifecycle.swift @@ -2,7 +2,6 @@ import CmuxNextAgentPane import CmuxNextBrowser import CmuxNextCompat import CmuxNextDaemon -import CmuxNextWakeups import Foundation /// Opening agent chat tabs on the workspace store with zero wait, and letting their view state @@ -250,12 +249,12 @@ extension AgentTabStore { NotificationCenter.default.post(name: Notification.Name.browserChildWindowPagesNeedUpdate, object: window) } guard spare.adoptSession(session, revealTurn: revealTurn, moved: moved) else { - NewTabSparePool.traceSpare(.spareRefused, note: Self.spareRefusal(spare.model)) + 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(.spareAdopted) + NewTabSparePool.traceSpare(.adopted) standaloneViews.remove(spare) (views[key], spare.model.allowsTabConversion) = (spare, true) wire(spare.model, key: key) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift index cda70b4ec1ec..5839c2d69058 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/NewTab/NewTabSparePool.swift @@ -158,14 +158,14 @@ final class NewTabSparePool { guard let self else { return nil } guard let spare = slot.peek, let window = self.services.keyWindowSource().flatMap(self.mainWindow) ?? self.target else { - Self.traceSpare(.spareEmpty) + Self.traceSpare(.empty) return nil } guard Self.servesChat(handshake: spare.model.hasHandshake, painted: spare.model.hasPainted, clientReady: spare.model.clientReady, lowPower: ProcessInfo.processInfo.isLowPowerModeEnabled, thermal: ProcessInfo.processInfo.thermalState) else { - Self.traceSpare(.spareUnready) + Self.traceSpare(.unready) return nil } return take(for: window, size: nil, refill: .whenQuiet)?.view @@ -261,9 +261,19 @@ final class NewTabSparePool { fitTimer.schedule(after: Self.fitSettle) { @MainActor [weak self] in self?.fitParked() } } - /// The chat-open trace's account of what an existing chat's open found in the pool (bench builds). - static func traceSpare(_ mark: ChatOpenTrace.Mark, note: @autoclosure () -> String? = nil) { + /// What an existing chat's open found in the pool. + enum SpareOutcome { case adopted, empty, unready, refused } + + /// The chat-open trace's account of `outcome` (bench and debug builds only; a no-op in Release, + /// which has no ``ChatOpenTrace``). + static func traceSpare(_ outcome: SpareOutcome, note: @autoclosure () -> String? = nil) { #if DEBUG || CMUX_BENCH_HOOKS + let mark: ChatOpenTrace.Mark = switch outcome { + case .adopted: .spareAdopted + case .empty: .spareEmpty + case .unready: .spareUnready + case .refused: .spareRefused + } ChatOpenTrace.shared.mark(mark) if let note = note() { ChatOpenTrace.shared.note(note) } #endif From 2f283ca30810f622ed88622cf858c0beff676c4d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 20:46:15 +0000 Subject: [PATCH 11/12] Reading the header state on mount no longer marks the spare touched The same-host bench showed every open refusing the spare as touched by tabState once dictation.audio was passive. The header's and project notice's mount reads are boot traffic; touchedBy now lists every touching request, so one trace names them all. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../Sources/CmuxNextAgentPane/AgentPaneModel.swift | 10 +++++++--- .../CmuxNextAppTests/SpareSessionAdoptionTests.swift | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 4a6c86f512f2..30b40a77b438 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -357,12 +357,16 @@ public final class AgentPaneModel { .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): boot traffic too. - case .dictation(.recordAudio): break + // The composer reports whether its harness takes audio on every mount (dictation.ts), and the + // header and project notice read their state on mount: boot traffic too (HQ#5914). + case .dictation(.recordAudio), .tabState, .projectInstructions: 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 { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift index 4a00eeaacec5..fb67b676746f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift @@ -59,13 +59,17 @@ struct SpareSessionAdoptionTests { #expect(!spare.model.clientReady, "a new handshake kept the old connection's ready") } - /// HQ#5914 finding 1: every sidebar open refused the spare as "touched by dictation": the - /// composer's mount reports whether its harness takes audio, which is no touch of the person's. - @Test func theComposersAudioReportLeavesTheSpareUntouched() async throws { + /// HQ#5914 finding 1: every sidebar open refused the spare as "touched by dictation", then by + /// "tabState": the composer's mount reports whether its harness takes audio and the header + /// reads its state, neither a touch of the person's. + @Test func theBootReportsLeaveTheSpareUntouched() async throws { let tabs = Self.run(records: [:]) let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "dictation.audio", "params": ["enabled": true]])) #expect(!spare.model.userTouched, "touched by \(spare.model.touchedBy ?? "?")") + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.tabState"])) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.tabState", "params": ["refresh": true]])) + #expect(!spare.model.userTouched, "touched by \(spare.model.touchedBy ?? "?")") _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "dictation.toggle"])) #expect(spare.model.userTouched, "starting dictation is the person's touch") } From 098aec3a4019b5e9c104572a5b496355b3feac77 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 21:58:13 +0000 Subject: [PATCH 12/12] The New Tab page's project list on mount no longer marks the spare touched After tabState went passive, the same-host bench showed every open refusing the spare as touched by listProjects alone. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-63b9964940c20354b639 --- .../Sources/CmuxNextAgentPane/AgentPaneModel.swift | 4 ++-- .../Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 30b40a77b438..90649761c93c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -358,8 +358,8 @@ public final class AgentPaneModel { .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 and project notice read their state on mount: boot traffic too (HQ#5914). - case .dictation(.recordAudio), .tabState, .projectInstructions: break + // 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: diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift index fb67b676746f..6af7b0de10b6 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SpareSessionAdoptionTests.swift @@ -59,9 +59,9 @@ struct SpareSessionAdoptionTests { #expect(!spare.model.clientReady, "a new handshake kept the old connection's ready") } - /// HQ#5914 finding 1: every sidebar open refused the spare as "touched by dictation", then by - /// "tabState": the composer's mount reports whether its harness takes audio and the header - /// reads its state, neither a touch of the person's. + /// HQ#5914 finding 1: every sidebar open refused the spare as "touched by dictation", then + /// "tabState", then "listProjects": the composer's mount reports whether its harness takes + /// audio, the header reads its state and the page lists projects, none a touch of the person's. @Test func theBootReportsLeaveTheSpareUntouched() async throws { let tabs = Self.run(records: [:]) let spare = try #require(tabs.makeSpare(AgentPaneNewTab(kind: .agent))) @@ -69,6 +69,7 @@ struct SpareSessionAdoptionTests { #expect(!spare.model.userTouched, "touched by \(spare.model.touchedBy ?? "?")") _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.tabState"])) _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "pane.tabState", "params": ["refresh": true]])) + _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "project.list"])) #expect(!spare.model.userTouched, "touched by \(spare.model.touchedBy ?? "?")") _ = await spare.model.respond(to: AgentPaneRequest(body: ["method": "dictation.toggle"])) #expect(spare.model.userTouched, "starting dictation is the person's touch")