From 5b6fcc3442f61d54d3c6621d1c3471ddd1cbf043 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:53:23 -0700 Subject: [PATCH 1/5] cmux-next: debug.scene.render waits for the agent page to show on its final engine On a cold launch the agent page is to show in Chromium but Chromium has not attached; its hidden backing WKWebView is already in the window and loaded, so the scene settled with no page on screen and the snapshot answered "agent page was not composited" with 0 web views and 0 Chromium pages. The settle predicate now refuses a page whose Chromium is still starting, a Chromium page the snapshot cannot draw, and a page before its first paint. The settle deadline grows to 20 s so a cold Chromium start, or its 8 s fallback to WebKit, fits inside the route's 30 s deadline. The not-settled error names each agent page's state. Fixes https://github.com/manaflow-ai/cmux/issues/26468 Co-Authored-By: Claude Opus 5.5 --- .../AgentPaneView+DebugClick.swift | 12 +++++ .../Control/CaptureSceneRegistry.swift | 52 +++++++++++++------ .../CaptureScenePageCompositedTests.swift | 35 +++++++++++-- 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift index 1216eeafda33..a49008065264 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift @@ -1,5 +1,6 @@ #if DEBUG || CMUX_BENCH_HOOKS public import AppKit +import CmuxNextPages public import WebKit /// `debug.agent_pane click` and `hover` (DEBUG builds), on whichever host shows the chat: the @@ -57,6 +58,17 @@ extension AgentPaneView { /// Whether the chat shows in Chromium (its clicks go through DevTools, ``debugPress(x:y:)``). public var debugShowsChromium: Bool { page?.showsChromium == true } + /// Whether the chat is to show in Chromium but Chromium has not attached yet: the page shows + /// nothing until it does, or until it falls back to WebKit. + public var debugChromiumStarting: Bool { pageDrawsOverCovers && !debugShowsChromium } + + /// Whether the chat's Chromium page is one a window snapshot draws: in this window, not parked, + /// hidden or under a clear (alpha 0) ancestor, and with a size. + public var debugChromiumPageShown: Bool { + guard let page, let window = page.window else { return false } + return PageRegistry.shownChromiumPages(in: window).contains { $0.page === page } + } + /// The zoom the chat shows at: view points per page CSS pixel. public var debugPageZoom: Double { guard let page else { return webView.pageZoom * webView.magnification } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift index df769e3cc4ff..b95985cbdb8b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift @@ -76,15 +76,15 @@ final class CaptureSceneRegistry { && self.services.windows.registry.members(of: targetWindowID).count >= 3 && self.services.windows.controller(for: targetWindowID)?.sidebar.model.sections.flatMap(\.workspaces).contains { !$0.rowState.isPending } == true && self.services.showcase.agentTabs.values.contains { - guard let view = self.services.agentTabs.existingView($0) else { return false } - return Self.agentPageCanSettle(attached: view.webView.window != nil, - loading: view.webView.isLoading, - chromium: view.debugShowsChromium) + self.agentPageState(self.services.agentTabs.existingView($0)).ready } }, start: start, window: 100, - deadline: 5_000, + // A cold launch starts Chromium for the agent page; that start may take up to the page's + // Chromium deadline (8 s) before WebKit takes over and loads. The route's own 30 s + // deadline still leaves room for seeding, readiness and the snapshot after this. + deadline: 20_000, ) let frameStats = frames.stop() guard settled != nil else { @@ -96,6 +96,9 @@ final class CaptureSceneRegistry { "daemon_workspaces": .number(Double(services.daemon.store.workspaces.count)), "window_workspaces": .number(Double(services.windows.registry.members(of: targetWindowID).count)), "agent_tabs": .number(Double(services.showcase.agentTabs.count)), + "agent_pages": .array(services.showcase.agentTabs.values.map { + .string(agentPageState(services.agentTabs.existingView($0)).diagnostic) + }), ]) } @@ -207,11 +210,31 @@ final class CaptureSceneRegistry { (snapshot["webviews_composited"]?.intValue ?? 0) + (snapshot["chromium_pages_composited"]?.intValue ?? 0) > 0 } - /// A Chromium agent can have a live, ready DOM while its backing WKWebView still reports loading - /// an asynchronous resource. WebKit still needs to finish navigation before seed_rows runs; - /// the readiness probe below then checks body, transcript and composer for either engine. - static func agentPageCanSettle(attached: Bool, loading: Bool, chromium: Bool) -> Bool { - attached && (chromium || !loading) + /// Whether the agent page shows what a capture would draw: in the window, past its first paint, + /// and on its final engine. While Chromium starts (`chromiumStarting`) the page has no Chromium + /// session yet and its backing WKWebView is hidden but already loaded, so judging that view let + /// the first render after launch go ahead with no page on screen (#26468: "agent page was not + /// composited", 0 web views and 0 Chromium pages). A Chromium page must be one the snapshot + /// draws (`chromiumShown`); its backing WKWebView may still report loading an asynchronous + /// resource. A WebKit page must finish navigation before seed_rows runs. The readiness probe + /// then checks body, transcript and composer for either engine. + static func agentPageCanSettle(attached: Bool, loading: Bool, chromium: Bool, chromiumStarting: Bool, + chromiumShown: Bool, painted: Bool) -> Bool { + guard attached, painted, !chromiumStarting else { return false } + return chromium ? chromiumShown : !loading + } + + private func agentPageState(_ view: AgentPaneView?) -> (ready: Bool, diagnostic: String) { + guard let view else { return (false, "no view") } + let attached = view.webView.window != nil + let loading = view.webView.isLoading + let chromium = view.debugShowsChromium + let starting = view.debugChromiumStarting + let shown = view.debugChromiumPageShown + let painted = view.model.hasPainted + let ready = Self.agentPageCanSettle(attached: attached, loading: loading, chromium: chromium, + chromiumStarting: starting, chromiumShown: shown, painted: painted) + return (ready, "inWindow=\(attached),loading=\(loading),chromium=\(chromium),starting=\(starting),shown=\(shown),painted=\(painted)") } private func readinessIsComplete(_ readiness: CmuxNextSettings.JSONValue) -> Bool { @@ -455,17 +478,14 @@ extension CaptureSceneRegistry { let generatedOK = actualGenerated == expectedGeneratedTitle let actualGeneratedPreview = actualGenerated.map { String($0.prefix(32)) } ?? "-" let view = services.agentTabs.existingView(tabID) - let painted = view?.model.hasPainted == true - let inWindow = view?.webView.window != nil - let loading = view?.webView.isLoading == true - let pageReady = Self.agentPageCanSettle(attached: inWindow, loading: loading, chromium: view?.debugShowsChromium == true) + let (pageReady, page) = agentPageState(view) let sidebarRow = services.windows.controller(for: windowID)?.sidebar.model.sections.flatMap(\.workspaces) .first(where: { $0.id.rawValue == workspace.id }) let sidebarRowMatches = sidebarRow?.title == expected let pending = sidebarRow?.rowState.isPending == true let ready = workspace.name == expected && workspace.nameSource == expectedSource - && tabFound && sessionOK && hostKnown && generatedOK && painted && pageReady && sidebarRowMatches && !pending - let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),painted=\(painted),inWindow=\(inWindow),loading=\(loading),sidebarRow=\(sidebarRowMatches),pending=\(pending)" + && tabFound && sessionOK && hostKnown && generatedOK && pageReady && sidebarRowMatches && !pending + let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),\(page),sidebarRow=\(sidebarRowMatches),pending=\(pending)" return (ready, String(diagnostic.prefix(330))) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift index be7d5a7708a7..8d057cc657f5 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift @@ -30,12 +30,37 @@ struct CaptureScenePageCompositedTests { #expect(!CaptureSceneRegistry.pageComposited([:])) } + private func canSettle(attached: Bool = true, loading: Bool = false, chromium: Bool = false, + chromiumStarting: Bool = false, chromiumShown: Bool = false, painted: Bool = true) -> Bool { + CaptureSceneRegistry.agentPageCanSettle(attached: attached, loading: loading, chromium: chromium, + chromiumStarting: chromiumStarting, chromiumShown: chromiumShown, + painted: painted) + } + @Test func chromiumPageMaySettleWhileBackingWebViewLoads() { - #expect(CaptureSceneRegistry.agentPageCanSettle(attached: true, loading: true, chromium: true)) - #expect(CaptureSceneRegistry.agentPageCanSettle(attached: true, loading: false, chromium: true)) - #expect(!CaptureSceneRegistry.agentPageCanSettle(attached: true, loading: true, chromium: false)) - #expect(CaptureSceneRegistry.agentPageCanSettle(attached: true, loading: false, chromium: false)) - #expect(!CaptureSceneRegistry.agentPageCanSettle(attached: false, loading: true, chromium: true)) + #expect(canSettle(loading: true, chromium: true, chromiumShown: true)) + #expect(canSettle(loading: false, chromium: true, chromiumShown: true)) + #expect(!canSettle(loading: true)) + #expect(canSettle(loading: false)) + #expect(!canSettle(attached: false, loading: true, chromium: true, chromiumShown: true)) + } + + /// #26468: on a cold launch the agent page is to show in Chromium but Chromium has not attached. + /// Its hidden backing WKWebView is in the window and done loading, which used to settle the + /// scene with no page on screen, so the snapshot drew 0 web views and 0 Chromium pages. + @Test func aChromiumPageStillStartingDoesNotSettle() { + #expect(!canSettle(loading: false, chromiumStarting: true)) + #expect(!canSettle(loading: false, chromium: true, chromiumStarting: true, chromiumShown: true)) + } + + @Test func aChromiumPageTheSnapshotCannotDrawDoesNotSettle() { + // Attached but parked, hidden, under a clear paint hold or without a size. + #expect(!canSettle(chromium: true, chromiumShown: false)) + } + + @Test func aPageBeforeItsFirstPaintDoesNotSettle() { + #expect(!canSettle(loading: false, painted: false)) + #expect(!canSettle(chromium: true, chromiumShown: true, painted: false)) } } #endif From d2313f2e356e774ce03df4603cf1384cbf6ae720 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:26:18 -0700 Subject: [PATCH 2/5] cmux-next: allow cold agent paint in the auto-name capture scene --- .../CmuxNextApp/Control/CaptureSceneRegistry.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift index b95985cbdb8b..c447ef36ceab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift @@ -212,7 +212,7 @@ final class CaptureSceneRegistry { /// Whether the agent page shows what a capture would draw: in the window, past its first paint, /// and on its final engine. While Chromium starts (`chromiumStarting`) the page has no Chromium - /// session yet and its backing WKWebView is hidden but already loaded, so judging that view let + /// session yet and its backing WKWebView is hidden and never loads (so it is not loading); judging that view let /// the first render after launch go ahead with no page on screen (#26468: "agent page was not /// composited", 0 web views and 0 Chromium pages). A Chromium page must be one the snapshot /// draws (`chromiumShown`); its backing WKWebView may still report loading an asynchronous @@ -454,7 +454,7 @@ extension CaptureSceneRegistry { /// A frame-based condition wait: no sleeps or socket polling. private func autoNameSettled(_ ready: @escaping () -> Bool) async -> (Double?, CmuxNextSettings.JSONValue) { frames.start() - let settled = await frames.settled(until: ready, start: ContinuousClock.now, window: 100, deadline: 10_000) + let settled = await frames.settled(until: ready, start: ContinuousClock.now, window: 100, deadline: 20_000) return (settled, frames.stop()) } @@ -485,8 +485,8 @@ extension CaptureSceneRegistry { let pending = sidebarRow?.rowState.isPending == true let ready = workspace.name == expected && workspace.nameSource == expectedSource && tabFound && sessionOK && hostKnown && generatedOK && pageReady && sidebarRowMatches && !pending - let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),\(page),sidebarRow=\(sidebarRowMatches),pending=\(pending)" - return (ready, String(diagnostic.prefix(330))) + let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),sidebarRow=\(sidebarRowMatches),pending=\(pending),\(page)" + return (ready, String(diagnostic.prefix(420))) } } #endif From 9e033dd82ed72572fc3b1a9491f56c8eed7d427f Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:10:54 -0700 Subject: [PATCH 3/5] test: distinguish agent document paint from the loading cover timeout --- .../AgentPaneView+DebugClick.swift | 3 ++ .../Control/CaptureSceneRegistry.swift | 2 +- .../AgentPaneCapturePaintTests.swift | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCapturePaintTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift index a49008065264..813ba948211f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift @@ -62,6 +62,9 @@ extension AgentPaneView { /// nothing until it does, or until it falls back to WebKit. public var debugChromiumStarting: Bool { pageDrawsOverCovers && !debugShowsChromium } + /// Whether the document has painted, independently of the loading cover's give-up deadline. + public var debugPageHasPainted: Bool { model.hasPainted } + /// Whether the chat's Chromium page is one a window snapshot draws: in this window, not parked, /// hidden or under a clear (alpha 0) ancestor, and with a size. public var debugChromiumPageShown: Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift index c447ef36ceab..cad6d1040647 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift @@ -231,7 +231,7 @@ final class CaptureSceneRegistry { let chromium = view.debugShowsChromium let starting = view.debugChromiumStarting let shown = view.debugChromiumPageShown - let painted = view.model.hasPainted + let painted = view.debugPageHasPainted let ready = Self.agentPageCanSettle(attached: attached, loading: loading, chromium: chromium, chromiumStarting: starting, chromiumShown: shown, painted: painted) return (ready, "inWindow=\(attached),loading=\(loading),chromium=\(chromium),starting=\(starting),shown=\(shown),painted=\(painted)") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCapturePaintTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCapturePaintTests.swift new file mode 100644 index 000000000000..32b84e7c21c9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCapturePaintTests.swift @@ -0,0 +1,52 @@ +#if DEBUG +import CmuxNextPagesTestSupport +import Foundation +import Testing +@testable import CmuxNextAgentPane +@testable import CmuxNextPages + +@MainActor +struct AgentPaneCapturePaintTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { + throw AgentPaneHostError.daemonStopped + } + } + + @Test func aLoadingCoverGivingUpDoesNotCountAsDocumentPaint() throws { + let file = try #require(AgentPageTestRoot.page) + let view = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file), + pageHost: true, chromium: FakeChromiumHosting())) + let page = try #require(view.page) + #expect(!page.hasPainted) + + // The cover may give up while Chromium is still starting. The document has not painted. + view.model.markPainted() + #expect(view.model.hasPainted) + #expect(!view.debugPageHasPainted) + + page.notePainted() + #expect(view.debugPageHasPainted) + } + + @Test func theLegacyWebKitHostUsesItsPanePaintSignal() throws { + let file = try #require(AgentPageTestRoot.page) + let view = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file), + pageHost: false, chromium: nil)) + #expect(!view.debugPageHasPainted) + view.model.markPainted() + #expect(view.debugPageHasPainted) + } + + @Test func documentPaintStillWaitsForTheAgentContent() throws { + let file = try #require(AgentPageTestRoot.page) + let view = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file), + pageHost: true, chromium: FakeChromiumHosting())) + let page = try #require(view.page) + page.notePainted() + #expect(!view.debugPageHasPainted) + view.model.markPainted() + #expect(view.debugPageHasPainted) + } +} +#endif From 450663d12529027574c01ea03d05e23aeed56e5a Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:07:58 -0700 Subject: [PATCH 4/5] fix: require agent document paint before settling captures The loading cover can mark the pane painted at its give-up deadline even when Chromium has not painted. Require the shared page paint probe and pane content signal together, retaining the legacy WebKit pane signal. Verification: scoped syntax, default verification and prepush checks pass. The prior regression commit preserves the failing case; fleet red/green execution follows the plain push. --- .../Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift index 6d195b779be7..8d466e6a46cc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift @@ -63,7 +63,7 @@ extension AgentPaneView { public var debugChromiumStarting: Bool { pageDrawsOverCovers && !debugShowsChromium } /// Whether the document has painted, independently of the loading cover's give-up deadline. - public var debugPageHasPainted: Bool { model.hasPainted } + public var debugPageHasPainted: Bool { model.hasPainted && (page?.hasPainted ?? true) } /// Whether the chat's Chromium page is one a window snapshot draws: in this window, not parked, /// hidden or under a clear (alpha 0) ancestor, and with a size. From a99f645b66721da8a009ddb96a98931551ba5440 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:40 -0700 Subject: [PATCH 5/5] refactor: keep capture readiness in the agent pane Move the readiness value and its behavioral tests out of the app composition target. Both scene paths keep the same paint, engine startup and snapshot eligibility conditions. Validation: Swift syntax, localization defaults, feature flags, and scoped package conventions pass. Prior focused fleet regression is red at 1345c736 and green at 450663d1; rerun the three focused suites on this head after push. Workspace package grouping differs on the fetched base and is unrelated to these six Swift files. --- .../AgentPaneCaptureState.swift | 29 ++++++++++++++ .../AgentPaneView+DebugClick.swift | 7 ++++ .../Control/CaptureSceneRegistry.swift | 37 +++--------------- .../AgentPaneCaptureReadinessTests.swift | 39 +++++++++++++++++++ .../CaptureScenePageCompositedTests.swift | 33 ---------------- 5 files changed, 80 insertions(+), 65 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCaptureState.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCaptureReadinessTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCaptureState.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCaptureState.swift new file mode 100644 index 000000000000..da9f53100865 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneCaptureState.swift @@ -0,0 +1,29 @@ +#if DEBUG || CMUX_BENCH_HOOKS +/// Whether an agent pane has reached a painted engine that a window snapshot can draw. +public struct AgentPaneCaptureState: Sendable { + /// The pane is attached, painted and drawable on its final engine. + public let ready: Bool + + /// The observed engine and paint signals, included when a scene fails to settle. + public let diagnostic: String + + /// Evaluates capture readiness without depending on a live page or app. + /// + /// A starting Chromium host leaves its backing WebKit view hidden and idle. It must not + /// settle until Chromium attaches and paints, or falls back to WebKit. Chromium then needs + /// snapshot eligibility; WebKit needs completed navigation before fixture rows are seeded. + /// + /// - Parameters: + /// - attached: Whether the backing view belongs to a window. + /// - loading: Whether WebKit is still navigating. + /// - chromium: Whether the pane currently shows Chromium. + /// - chromiumStarting: Whether Chromium is requested but has not attached. + /// - chromiumShown: Whether the window snapshot can draw this Chromium page. + /// - painted: Whether both the document and agent content have painted. + public init(attached: Bool, loading: Bool, chromium: Bool, chromiumStarting: Bool, + chromiumShown: Bool, painted: Bool) { + ready = attached && painted && !chromiumStarting && (chromium ? chromiumShown : !loading) + diagnostic = "inWindow=\(attached),loading=\(loading),chromium=\(chromium),starting=\(chromiumStarting),shown=\(chromiumShown),painted=\(painted)" + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift index 8d466e6a46cc..92488a9d3c55 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+DebugClick.swift @@ -72,6 +72,13 @@ extension AgentPaneView { return PageRegistry.shownChromiumPages(in: window).contains { $0.page === page } } + /// The paint and engine state a scene capture must wait for before drawing the agent pane. + public var debugCaptureState: AgentPaneCaptureState { + AgentPaneCaptureState(attached: webView.window != nil, loading: webView.isLoading, + chromium: debugShowsChromium, chromiumStarting: debugChromiumStarting, + chromiumShown: debugChromiumPageShown, painted: debugPageHasPainted) + } + /// The zoom the chat shows at: view points per page CSS pixel. public var debugPageZoom: Double { guard let page else { return webView.pageZoom * webView.magnification } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift index 7e9f36a065e3..dfce9945ede8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/CaptureSceneRegistry.swift @@ -81,7 +81,7 @@ final class CaptureSceneRegistry { && self.services.windows.registry.members(of: targetWindowID).count >= 3 && self.services.windows.controller(for: targetWindowID)?.sidebar.model.sections.flatMap(\.workspaces).contains { !$0.rowState.isPending } == true && self.services.showcase.agentTabs.values.contains { - self.agentPageState(self.services.agentTabs.existingView($0)).ready + self.services.agentTabs.existingView($0)?.debugCaptureState.ready == true } }, start: start, @@ -102,7 +102,7 @@ final class CaptureSceneRegistry { "window_workspaces": .number(Double(services.windows.registry.members(of: targetWindowID).count)), "agent_tabs": .number(Double(services.showcase.agentTabs.count)), "agent_pages": .array(services.showcase.agentTabs.values.map { - .string(agentPageState(services.agentTabs.existingView($0)).diagnostic) + .string(services.agentTabs.existingView($0)?.debugCaptureState.diagnostic ?? "no view") }), ]) } @@ -220,33 +220,6 @@ final class CaptureSceneRegistry { (snapshot["webviews_composited"]?.intValue ?? 0) + (snapshot["chromium_pages_composited"]?.intValue ?? 0) > 0 } - /// Whether the agent page shows what a capture would draw: in the window, past its first paint, - /// and on its final engine. While Chromium starts (`chromiumStarting`) the page has no Chromium - /// session yet and its backing WKWebView is hidden and never loads (so it is not loading); judging that view let - /// the first render after launch go ahead with no page on screen (#26468: "agent page was not - /// composited", 0 web views and 0 Chromium pages). A Chromium page must be one the snapshot - /// draws (`chromiumShown`); its backing WKWebView may still report loading an asynchronous - /// resource. A WebKit page must finish navigation before seed_rows runs. The readiness probe - /// then checks body, transcript and composer for either engine. - static func agentPageCanSettle(attached: Bool, loading: Bool, chromium: Bool, chromiumStarting: Bool, - chromiumShown: Bool, painted: Bool) -> Bool { - guard attached, painted, !chromiumStarting else { return false } - return chromium ? chromiumShown : !loading - } - - private func agentPageState(_ view: AgentPaneView?) -> (ready: Bool, diagnostic: String) { - guard let view else { return (false, "no view") } - let attached = view.webView.window != nil - let loading = view.webView.isLoading - let chromium = view.debugShowsChromium - let starting = view.debugChromiumStarting - let shown = view.debugChromiumPageShown - let painted = view.debugPageHasPainted - let ready = Self.agentPageCanSettle(attached: attached, loading: loading, chromium: chromium, - chromiumStarting: starting, chromiumShown: shown, painted: painted) - return (ready, "inWindow=\(attached),loading=\(loading),chromium=\(chromium),starting=\(starting),shown=\(shown),painted=\(painted)") - } - private func readinessIsComplete(_ readiness: CmuxNextSettings.JSONValue) -> Bool { guard case .object(let report) = readiness else { return false } return (report["body_text_length"]?.intValue ?? 0) > 0 && (report["transcript_rows"]?.intValue ?? 0) > 0 @@ -490,14 +463,14 @@ extension CaptureSceneRegistry { let generatedOK = actualGenerated == expectedGeneratedTitle let actualGeneratedPreview = actualGenerated.map { String($0.prefix(32)) } ?? "-" let view = services.agentTabs.existingView(tabID) - let (pageReady, page) = agentPageState(view) + let page = view?.debugCaptureState let sidebarRow = services.windows.controller(for: windowID)?.sidebar.model.sections.flatMap(\.workspaces) .first(where: { $0.id.rawValue == workspace.id }) let sidebarRowMatches = sidebarRow?.title == expected let pending = sidebarRow?.rowState.isPending == true let ready = workspace.name == expected && workspace.nameSource == expectedSource - && tabFound && sessionOK && hostKnown && generatedOK && pageReady && sidebarRowMatches && !pending - let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),sidebarRow=\(sidebarRowMatches),pending=\(pending),\(page)" + && tabFound && sessionOK && hostKnown && generatedOK && page?.ready == true && sidebarRowMatches && !pending + let diagnostic = "name=\(workspace.name),source=\(workspace.nameSource.rawValue),expected=\(expected),tabFound=\(tabFound),sessionOK=\(sessionOK),hostKnown=\(hostKnown),generatedOK=\(generatedOK),actualGenerated=\(actualGeneratedPreview),sidebarRow=\(sidebarRowMatches),pending=\(pending),\(page?.diagnostic ?? "no view")" return (ready, String(diagnostic.prefix(420))) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCaptureReadinessTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCaptureReadinessTests.swift new file mode 100644 index 000000000000..444509a340d6 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneCaptureReadinessTests.swift @@ -0,0 +1,39 @@ +#if DEBUG +import CmuxNextAgentPane +import Testing + +struct AgentPaneCaptureReadinessTests { + private func canSettle(attached: Bool = true, loading: Bool = false, chromium: Bool = false, + chromiumStarting: Bool = false, chromiumShown: Bool = false, painted: Bool = true) -> Bool { + AgentPaneCaptureState(attached: attached, loading: loading, chromium: chromium, + chromiumStarting: chromiumStarting, chromiumShown: chromiumShown, + painted: painted).ready + } + + @Test func chromiumPageMaySettleWhileBackingWebViewLoads() { + #expect(canSettle(loading: true, chromium: true, chromiumShown: true)) + #expect(canSettle(loading: false, chromium: true, chromiumShown: true)) + #expect(!canSettle(loading: true)) + #expect(canSettle(loading: false)) + #expect(!canSettle(attached: false, loading: true, chromium: true, chromiumShown: true)) + } + + /// #26468: on a cold launch the agent page is to show in Chromium but Chromium has not attached. + /// Its hidden backing WKWebView is in the window and done loading, which used to settle the + /// scene with no page on screen, so the snapshot drew 0 web views and 0 Chromium pages. + @Test func aChromiumPageStillStartingDoesNotSettle() { + #expect(!canSettle(loading: false, chromiumStarting: true)) + #expect(!canSettle(loading: false, chromium: true, chromiumStarting: true, chromiumShown: true)) + } + + @Test func aChromiumPageTheSnapshotCannotDrawDoesNotSettle() { + // Attached but parked, hidden, under a clear paint hold or without a size. + #expect(!canSettle(chromium: true, chromiumShown: false)) + } + + @Test func aPageBeforeItsFirstPaintDoesNotSettle() { + #expect(!canSettle(loading: false, painted: false)) + #expect(!canSettle(chromium: true, chromiumShown: true, painted: false)) + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift index 8d057cc657f5..ff98d9ecacb0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CaptureScenePageCompositedTests.swift @@ -29,38 +29,5 @@ struct CaptureScenePageCompositedTests { ])) #expect(!CaptureSceneRegistry.pageComposited([:])) } - - private func canSettle(attached: Bool = true, loading: Bool = false, chromium: Bool = false, - chromiumStarting: Bool = false, chromiumShown: Bool = false, painted: Bool = true) -> Bool { - CaptureSceneRegistry.agentPageCanSettle(attached: attached, loading: loading, chromium: chromium, - chromiumStarting: chromiumStarting, chromiumShown: chromiumShown, - painted: painted) - } - - @Test func chromiumPageMaySettleWhileBackingWebViewLoads() { - #expect(canSettle(loading: true, chromium: true, chromiumShown: true)) - #expect(canSettle(loading: false, chromium: true, chromiumShown: true)) - #expect(!canSettle(loading: true)) - #expect(canSettle(loading: false)) - #expect(!canSettle(attached: false, loading: true, chromium: true, chromiumShown: true)) - } - - /// #26468: on a cold launch the agent page is to show in Chromium but Chromium has not attached. - /// Its hidden backing WKWebView is in the window and done loading, which used to settle the - /// scene with no page on screen, so the snapshot drew 0 web views and 0 Chromium pages. - @Test func aChromiumPageStillStartingDoesNotSettle() { - #expect(!canSettle(loading: false, chromiumStarting: true)) - #expect(!canSettle(loading: false, chromium: true, chromiumStarting: true, chromiumShown: true)) - } - - @Test func aChromiumPageTheSnapshotCannotDrawDoesNotSettle() { - // Attached but parked, hidden, under a clear paint hold or without a size. - #expect(!canSettle(chromium: true, chromiumShown: false)) - } - - @Test func aPageBeforeItsFirstPaintDoesNotSettle() { - #expect(!canSettle(loading: false, painted: false)) - #expect(!canSettle(chromium: true, chromiumShown: true, painted: false)) - } } #endif