From e4e483e924f5c3611fae506660b9a8b9c28ccdc0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 06:23:07 -0700 Subject: [PATCH 1/3] test(cmux-next): a page that never paints drops its loading state at a deadline (cx-txnr) Red: AgentPaneColdChatRevealTests.aPageThatNeverPaintsDropsItsLoadingStateAtTheDeadline fails; the loading state stays over a clear page for good. Co-Authored-By: Claude Opus 5.5 --- .../AgentPaneView+Loading.swift | 2 ++ .../AgentPaneColdChatRevealTests.swift | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift index f2291097ed26..9884b4417bc9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift @@ -17,6 +17,8 @@ public final class AgentPaneLoadingView: NSView { /// fades in, never a mark that pops up and goes. Longer than a pane's hold on the chat it /// showed (`PanePaintHold.limit`), so the mark never shows the moment a hold gives up. static let markDelay: Duration = .milliseconds(800) + /// Placeholder until the fix: the loading state has no deadline yet (cx-txnr). + static var paintLimit: Duration = .seconds(8) private let markTimer = DemandTimer(owner: "agentPane.loadingMark") private var revealWaiters: [() -> Void] = [] /// While it fades out over the page, clicks and scrolls go to the page. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift index 894fa5494e96..84a2b7f32027 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift @@ -63,6 +63,26 @@ struct AgentPaneColdChatRevealTests { #expect(!Self.markShows(view), "the busy mark stayed while the page faded in") } + /// cx-txnr: after a relaunch a page that never reported its first paint (WebKit holds an + /// occluded window's animation frames) kept the pane on its loading state, a spinner over a + /// clear page, for good. The loading state gives up at its deadline and shows the page. + @Test func aPageThatNeverPaintsDropsItsLoadingStateAtTheDeadline() async throws { + let limit = AgentPaneLoadingView.paintLimit + AgentPaneLoadingView.paintLimit = .milliseconds(200) + defer { AgentPaneLoadingView.paintLimit = limit } + let (view, window) = try Self.pane() + defer { window.close() } + #expect(view.showsLoadingState) + try await Self.settle { view.loadingView.superview == nil } + #expect(view.loadingView.superview == nil, "a page that never painted kept its loading state") + #expect(view.pageContent.alphaValue == 1, "the page stayed clear after the loading state gave up") + #expect(!view.model.hasPainted) + + view.model.markPainted() + #expect(view.loadingView.superview == nil, "a late paint brought the loading state back") + #expect(view.pageContent.alphaValue == 1) + } + @Test func aNewTabPageShowsInOneFrame() throws { let (view, window) = try Self.pane(newTab: AgentPaneNewTab(kind: .agent)) defer { window.close() } From 8b4da0afd0df299d752ad7b7e187a4f15b978985 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 06:24:38 -0700 Subject: [PATCH 2/3] fix(cmux-next): an agent page that never paints drops its loading state after 8 s (cx-txnr) After a relaunch a restored page whose window WebKit treated as hidden could hold its requestAnimationFrame, so pane.painted never came and the pane kept its spinner over a clear page with no deadline. The loading state now gives up at AgentPaneLoadingView.paintLimit and shows the page; leave() runs once, so the late paint does not fade it again. Green: CmuxNextAgentPaneTests (80 tests) pass. Co-Authored-By: Claude Opus 5.5 --- .../AgentPaneView+Loading.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift index 9884b4417bc9..a7892eb7511a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift @@ -1,6 +1,9 @@ public import AppKit import CmuxNextDesign import CmuxNextWakeups +import os + +private let loadingLogger = Logger(subsystem: "com.cmuxterm.app.next", category: "agent-pane.webview") /// The pane's first frame (cx-sj96): from the moment it is made until its page draws what it is /// (`pane.painted`: the handshake's answer or the host's error), the pane shows its page @@ -17,9 +20,13 @@ public final class AgentPaneLoadingView: NSView { /// fades in, never a mark that pops up and goes. Longer than a pane's hold on the chat it /// showed (`PanePaintHold.limit`), so the mark never shows the moment a hold gives up. static let markDelay: Duration = .milliseconds(800) - /// Placeholder until the fix: the loading state has no deadline yet (cx-txnr). + /// How long the loading state waits for the page's first paint before it shows the page + /// anyway (cx-txnr): WebKit holds the animation frames of a page it thinks is not visible + /// (an occluded window after a relaunch), and its `pane.painted` may never come. Like the + /// pane's other covers (`PanePaintHold.limit`, the launch image), it never stays for good. static var paintLimit: Duration = .seconds(8) private let markTimer = DemandTimer(owner: "agentPane.loadingMark") + private let paintTimer = DemandTimer(owner: "agentPane.paintLimit") private var revealWaiters: [() -> Void] = [] /// While it fades out over the page, clicks and scrolls go to the page. private var leaving = false @@ -62,6 +69,11 @@ public final class AgentPaneLoadingView: NSView { } } + /// Runs `giveUp` after ``paintLimit`` unless the loading state leaves first. + func giveUpLater(_ giveUp: @escaping @MainActor @Sendable () -> Void) { + paintTimer.schedule(after: Self.paintLimit) { @MainActor in giveUp() } + } + func hideMark() { markTimer.cancel() indicator.removeFromSuperview() @@ -78,6 +90,9 @@ public final class AgentPaneLoadingView: NSView { /// launch image, cleared once this ends), the page fades in over that. A chat already shown /// never gets here: it draws no loading state again, so switching back to it is instant. func leave(over page: NSView, fades: Bool) { + // Once: the page's paint can follow the deadline's leave. + guard superview != nil, !leaving else { return } + paintTimer.cancel() hideMark() leaving = true let overImage = isHidden @@ -123,6 +138,11 @@ extension AgentPaneView { themeLoadingState(themeTokens) loadingView.showMarkLater() model.whenPainted { [weak self] in self?.endLoadingState() } + loadingView.giveUpLater { [weak self] in + guard let self, !model.hasPainted, loadingView.superview != nil else { return } + loadingLogger.error("agent pane page did not paint within the loading limit; showing it") + endLoadingState() + } } /// The page shows and the loading state goes (``AgentPaneLoadingView/leave(over:fades:)``): a From bd7c4160f3d55cfec719dbc7b1f4548400a0e940 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 18 Oct 2026 06:31:41 -0700 Subject: [PATCH 3/3] fix(cmux-next): give up on a page's first paint only while it is in sight, through markPainted (cx-txnr) Review: the 8 s clock started at init, so a parked spare or a hidden tab gave up unseen, and giving up left hasPainted false, so launch images, the New Tab frame cover and the paint hold still treated the pane as unpainted. The loading view now checks every 4 s and gives up after two checks in a row that see the pane (window ordered in, pane not hidden and inside the window's content); giving up calls markPainted. The check interval is per view, so tests no longer change a shared static. Green: CmuxNextAgentPaneTests, 81 tests pass; AgentPaneColdChatRevealTests 5/5 runs. Co-Authored-By: Claude Opus 5.5 --- .../AgentPaneView+Loading.swift | 63 +++++++++++++++---- .../AgentPaneColdChatRevealTests.swift | 25 +++++--- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift index a7892eb7511a..5c6a5b6981e4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Loading.swift @@ -20,13 +20,20 @@ public final class AgentPaneLoadingView: NSView { /// fades in, never a mark that pops up and goes. Longer than a pane's hold on the chat it /// showed (`PanePaintHold.limit`), so the mark never shows the moment a hold gives up. static let markDelay: Duration = .milliseconds(800) - /// How long the loading state waits for the page's first paint before it shows the page - /// anyway (cx-txnr): WebKit holds the animation frames of a page it thinks is not visible - /// (an occluded window after a relaunch), and its `pane.painted` may never come. Like the - /// pane's other covers (`PanePaintHold.limit`, the launch image), it never stays for good. - static var paintLimit: Duration = .seconds(8) + /// How often the loading state checks on a page that has not painted (cx-txnr): WebKit + /// holds the animation frames of a page it thinks is not visible (a window occluded at a + /// relaunch), and its `pane.painted` may then never come. Seen unpainted at two checks in a + /// row (4-8 s in sight), the page counts as painted and shows as it is: like the pane's + /// other covers (`PanePaintHold.limit`, the launch image), it never stays for good. Out of + /// sight (a parked spare, a hidden tab, a window ordered out) it waits. + var paintCheckInterval: Duration = .seconds(4) { + didSet { if onGiveUp != nil { schedulePaintCheck() } } + } private let markTimer = DemandTimer(owner: "agentPane.loadingMark") - private let paintTimer = DemandTimer(owner: "agentPane.paintLimit") + private let paintTimer = DemandTimer(owner: "agentPane.paintCheck") + private var onGiveUp: (@MainActor () -> Void)? + /// The last check saw the pane, still unpainted. + private var seenUnpainted = false private var revealWaiters: [() -> Void] = [] /// While it fades out over the page, clicks and scrolls go to the page. private var leaving = false @@ -69,9 +76,38 @@ public final class AgentPaneLoadingView: NSView { } } - /// Runs `giveUp` after ``paintLimit`` unless the loading state leaves first. - func giveUpLater(_ giveUp: @escaping @MainActor @Sendable () -> Void) { - paintTimer.schedule(after: Self.paintLimit) { @MainActor in giveUp() } + /// Runs `giveUp` once the pane has been seen unpainted at two checks in a row, unless the + /// loading state leaves first. + func giveUpWhenSeenUnpainted(_ giveUp: @escaping @MainActor () -> Void) { + onGiveUp = giveUp + seenUnpainted = false + schedulePaintCheck() + } + + private func schedulePaintCheck() { + paintTimer.schedule(after: paintCheckInterval) { @MainActor [weak self] in self?.checkPaint() } + } + + private func checkPaint() { + guard superview != nil, !leaving, let giveUp = onGiveUp else { return } + let seen = paneIsSeen + if seen, seenUnpainted { + onGiveUp = nil + giveUp() + return + } + seenUnpainted = seen + schedulePaintCheck() + } + + /// The pane is on screen: its window is ordered in, and the pane is neither hidden nor + /// outside the window's content (a parked New Tab spare). Not the window's occlusion state: + /// WebKit's own view of it is what holds the paint. + private var paneIsSeen: Bool { + guard let pane = superview, let window, window.isVisible, let content = window.contentView, + !pane.isHiddenOrHasHiddenAncestor else { return false } + // Window coordinates: `visibleRect` does not clip to views that do not clip to bounds. + return pane.convert(pane.bounds, to: nil).intersects(content.convert(content.bounds, to: nil)) } func hideMark() { @@ -93,6 +129,7 @@ public final class AgentPaneLoadingView: NSView { // Once: the page's paint can follow the deadline's leave. guard superview != nil, !leaving else { return } paintTimer.cancel() + onGiveUp = nil hideMark() leaving = true let overImage = isHidden @@ -138,10 +175,10 @@ extension AgentPaneView { themeLoadingState(themeTokens) loadingView.showMarkLater() model.whenPainted { [weak self] in self?.endLoadingState() } - loadingView.giveUpLater { [weak self] in - guard let self, !model.hasPainted, loadingView.superview != nil else { return } - loadingLogger.error("agent pane page did not paint within the loading limit; showing it") - endLoadingState() + loadingView.giveUpWhenSeenUnpainted { [weak self] in + guard let self, !model.hasPainted else { return } + loadingLogger.error("agent pane page did not report its first paint in sight; showing it") + model.markPainted() } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift index 84a2b7f32027..901d6fa39f17 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneColdChatRevealTests.swift @@ -65,22 +65,31 @@ struct AgentPaneColdChatRevealTests { /// cx-txnr: after a relaunch a page that never reported its first paint (WebKit holds an /// occluded window's animation frames) kept the pane on its loading state, a spinner over a - /// clear page, for good. The loading state gives up at its deadline and shows the page. + /// clear page, for good. Seen unpainted at two checks in a row, the page counts as painted. @Test func aPageThatNeverPaintsDropsItsLoadingStateAtTheDeadline() async throws { - let limit = AgentPaneLoadingView.paintLimit - AgentPaneLoadingView.paintLimit = .milliseconds(200) - defer { AgentPaneLoadingView.paintLimit = limit } let (view, window) = try Self.pane() defer { window.close() } + view.loadingView.paintCheckInterval = .milliseconds(100) #expect(view.showsLoadingState) try await Self.settle { view.loadingView.superview == nil } #expect(view.loadingView.superview == nil, "a page that never painted kept its loading state") #expect(view.pageContent.alphaValue == 1, "the page stayed clear after the loading state gave up") - #expect(!view.model.hasPainted) + #expect(view.model.hasPainted, "the pane still waits on a paint it gave up on") + } - view.model.markPainted() - #expect(view.loadingView.superview == nil, "a late paint brought the loading state back") - #expect(view.pageContent.alphaValue == 1) + /// A pane out of sight (a New Tab spare parked outside the window's content) keeps its + /// loading state: its clock runs only while it is seen. + @Test func aPaneOutOfSightKeepsItsLoadingState() async throws { + let (view, window) = try Self.pane() + defer { window.close() } + let content = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + window.contentView = content + view.frame = NSRect(x: 10_000, y: 10_000, width: 400, height: 300) + content.addSubview(view) + view.loadingView.paintCheckInterval = .milliseconds(50) + try await Task.sleep(for: .milliseconds(400)) + #expect(view.loadingView.superview != nil, "a pane nobody could see gave up on its page") + #expect(!view.model.hasPainted) } @Test func aNewTabPageShowsInOneFrame() throws {