Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ 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 document has painted, independently of the loading cover's give-up deadline.
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.
public var debugChromiumPageShown: Bool {
guard let page, let window = page.window else { return false }
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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,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.services.agentTabs.existingView($0)?.debugCaptureState.ready == true
}
},
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 {
Expand All @@ -103,6 +103,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(services.agentTabs.existingView($0)?.debugCaptureState.diagnostic ?? "no view")
}),
])
}

Expand Down Expand Up @@ -223,13 +226,6 @@ 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)
}

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
Expand Down Expand Up @@ -629,7 +625,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())
}

Expand All @@ -653,18 +649,15 @@ 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 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 && 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)"
return (ready, String(diagnostic.prefix(330)))
&& 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)))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,5 @@ struct CaptureScenePageCompositedTests {
]))
#expect(!CaptureSceneRegistry.pageComposited([:]))
}

@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))
}
}
#endif
Loading