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
@@ -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
Expand All @@ -17,7 +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 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.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
Expand Down Expand Up @@ -60,6 +76,40 @@ public final class AgentPaneLoadingView: NSView {
}
}

/// 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() {
markTimer.cancel()
indicator.removeFromSuperview()
Expand All @@ -76,6 +126,10 @@ 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()
onGiveUp = nil
hideMark()
leaving = true
let overImage = isHidden
Expand Down Expand Up @@ -121,6 +175,11 @@ extension AgentPaneView {
themeLoadingState(themeTokens)
loadingView.showMarkLater()
model.whenPainted { [weak self] in self?.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()
}
}

/// The page shows and the loading state goes (``AgentPaneLoadingView/leave(over:fades:)``): a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ 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. Seen unpainted at two checks in a row, the page counts as painted.
@Test func aPageThatNeverPaintsDropsItsLoadingStateAtTheDeadline() async throws {
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, "the pane still waits on a paint it gave up on")
}

/// 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 {
let (view, window) = try Self.pane(newTab: AgentPaneNewTab(kind: .agent))
defer { window.close() }
Expand Down
Loading