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
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,12 +34,16 @@ public final class LaunchReveal {
@ObservationIgnored private var held: [LaunchRegion: NSHashTable<NSView>] = [:]
@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<Duration>
@ObservationIgnored private let deadline: Duration

/// - Parameter deadline: How long after the first `hold` every region
/// shows anyway.
public init(clock: any Clock<Duration> = ContinuousClock(), deadline: Duration = .milliseconds(1500)) {
deadlineTimer = DemandTimer(owner: "launch.reveal-deadline", clock: clock)
self.clock = clock
self.deadline = deadline
}

Expand All @@ -52,9 +57,21 @@ public final class LaunchReveal {
// a defaulted subscript would go to a temporary.
let table = held[region] ?? NSHashTable<NSView>.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) }
}
}

Expand Down Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions scripts/cmux-next/bench-startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading