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
16 changes: 12 additions & 4 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,20 @@ struct AgentTabContent {
let newTabFrame = { view?.model.newTab == nil || view?.pageDrawsOverCovers == true ? nil : AgentPaneNewTabFrame.shared.image(size: paneView.contentHost.bounds.size, appearance: Self.appearance(of: paneView)) }
let image = { images.take(key, size: paneView.contentHost.bounds.size, appearance: Self.appearance(of: paneView)) ?? newTabFrame() }
if paneView.launchImageView == nil, let image = image() { paneView.showLaunchImage(image) }
guard paneView.launchImageView != nil, let view else { return }
guard let imageView = paneView.launchImageView, let view else { return }
view.showsLoadingState = false
// The image's limit takes it only once the page has drawn. A chat read at launch can take
// longer than that limit (acpmux's attach, and the page holds its first frame until the
// chat is read), and the frame it would hand the pane to is empty.
paneView.launchImageMayClear = { [weak view] in view?.model.hasPainted ?? true }
paneView.onLaunchImageCleared = { [weak view] in
if let view, view.model.newTab == nil { view.showsLoadingState = true }
}
// Once the page has faded in over it (`AgentPaneLoadingView.leave`).
view.loadingView.whenRevealed { [weak paneView] in paneView?.clearLaunchImage() }
view.loadingView.whenRevealed { [weak paneView, weak imageView] in
guard let imageView else { return }
paneView?.clearLaunchImage(ifShowing: imageView)
}
}

