diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift index a87cac08d2b8..896446bf62df 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeHostView.swift @@ -295,10 +295,16 @@ final class HomeHostView: NSView { // with history never flashes it (at once when the page is cached). transcript.holdsFirstRun = true let binding = transcript.binding + let reveal = services.launchReveal // task-owner: lives as long as this view; ends when the first page is in firstPage = Task { [weak self] in await binding.opened() - self?.transcript.holdsFirstRun = false + // A Home released before its page came (deinit cancels this) must not + // reveal the Home that replaced it, which waits for its own page. + guard let self, !Task.isCancelled else { return } + transcript.holdsFirstRun = false + // A launch holds a Home still being made until here (WorkspaceContentController). + reveal.markReady(.home) } wantsLayer = true clipsToBounds = true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceContentController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceContentController.swift index 7d91cbc167c3..a6263e6f022f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceContentController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WorkspaceContentController.swift @@ -161,6 +161,11 @@ final class WorkspaceContentController: LayoutPaneContentProvider { screenBar.onVisibilityChange = { [weak self] visible in self?.contentView.showsBar = visible } // Observation applies the first snapshot synchronously, including the empty-state view. observe() + // A Home with no pane yet is still being made (its dock, then the Chief conversation, over + // several daemon replies): it stays clear until its conversation is in, never in pieces. + if workspace.kind == "home", layoutModel.screens.allSatisfy({ $0.layout.panes.isEmpty }) { + services.launchReveal.hold(contentView, until: .home) + } } func teardown() { @@ -172,6 +177,8 @@ final class WorkspaceContentController: LayoutPaneContentProvider { screenBar.teardown() for controller in panes.values { controller.teardown() } panes.removeAll() + // A Home closed before its page came stops counting toward `.home`'s wait. + services.launchReveal.release(contentView) contentView.removeFromSuperview() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Launch/LaunchReveal.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Launch/LaunchReveal.swift index 008390bfd864..b72930d83ed8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Launch/LaunchReveal.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Launch/LaunchReveal.swift @@ -15,7 +15,8 @@ public import CmuxNextDesignTokens /// launch that cannot produce a region (daemon unavailable) calls /// `markAllReady`. A region whose data never arrives (no tabs, a pane /// that never draws) is released by the deadline, which the first `hold` -/// arms: a missed producer costs a short delay, never a blank window. +/// arms (a region made after the window: its own first hold): a missed +/// producer costs a short delay, never a blank window. /// /// Call `markReady` where real data lands, not on an empty placeholder /// model (unless nothing is coming before the first frame: a fresh @@ -33,12 +34,16 @@ public final class LaunchReveal { @ObservationIgnored private var held: [LaunchRegion: NSHashTable] = [:] @ObservationIgnored private var waiters: [LaunchRegion: [@MainActor () -> Void]] = [:] @ObservationIgnored private let deadlineTimer: DemandTimer + /// The deadlines of regions made after the window (`LaunchRegion.hasOwnDeadline`). + @ObservationIgnored private var ownDeadlines: [LaunchRegion: DemandTimer] = [:] + @ObservationIgnored private let clock: any Clock @ObservationIgnored private let deadline: Duration /// - Parameter deadline: How long after the first `hold` every region /// shows anyway. public init(clock: any Clock = ContinuousClock(), deadline: Duration = .milliseconds(1500)) { deadlineTimer = DemandTimer(owner: "launch.reveal-deadline", clock: clock) + self.clock = clock self.deadline = deadline } @@ -52,9 +57,21 @@ public final class LaunchReveal { // a defaulted subscript would go to a temporary. let table = held[region] ?? NSHashTable.weakObjects() held[region] = table + // Held alone (the views held before it went away): a region with its own deadline waits from here. + let alone = table.allObjects.allSatisfy { $0 === view } table.add(view) - deadlineTimer.scheduleIfIdle(after: deadline) { @MainActor [weak self] in - self?.markAllReady() + guard region.hasOwnDeadline else { + deadlineTimer.scheduleIfIdle(after: deadline) { @MainActor [weak self] in + for region in LaunchRegion.allCases where !region.hasOwnDeadline { self?.markReady(region) } + } + return + } + let timer = ownDeadlines[region] ?? DemandTimer(owner: "launch.reveal-deadline.\(region.rawValue)", clock: clock) + ownDeadlines[region] = timer + if alone { + timer.schedule(after: deadline) { @MainActor [weak self] in self?.markReady(region) } + } else { + timer.scheduleIfIdle(after: deadline) { @MainActor [weak self] in self?.markReady(region) } } } @@ -88,6 +105,7 @@ public final class LaunchReveal { /// Every region is ready (or will never be): nothing stays held. public func markAllReady() { deadlineTimer.cancel() + for timer in ownDeadlines.values { timer.cancel() } for region in LaunchRegion.allCases { markReady(region) } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/Launch/LaunchRegion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/Launch/LaunchRegion.swift index 03a6e3646bc8..8e732b1fded1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/Launch/LaunchRegion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesignTokens/Launch/LaunchRegion.swift @@ -7,4 +7,10 @@ public nonisolated enum LaunchRegion: String, Sendable, CaseIterable { case tabs /// Pane content: the first terminal frame. case pane + /// Home: its conversation's first page (header, first-run actions, message box). + case home + + /// Made after the launch window (Home: over several daemon replies), so its wait runs from + /// its own first hold; the launch deadline, armed by the window's holds, leaves it held. + public var hasOwnDeadline: Bool { self == .home } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeLaunchRevealTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeLaunchRevealTests.swift new file mode 100644 index 000000000000..39cc28cfbda8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HomeLaunchRevealTests.swift @@ -0,0 +1,120 @@ +import AppKit +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextDesign +import CmuxNextWakeupsTestSupport +import Foundation +import Testing + +/// Leo's never-blank first launch frame: a first launch shows Home settled, not in pieces. A cold +/// launch showed the home workspace's "Start something new" at 3049 ms, then the second column and +/// import cards, then the header, actions and message box at about 4978 ms: Home is made over +/// several daemon replies (the dock's page tab, then the Chief conversation) and no launch region +/// held it. A Home with no pane yet stays clear until its conversation's first page is in. +@MainActor @Suite(.serialized) +struct HomeLaunchRevealTests { + @Test func aHomeStillBeingMadeStaysClearUntilItsConversationIsIn() throws { + let (services, reveal, window, content) = try Self.shown(kind: "home") + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + #expect(content.contentView.alphaValue == 0, "a Home with no pane yet drew its pieces as they came") + reveal.markReady(.home) + #expect(content.contentView.alphaValue == 1) + } + + @Test func anotherWorkspaceWithNoPaneIsNotHeld() throws { + let (services, _, window, content) = try Self.shown(kind: "normal") + defer { withExtendedLifetime(services) { content.teardown(); window.close() } } + #expect(content.contentView.alphaValue == 1, "only Home waits for its conversation") + } + + @Test func homeIsReadyOnceItsConversationsFirstPageIsIn() async { + let (services, reveal) = Self.services() + let view = HomeHostView(services: services, conversation: "conv_local") + view.frame = NSRect(x: 0, y: 0, width: 600, height: 700) + let deadline = ContinuousClock.now + .seconds(10) + // Polls a state the view reaches on its own turns (the offline store answering the open). + while !reveal.isReady(.home), ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(20)) } + #expect(reveal.isReady(.home), "Home's first page came in and the launch still held Home") + withExtendedLifetime(view) {} + } + + /// Home is made after the launch window (3049 ms on a cold launch, the sidebar's hold long + /// before): the launch deadline must not release it before it is held, and its own wait runs + /// from its own hold. The other regions still show at the launch deadline. + @Test func aHomeHeldAfterTheLaunchDeadlineWaitsItsOwn() async throws { + let clock = ManualClock() + let reveal = LaunchReveal(clock: clock, deadline: .milliseconds(1500)) + let sidebar = NSView(), home = NSView() + reveal.hold(sidebar, until: .sidebar) + try await clock.waitForSleeper() + clock.advance(by: .milliseconds(1500)) + await Self.yield(until: { reveal.isReady(.sidebar) }) + #expect(reveal.isReady(.sidebar) && reveal.isReady(.pane), "the launch deadline still shows the window's regions") + reveal.hold(home, until: .home) + #expect(home.alphaValue == 0, "the launch deadline released Home before it was made: it came in pieces") + try await clock.waitForSleeper() + clock.advance(by: .milliseconds(1500)) + await Self.yield(until: { reveal.isReady(.home) }) + #expect(home.alphaValue == 1, "a Home whose conversation never came stayed clear past its own deadline") + } + + /// A Home closed before its page came (a fresh Home replaced it) leaves its deadline behind: + /// the Home that replaced it waits its own, never the closed one's remainder. + @Test func aHomeThatReplacesAClosedOneWaitsItsOwnDeadline() async throws { + let clock = ManualClock() + let reveal = LaunchReveal(clock: clock, deadline: .milliseconds(1500)) + let first = NSView(), second = NSView() + reveal.hold(first, until: .home) + try await clock.waitForSleeper() + clock.advance(by: .milliseconds(1000)) + reveal.release(first) + reveal.hold(second, until: .home) + try await clock.waitForSleeper() + clock.advance(by: .milliseconds(600)) + await Self.yield(until: { reveal.isReady(.home) }) + #expect(second.alphaValue == 0, "the replacing Home showed at the closed Home's deadline, before its page") + clock.advance(by: .milliseconds(900)) + await Self.yield(until: { reveal.isReady(.home) }) + #expect(second.alphaValue == 1, "the replacing Home stayed clear past its own deadline") + } + + // MARK: Fixtures + + /// Lets a timer that fired run its main-actor work. + static func yield(until done: () -> Bool) async { + for _ in 0..<200 where !done() { await Task.yield() } + } + + /// A reveal whose deadline never fires during a test. + static func services() -> (AppServices, LaunchReveal) { + _ = NSApplication.shared + let reveal = LaunchReveal(deadline: .seconds(60)) + return (AppServices(environment: AppEnvironment.current([:]), launchReveal: reveal), reveal) + } + + private static func connected() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"home-launch-reveal-test","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + /// A workspace of `kind` with no pane yet (Home as `ensure_home` returns it), shown in a window. + static func shown(kind: String) throws -> (AppServices, LaunchReveal, NSWindow, WorkspaceContentController) { + let (services, reveal) = Self.services() + let store = services.daemon.store + store.connectionState = .connected(try Self.connected()) + var workspace = WorkspaceSnapshot(id: WorkspaceHandle(rawValue: 1), key: nil, name: "Home") + workspace.kind = kind + store.apply(snapshot: DaemonTree(workspaces: [workspace])) + let model = try #require(store.workspaces.first) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"w1","workspace_key":"w1"}"#.utf8)) + let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 1200, height: 700), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let content = WorkspaceContentController(workspace: model, daemon: services.daemon, services: services, state: WindowState(record: record)) + content.contentView.frame = NSRect(x: 0, y: 0, width: 1200, height: 700) + window.contentView = content.contentView + window.orderFrontRegardless() + content.applyCurrent() + return (services, reveal, window, content) + } +} diff --git a/scripts/cmux-next/bench-startup.py b/scripts/cmux-next/bench-startup.py index 62504d12143e..38bb1860cb9c 100755 --- a/scripts/cmux-next/bench-startup.py +++ b/scripts/cmux-next/bench-startup.py @@ -118,6 +118,7 @@ ("reveal.sidebar", "sidebar revealed"), ("reveal.tabs", "tab strips revealed"), ("reveal.pane", "pane content revealed"), + ("reveal.home", "Home revealed"), ("agent_pane.view_created", "first agent pane view made"), ("agent_pane.page_loaded", "agent page loaded (didFinish)"), ("agent_pane.handshake_start", "agent page asked for its handshake"),