/// A parked spare taken into this pane draws nothing until WebKit renders it in its new place
Expand All @@ -89,9 +96,10 @@ struct AgentTabContent {
BenchSpans.mark(image == nil ? "newTab.cover.none \(size) \(String(describing: appearance))" : own != nil ? "newTab.cover.own" : "newTab.cover")
// The image's deadline shows the page too, should its frame never be reported.
if let image { paneView.showLaunchImage(image); paneView.onLaunchImageCleared = { [weak view] in view?.revealMovedPage() } }
view.hideMovedPage { [weak paneView] in
let imageView = image == nil ? nil : paneView.launchImageView
view.hideMovedPage { [weak paneView, weak imageView] in
BenchSpans.mark("newTab.cover.cleared")
if image != nil { paneView?.clearLaunchImage() }
if let imageView { paneView?.clearLaunchImage(ifShowing: imageView) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import CmuxNextWakeups
/// (`showLaunchImage`). Deferred launch work (`LaunchSettle`: the palette's
/// warm-up steps) waits until none is left: a step run at the first idle
/// moment held the main thread just as WebKit needed it to start the page's
/// navigation (about 70 ms on a cold launch, cx-z9kz0). Each image goes when
/// its page paints, or at its limit (`paneLaunchImageLimit`).
/// navigation (about 70 ms on a cold launch, cx-z9kz0). An image goes when its
/// page paints; one standing in for a page that has not painted stays past
/// `paneLaunchImageLimit` (`launchImageMayClear`), so what bounds the wait for
/// deferred work is the backstop below, not the image.
@MainActor
final class LaunchImageHold {
static let shared = LaunchImageHold()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import AppKit

/// How long a launch image may stay when its page never reports a first frame.
/// How long a launch image may stay once the page it stands in for has painted. It is not the
/// bound on a page that never reports a first frame: such a page keeps its frame past this
/// (`launchImageMayClear`), because taking it leaves the pane empty. What bounds that case is
/// `AgentPaneLoadingView`'s give-up, which shows the page as it is after two unpainted checks
/// (2 x `paintCheckInterval`, about 8 s). Deferred launch work has its own bound either way:
/// `LaunchImageHold`'s backstop runs the waiters at this limit plus a second.
let paneLaunchImageLimit: Duration = .seconds(3)

extension PaneContentView {
/// Draws `image` (an agent page's last frame, `AgentPaneLaunchImages`)
/// under the content, top-left at its point size, until the live page
/// paints: an agent page is transparent until then, so the pane never
/// shows empty at launch.
func showLaunchImage(_ image: NSImage) {
func showLaunchImage(_ image: NSImage, limit: Duration = paneLaunchImageLimit) {
clearLaunchImage()
let view = NSView(frame: contentHost.bounds)
view.wantsLayer = true
Expand All @@ -20,12 +25,36 @@ extension PaneContentView {
launchImageView = view
LaunchImageHold.shared.began()
DebugTimings.markLaunch("agent_pane.launch_image_shown")
launchImageDeadline.schedule(after: paneLaunchImageLimit) { @MainActor [weak self] in self?.clearLaunchImage() }
launchImageDeadline.schedule(after: limit) { @MainActor [weak self] in
guard let self else { return }
// The frame stands in for a page that has not drawn yet, so the limit alone does not
// take it: dropping it here used to hand the pane its empty loading state while the
// page was still inside its own first-paint budget, which is a chat that draws, goes
// dark for several seconds, then comes back (Leo 2026-10-22). What takes it instead is
// the page drawing over it, or the pane giving up on a page that never reports and
// showing it as it is (`AgentPaneLoadingView`), both of which end on the page. The
// give-up is still the earlier of the two budgets (about 8 s against the page's own
// ~10 s `CHAT_DRAW_HOLD_MS` after its handshake), so a daemon slow enough to miss it
// reveals the page while it is itself still blank. That narrows Leo's dark gap rather
// than closing it; closing it means moving one of the two budgets.
guard launchImageMayClear?() ?? true else { return }
clearLaunchImage()
}
}

/// A page's delayed reveal removes only the frame it stood behind. Another tab may have
/// installed its own frame in this pane while that page was still loading.
func clearLaunchImage(ifShowing image: NSView) {
guard launchImageView === image else { return }
clearLaunchImage()
}

/// Removes the launch image (the page painted, or the pane shows another tab).
func clearLaunchImage() {
launchImageDeadline.cancel()
// The next frame says for itself whether its limit may take it; `showLaunchImage` clears
// first, so a frame held over a painted page never inherits a relaunch's answer.
launchImageMayClear = nil
if launchImageView != nil { DebugTimings.markLaunch("agent_pane.launch_image_cleared") }
if let launchImageView {
launchImageView.removeFromSuperview()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ final class PaneContentView: NSView, PaneContentChrome {
var launchImageView: NSView?
/// Runs once when the launch image goes (`clearLaunchImage`).
var onLaunchImageCleared: (() -> Void)?
/// Asked when the launch image's limit is up, before it goes: `false` keeps the frame, because
/// the page it stands in for is still coming and dropping it now would leave the pane empty
/// (`PaneContentView+LaunchImage`). Nil means the limit clears it, as it does for a frame held
/// over a page that has already painted.
var launchImageMayClear: (() -> Bool)?
let launchImageDeadline = DemandTimer(owner: "pane.launch-image")
/// Browsers this pane showed before, kept in place and hidden, oldest
/// first (`PaneContentView+Parking`).
Expand Down Expand Up @@ -348,6 +353,8 @@ final class PaneContentView: NSView, PaneContentChrome {
func detachContent(parks: Bool = false) {
// The strip's band pins end before the browser leaves (R109).
releaseBand()
// A displaced or closed page cannot reveal this pane's saved frame anymore.
clearLaunchImage()
endPaintHold()
let shown = hostsContent ? content : nil
(shown as? PaneContentChrome)?.onPaneHeaderHeightChange = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,79 @@ import Testing
}

func tearDown() {
// A test that ends with its frame still up (one whose page never painted) would leave
// `LaunchImageHold.shared` counting it for the rest of the process: the pane's deinit
// does not end the hold by design, so the next test to await it would take the 4 s
// backstop instead of returning at once.
pane.clearLaunchImage()
window.orderOut(nil)
window.close()
}
}

/// A stand-in for an agent page's last frame (`AgentPaneLaunchImages`).
private static func lastFrame() -> NSImage {
NSImage(size: NSSize(width: 40, height: 30), flipped: false) { rect in
NSColor.textBackgroundColor.setFill()
rect.fill()
return true
}
}

/// Leo 2026-10-22, on a chat at launch: it "flickers the initial thing/paints and renders it...
/// and then it just goes to black screen for a while". The pane drew the chat's last frame, took
/// it back at `paneLaunchImageLimit`, and showed its empty loading state until the page reported
/// a first frame — which waits on acpmux reading the chat, several seconds on a busy daemon. The
/// frame stays while the page it stands in for is still coming; the pane's own give-up on a page
/// that never reports is what ends it then (`AgentPaneLoadingView`).
@Test func theLastFrameWaitsForThePageItStandsInFor() async throws {
let launch = LaunchWindow()
defer { launch.tearDown() }
launch.pane.showLaunchImage(Self.lastFrame(), limit: .milliseconds(40))
launch.pane.launchImageMayClear = { false }
try await Task.sleep(for: .milliseconds(300))
#expect(launch.pane.launchImageView != nil,
"the pane took back the chat's last frame while its page was still coming, leaving it empty")
}

/// A frame held over a page that has drawn is the other half: its limit still takes it, so a
/// cover that outlived its purpose never stays (`coverMove`).
@Test func theLastFrameGoesAtItsLimitOnceThePageDrew() async throws {
let launch = LaunchWindow()
defer { launch.tearDown() }
launch.pane.showLaunchImage(Self.lastFrame(), limit: .milliseconds(40))
launch.pane.launchImageMayClear = { true }
try await Task.sleep(for: .milliseconds(300))
#expect(launch.pane.launchImageView == nil, "a frame over a page that had drawn stayed past its limit")
}

@Test func aPreviousPageCannotClearTheNextPagesFrame() throws {
let launch = LaunchWindow()
defer { launch.tearDown() }
launch.pane.showLaunchImage(Self.lastFrame())
let previous = try #require(launch.pane.launchImageView)
launch.pane.showLaunchImage(Self.lastFrame())
let current = try #require(launch.pane.launchImageView)
launch.pane.clearLaunchImage(ifShowing: previous)
#expect(launch.pane.launchImageView === current,
"the previous tab's delayed reveal took back the next tab's frame")
launch.pane.clearLaunchImage(ifShowing: current)
#expect(launch.pane.launchImageView == nil)
}

@Test(arguments: [false, true])
func aPageThatLeavesThePaneTakesItsUnpaintedFrameWithIt(parks: Bool) {
let launch = LaunchWindow()
defer { launch.tearDown() }
launch.pane.show(NSView())
launch.pane.showLaunchImage(Self.lastFrame())
launch.pane.launchImageMayClear = { false }
launch.pane.detachContent(parks: parks)
#expect(launch.pane.launchImageView == nil,
"a closed or displaced page left its last frame covering the empty pane")
#expect(launch.pane.launchImageMayClear == nil)
}

@Test func tabsThatArriveBeforeTheFirstFrameDrawInIt() {
let launch = LaunchWindow()
defer { launch.tearDown() }
Expand Down
Loading