From 3cc0d656b6780a99e333ddb0dbd0e87c949e5028 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 13:50:06 -0700 Subject: [PATCH 01/22] test: reproduce cold Cmd-N placement after stale layout Agent-Session: https://cmux.com/deeplink/agent?id=aid-7d538a3fe147c325f822 (cherry picked from commit 8129deaa308961fdaaad663f30802e2152348c7b) --- .../Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index 2ec9fd0f05a1..6b758ed8aa36 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -70,6 +70,14 @@ struct ColdCmdNRowSlotTests { let anchor = try #require(before.firstIndex(of: current.id), "middle workspace is not drawn: \(before)") try #require(anchor + 1 < before.count, "need a row following the selected middle workspace: \(before)") + // An updater can leave the first cold layout one projection behind while AppKit has no + // realized row geometry yet. Keep that state across the first Cmd-N so it cannot expose + // the daemon's temporary tail placement as a visible bottom frame. + let document = try #require(SidebarDocStore.current) + document.shownVersion = document.version &- 1 + controller.sidebar.model.setSections([]) + controller.sidebar.container.sidebarView.renderNow() + var frames: [[String]] = [] let previousOnApply = list.onApply defer { list.onApply = previousOnApply } From a2af5fdec5275a3564fb655184240c4c15289712 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 13:55:48 -0700 Subject: [PATCH 02/22] fix: defer cold Cmd-N until its current row is realized Agent-Session: https://cmux.com/deeplink/agent?id=aid-7d538a3fe147c325f822 (cherry picked from commit 9ab7ef49d135684c5316af63f847b599e753a1d2) --- .../Sources/CmuxNextApp/SidebarBridge.swift | 4 +++ .../Windows/NewWorkspaceDefaultSlot.swift | 25 ++++++++++++++++++- .../Windows/PendingWorkspaceRows.swift | 17 ++++++++++--- .../Windows/WindowManager+Placement.swift | 3 ++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index 69ddd6375a2e..2ebb6f161e02 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -153,6 +153,10 @@ final class SidebarBridge { guard let self else { return } self.showProfiles(profiles, active: active, launching: launching) self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) + // A cold/update layout can omit the current row when the create + // arrives. Retry deferred default placements after this pass has + // realized the fresh rows, before pending stand-ins are forgotten. + services.windows.applyPendingPlacements(live: Set(services.windows.pendingPlacements.keys)) // A workspace the window lists shows its own row from here on (cx-9a7wx). No create waits: no walk of every row. guard !PendingWorkspaceRows.shared.rows.isEmpty else { return } PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index b13a60cdb9c0..cd1f1d7cd6c7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -25,6 +25,26 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { var placement: NewWorkspacePlacement /// The workspace the window showed when the new one was asked for. var current: String? + /// The machine of `current`, when it was known at the press. A cold + /// sidebar can briefly omit that row while its document projection catches + /// up; only an omitted row on this same machine needs to defer placement. + var currentMachine: String? = nil + + init(placement: NewWorkspacePlacement, current: String?, currentMachine: String? = nil) { + self.placement = placement + self.current = current + self.currentMachine = currentMachine + } + + /// The current row is expected in this machine section but is not in the + /// stale layout yet. Returning true keeps Cmd-N pending until the row is + /// realized, instead of resolving the slot against an empty layout. + func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection]) -> Bool { + guard placement == .afterCurrent, let current, let currentMachine, + case let .machine(machine) = section, machine.rawValue == currentMachine, + !sections.flatMap(\.workspaces).contains(where: { $0.id.rawValue == current }) else { return false } + return true + } /// The slot for new workspace `id` in `section` of a window's `sections`; /// nil keeps the daemon's place. `home` is the home workspace's id. @@ -37,6 +57,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } return nil case .afterCurrent: + if awaitsCurrentAnchor(section: section, in: sections) { return nil } if let current, current != id, current != home, WorkspaceSlot.below(current).position(moving: [id], section: section, in: sections) != nil { return .below(current) @@ -102,7 +123,9 @@ enum NewWorkspacePlacements { static func rule(for windowID: String?, in windows: WindowManager) -> NewWorkspaceDefaultSlot { let placement = windows.services.settings?.snapshot.newWorkspacePlacement ?? CmuxConfigSnapshot.newWorkspacePlacementFallback let window = windowID ?? windows.registry.value.mostRecentOpen() - return NewWorkspaceDefaultSlot(placement: placement, current: window.flatMap { windows.states[$0]?.workspaceID }) + let current = window.flatMap { windows.states[$0]?.workspaceID } + let currentMachine = current.flatMap { windows.services.machines.daemon(forWorkspace: $0)?.machineID } + return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) } /// Places workspace `id` at `slot`, else by `byDefault`, in window diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift index a98aaee1ea33..d9d6135a4b3b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift @@ -128,9 +128,20 @@ final class PendingWorkspaceRows { let listed = Set(sections.flatMap(\.workspaces).map(\.id.rawValue)) for row in rows where !listed.contains(row.id) { let section = SectionID.machine(row.machine) - // No slot keeps the daemon's place: the end of the loose rows. - let slot = row.slot ?? row.byDefault?.slot(for: row.id, section: section, in: sections, home: home) ?? .bottom(anchor: nil) - guard let position = slot.position(moving: [row.id], section: section, in: sections) else { continue } + // An explicit slot is ready at the press. A default slot whose + // current anchor is missing belongs to the stale cold layout and + // must wait for that anchor, rather than drawing a bottom frame. + let slot: WorkspaceSlot? + if let explicit = row.slot { + slot = explicit + } else if let byDefault = row.byDefault { + guard let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home) else { continue } + slot = resolved + } else { + // No slot keeps the daemon's place: the end of the loose rows. + slot = .bottom(anchor: nil) + } + guard let slot, let position = slot.position(moving: [row.id], section: section, in: sections) else { continue } SidebarEdits.insert([row.face], at: position, in: §ions) } return sections diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 9b33b8d4b307..1c7d8a8fbc5a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -27,11 +27,12 @@ extension WindowManager { func applyPendingPlacements(live: Set) { // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. for (id, pending) in pendingPlacements where live.contains(id) && services.machines.workspace(id: id)?.0.isProvisional != true { - pendingPlacements[id] = nil guard let window = pending.window ?? registry.value.owner(of: id), let bridge = controller(for: window)?.sidebar, let daemon = services.machines.daemon(forWorkspace: id) else { continue } let sections = SidebarRowSection.withoutPlaceholders(bridge.model.sections) let section = SectionID.machine(MachineID(daemon.machineID)) + if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections) == true { continue } + pendingPlacements[id] = nil let home = NewWorkspacePlacements.home(services.machines) // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. From 34f95d02b7def680c659e76a7c9ee055c55a29b6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:08:06 -0700 Subject: [PATCH 03/22] test: cover cold default slot anchor wait Agent-Session: https://cmux.com/deeplink/agent?id=aid-7d538a3fe147c325f822 (cherry picked from commit f3df4bd7479cba39c89ed768c7786c59de5b06b9) --- .../ColdCmdNRowSlotTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index 6b758ed8aa36..7873764dd2bf 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -70,6 +70,32 @@ struct ColdCmdNRowSlotTests { let anchor = try #require(before.firstIndex(of: current.id), "middle workspace is not drawn: \(before)") try #require(anchor + 1 < before.count, "need a row following the selected middle workspace: \(before)") + // Exercise the pending stand-in directly while the restored sidebar projection is + // empty. The real placement rule carries the current machine from the Cmd-N press; + // the pre-fix path resolved this state to a top/bottom frame. + let machine = try #require(services.machines.daemon(forWorkspace: current.id)?.machineID, + "current workspace daemon is unavailable") + let probeRule = NewWorkspacePlacements.rule(for: window, in: services.windows) + let probe = PendingWorkspaceRows.Row( + id: "cold-probe", + window: window, + machine: MachineID(machine), + face: SidebarMapping.shared.creatingRow(id: "cold-probe", machine: MachineID(machine), name: "cold-probe", opensPage: true), + slot: nil, + byDefault: probeRule + ) + let staleSections = [SidebarRowSection( + kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), nodes: []) + ] + let coldFrame = PendingWorkspaceRows.adding([probe], to: staleSections, home: nil) + #expect(coldFrame.flatMap(\.workspaces).isEmpty, "a stale cold layout must not draw a top or bottom frame") + let settledSections = [SidebarRowSection( + kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), + nodes: [.workspace(SidebarWorkspace(id: current.id, machineID: MachineID(machine), title: "current"))] + )] + let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) + #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) + // An updater can leave the first cold layout one projection behind while AppKit has no // realized row geometry yet. Keep that state across the first Cmd-N so it cannot expose // the daemon's temporary tail placement as a visible bottom frame. From ee3614ed6d890e2c67c94562663a7fae11d5f49e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:31:20 -0700 Subject: [PATCH 04/22] fix: preserve cold placement fallbacks Agent-Session: https://cmux.com/deeplink/agent?id=aid-7d538a3fe147c325f822 (cherry picked from commit c6cfa9c8303d8fd0001826c19425bb2ab6815df8) --- .../CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift | 5 +++-- .../CmuxNextApp/Windows/PendingWorkspaceRows.swift | 9 +++++++-- .../CmuxNextApp/Windows/WindowManager+Placement.swift | 5 +++-- .../Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift | 10 ++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index cd1f1d7cd6c7..fb7604936292 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -39,8 +39,9 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { /// The current row is expected in this machine section but is not in the /// stale layout yet. Returning true keeps Cmd-N pending until the row is /// realized, instead of resolving the slot against an empty layout. - func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection]) -> Bool { + func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil) -> Bool { guard placement == .afterCurrent, let current, let currentMachine, + current != home, case let .machine(machine) = section, machine.rawValue == currentMachine, !sections.flatMap(\.workspaces).contains(where: { $0.id.rawValue == current }) else { return false } return true @@ -57,7 +58,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } return nil case .afterCurrent: - if awaitsCurrentAnchor(section: section, in: sections) { return nil } + if awaitsCurrentAnchor(section: section, in: sections, home: home) { return nil } if let current, current != id, current != home, WorkspaceSlot.below(current).position(moving: [id], section: section, in: sections) != nil { return .below(current) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift index d9d6135a4b3b..1d5761dd44e7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift @@ -135,8 +135,13 @@ final class PendingWorkspaceRows { if let explicit = row.slot { slot = explicit } else if let byDefault = row.byDefault { - guard let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home) else { continue } - slot = resolved + if let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home) { + slot = resolved + } else if byDefault.awaitsCurrentAnchor(section: section, in: sections, home: home) { + continue + } else { + slot = .bottom(anchor: nil) + } } else { // No slot keeps the daemon's place: the end of the loose rows. slot = .bottom(anchor: nil) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 1c7d8a8fbc5a..b428c552011e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -31,9 +31,10 @@ extension WindowManager { let daemon = services.machines.daemon(forWorkspace: id) else { continue } let sections = SidebarRowSection.withoutPlaceholders(bridge.model.sections) let section = SectionID.machine(MachineID(daemon.machineID)) - if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections) == true { continue } - pendingPlacements[id] = nil let home = NewWorkspacePlacements.home(services.machines) + if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, + pending.byDefault?.current.flatMap({ services.machines.workspace(id: $0) }) != nil { continue } + pendingPlacements[id] = nil // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. let slot = pending.slot ?? (daemon.isLocal ? pending.byDefault?.move(for: id, section: section, in: sections, home: home) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index 7873764dd2bf..fd5e0c09ea0d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -95,6 +95,16 @@ struct ColdCmdNRowSlotTests { )] let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) + #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, home: current.id), + "Home/current rows intentionally omitted from the sidebar must use the normal fallback") + let bottomRule = NewWorkspaceDefaultSlot(placement: .bottom, current: nil) + let bottom = PendingWorkspaceRows.Row( + id: "bottom-probe", window: window, machine: MachineID(machine), + face: SidebarMapping.shared.creatingRow(id: "bottom-probe", machine: MachineID(machine), name: "bottom-probe", opensPage: true), + slot: nil, byDefault: bottomRule) + let bottomFrame = PendingWorkspaceRows.adding([bottom], to: staleSections, home: nil) + #expect(bottomFrame.flatMap(\.workspaces).map(\.id.rawValue) == ["bottom-probe"], + "bottom placement keeps its immediate loose-row stand-in") // An updater can leave the first cold layout one projection behind while AppKit has no // realized row geometry yet. Keep that state across the first Cmd-N so it cannot expose From a0211a5f5150e17218431850c91c7f69338526fc Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 14:39:05 -0700 Subject: [PATCH 05/22] fix: correct cold slot regression types Agent-Session: https://cmux.com/deeplink/agent?id=aid-7d538a3fe147c325f822 (cherry picked from commit 290df631b4ceb2b9413dc540e63a02ae00cbffec) --- .../Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index fd5e0c09ea0d..bae8d29ae503 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -91,10 +91,10 @@ struct ColdCmdNRowSlotTests { #expect(coldFrame.flatMap(\.workspaces).isEmpty, "a stale cold layout must not draw a top or bottom frame") let settledSections = [SidebarRowSection( kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), - nodes: [.workspace(SidebarWorkspace(id: current.id, machineID: MachineID(machine), title: "current"))] + nodes: [.workspace(SidebarWorkspace(id: WorkspaceID(current.id), machineID: MachineID(machine), title: "current"))] )] let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) - #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) + #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id.rawValue, "cold-probe"]) #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, home: current.id), "Home/current rows intentionally omitted from the sidebar must use the normal fallback") let bottomRule = NewWorkspaceDefaultSlot(placement: .bottom, current: nil) From 1013a053e31c5e6d1c89ca56fb9937cd6729aa09 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 18:26:00 -0700 Subject: [PATCH 06/22] fix: a current workspace shown in a sidebar band does not defer Cmd-N placement The list leaves pinned, top-region and custom-section workspaces out on purpose, so their absence is not a stale cold layout: the press records no current machine for them, and the placement pass stops waiting once the current workspace has been pinned since the press. afterCurrent then falls back to top, as documented. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Windows/NewWorkspaceDefaultSlot.swift | 16 +++++++++++++++- .../Windows/WindowManager+Placement.swift | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index fb7604936292..d8d70f3a2bec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -28,6 +28,9 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { /// The machine of `current`, when it was known at the press. A cold /// sidebar can briefly omit that row while its document projection catches /// up; only an omitted row on this same machine needs to defer placement. + /// Nil when `current` shows in a band of the window's layout (pinned, the + /// top region, a custom section): the list leaves that row out on purpose, + /// so its absence is no stale layout and `afterCurrent` falls back to `top`. var currentMachine: String? = nil init(placement: NewWorkspacePlacement, current: String?, currentMachine: String? = nil) { @@ -125,10 +128,21 @@ enum NewWorkspacePlacements { let placement = windows.services.settings?.snapshot.newWorkspacePlacement ?? CmuxConfigSnapshot.newWorkspacePlacementFallback let window = windowID ?? windows.registry.value.mostRecentOpen() let current = window.flatMap { windows.states[$0]?.workspaceID } - let currentMachine = current.flatMap { windows.services.machines.daemon(forWorkspace: $0)?.machineID } + let layout = window.flatMap { windows.controller(for: $0)?.sidebar.model.layout } + let currentMachine = current.flatMap { id in + layout.map { shownInBand(id, layout: $0, machines: windows.services.machines) } == true + ? nil : windows.services.machines.daemon(forWorkspace: id)?.machineID + } return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) } + /// Whether workspace `id` shows as an item of a section of `layout` (pinned, the top region, a + /// custom section), which the window's workspace list leaves out. + static func shownInBand(_ id: String, layout: SidebarLayoutDocument, machines: MachineRegistry) -> Bool { + guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } + return layout.sections.contains { $0.items.contains { $0.ref == ref } } + } + /// Places workspace `id` at `slot`, else by `byDefault`, in window /// `windowID` (nil: the window that lists it): now when it is mirrored, /// else once the daemon reports it. `then` runs after, with the sidebar. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index b428c552011e..4feb634770cf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -32,8 +32,10 @@ extension WindowManager { let sections = SidebarRowSection.withoutPlaceholders(bridge.model.sections) let section = SectionID.machine(MachineID(daemon.machineID)) let home = NewWorkspacePlacements.home(services.machines) + // Waits for a current row a cold layout omits; one pinned since the press shows in the band, not the list. if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, - pending.byDefault?.current.flatMap({ services.machines.workspace(id: $0) }) != nil { continue } + let current = pending.byDefault?.current, services.machines.workspace(id: current) != nil, + !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, machines: services.machines) { continue } pendingPlacements[id] = nil // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. From f28c7d8dcb69abaa6e0a04b36b102f4170a513db Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 19:39:52 -0700 Subject: [PATCH 07/22] fix: a legacy-pinned current workspace does not defer Cmd-N placement either Legacy pins (workspace-pin-v1) leave the machine section with no layout item, so shownInBand also counts the workspace's own pinned flag; the cold-slot test compares plain string ids. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift | 6 ++++-- .../Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 06f74dc0cfea..1539f3d6f686 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -136,9 +136,11 @@ enum NewWorkspacePlacements { return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) } - /// Whether workspace `id` shows as an item of a section of `layout` (pinned, the top region, a - /// custom section), which the window's workspace list leaves out. + /// Whether workspace `id` shows outside the window's workspace list: an item of a section of + /// `layout` (pinned, the top region, a custom section), or a legacy pin (`workspace-pin-v1`, the + /// Pinned list section while the sidebar layout is unavailable). static func shownInBand(_ id: String, layout: SidebarLayoutDocument, machines: MachineRegistry) -> Bool { + if machines.workspace(id: id)?.0.pinned == true { return true } guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } return layout.sections.contains { $0.items.contains { $0.ref == ref } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index bae8d29ae503..3f62e4feb8fb 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -94,7 +94,7 @@ struct ColdCmdNRowSlotTests { nodes: [.workspace(SidebarWorkspace(id: WorkspaceID(current.id), machineID: MachineID(machine), title: "current"))] )] let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) - #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id.rawValue, "cold-probe"]) + #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, home: current.id), "Home/current rows intentionally omitted from the sidebar must use the normal fallback") let bottomRule = NewWorkspaceDefaultSlot(placement: .bottom, current: nil) From 090ba6353a38aa16df305bf9f88505753bdf1f70 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 20:55:30 -0700 Subject: [PATCH 08/22] fix: Cmd-N waits for a missing current row only while the sidebar layout is catching up A current row can be left out of the list on purpose (the filter, another space, a close in flight), so its absence alone is not a stale layout. awaitsCurrentAnchor now also requires the sidebar document's drawn projection to be behind its version; once it has caught up the slot falls back as documented and the placement completes. The cold-slot test sets that state explicitly and checks the caught-up fallback for a non-home row (review at f28c7d8d). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Windows/NewWorkspaceDefaultSlot.swift | 18 +++++++++++++++--- .../ColdCmdNRowSlotTests.swift | 8 +++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 1539f3d6f686..5cff1d7670a1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -41,15 +41,27 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { /// The current row is expected in this machine section but is not in the /// stale layout yet. Returning true keeps Cmd-N pending until the row is - /// realized, instead of resolving the slot against an empty layout. - func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil) -> Bool { - guard placement == .afterCurrent, let current, let currentMachine, + /// realized, instead of resolving the slot against an empty layout. Only + /// while the sidebar document's drawn projection is behind its version + /// (`layoutCatchingUp`): once it has caught up, a row still missing was + /// left out on purpose (a filter, another space, a close) and the slot + /// falls back as documented. + func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil, + catchingUp: Bool = Self.layoutCatchingUp()) -> Bool { + guard catchingUp, placement == .afterCurrent, let current, let currentMachine, current != home, case let .machine(machine) = section, machine.rawValue == currentMachine, !sections.flatMap(\.workspaces).contains(where: { $0.id.rawValue == current }) else { return false } return true } + /// Whether the sidebar document's drawn projection is behind its version (a cold or updated + /// layout a hop before its rows show). + static func layoutCatchingUp() -> Bool { + guard let store = SidebarDocStore.current, store.isActive else { return false } + return store.shown == nil || store.shownVersion != store.version + } + /// The slot for new workspace `id` in `section` of a window's `sections`; /// nil keeps the daemon's place. `home` is the home workspace's id. func slot(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?) -> WorkspaceSlot? { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index 3f62e4feb8fb..bc7aeda10908 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -87,6 +87,9 @@ struct ColdCmdNRowSlotTests { let staleSections = [SidebarRowSection( kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), nodes: []) ] + // The document's drawn projection is a hop behind, as on a cold launch. + let document = try #require(SidebarDocStore.current) + document.shownVersion = document.version &- 1 let coldFrame = PendingWorkspaceRows.adding([probe], to: staleSections, home: nil) #expect(coldFrame.flatMap(\.workspaces).isEmpty, "a stale cold layout must not draw a top or bottom frame") let settledSections = [SidebarRowSection( @@ -95,8 +98,8 @@ struct ColdCmdNRowSlotTests { )] let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) - #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, home: current.id), - "Home/current rows intentionally omitted from the sidebar must use the normal fallback") + #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, catchingUp: false), + "once the layout caught up, a current row left out on purpose (filter, space, close) uses the normal fallback") let bottomRule = NewWorkspaceDefaultSlot(placement: .bottom, current: nil) let bottom = PendingWorkspaceRows.Row( id: "bottom-probe", window: window, machine: MachineID(machine), @@ -109,7 +112,6 @@ struct ColdCmdNRowSlotTests { // An updater can leave the first cold layout one projection behind while AppKit has no // realized row geometry yet. Keep that state across the first Cmd-N so it cannot expose // the daemon's temporary tail placement as a visible bottom frame. - let document = try #require(SidebarDocStore.current) document.shownVersion = document.version &- 1 controller.sidebar.model.setSections([]) controller.sidebar.container.sidebarView.renderNow() From 657487483d46c1e92b8c17cf8ad0e3062c34b23f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 21:08:34 -0700 Subject: [PATCH 09/22] fix: a placement retry inside a placement's own rows refresh returns at once The rows pass now retries deferred placements, and a placement refreshes the rows pass (syncNow) to read current rows, so the two recursed without end. applyPendingPlacements returns when it is already running, and the rows callback calls it only while a placement waits (review at 090ba635). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift | 5 ++++- .../CmuxNextApp/Windows/WindowManager+Placement.swift | 3 +++ .../CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index f4f778f813aa..874f6c001bdb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -165,7 +165,10 @@ final class SidebarBridge { // A cold/update layout can omit the current row when the create // arrives. Retry deferred default placements after this pass has // realized the fresh rows, before pending stand-ins are forgotten. - services.windows.applyPendingPlacements(live: Set(services.windows.pendingPlacements.keys)) + // Only when a placement waits; inside a placement's own rows refresh the call returns at once. + if !services.windows.pendingPlacements.isEmpty { + services.windows.applyPendingPlacements(live: Set(services.windows.pendingPlacements.keys)) + } // A workspace the window lists shows its own row from here on (cx-9a7wx). No create waits: no walk of every row. guard !PendingWorkspaceRows.shared.rows.isEmpty else { return } PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index f067f2210f63..403cf760e655 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -25,6 +25,9 @@ struct PendingPlacement { // owning daemon. extension WindowManager { func applyPendingPlacements(live: Set) { + guard !applyingPlacements else { return } + applyingPlacements = true + defer { applyingPlacements = false } // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. let ready = pendingPlacementOrder.filter { live.contains($0) && services.machines.workspace(id: $0)?.0.isProvisional == false } // A placement waiting on its current row keeps its entry and its place in the order. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift index 858fa8e02405..2ee4ff2d8e7e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift @@ -57,6 +57,9 @@ final class WindowManager { var pendingPlacements: [String: PendingPlacement] = [:] /// Registration order survives a daemon snapshot that reports several creates together. var pendingPlacementOrder: [String] = [] + /// `applyPendingPlacements` is running: its rows refresh runs the sidebar's rows pass, whose + /// callback retries deferred placements, so a nested call returns at once (no re-entry). + var applyingPlacements = false /// Windows none of whose workspaces a machine reports yet (just created, /// or saved with workspaces that are gone or on a Cloud machine still /// connecting): kept off screen until their content is installed, so no From 02cebad1e90a98c83bea57e3a9c7466c50133bad Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 22:13:50 -0700 Subject: [PATCH 10/22] lint: currentMachine is implicitly nil Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 5cff1d7670a1..e7d66ad006bf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -31,7 +31,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { /// Nil when `current` shows in a band of the window's layout (pinned, the /// top region, a custom section): the list leaves that row out on purpose, /// so its absence is no stale layout and `afterCurrent` falls back to `top`. - var currentMachine: String? = nil + var currentMachine: String? init(placement: NewWorkspacePlacement, current: String?, currentMachine: String? = nil) { self.placement = placement From 19ed3feae0ae6693c05971959da6f5eca320e872 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:26:22 -0700 Subject: [PATCH 11/22] Placement retry lives beside the placements; the god files do not grow retryDeferredPlacements and the re-entry flag move into WindowManager+Placement.swift, so WindowManager.swift is back to base's size and the rows callback is one line. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Sources/CmuxNextApp/SidebarBridge.swift | 8 +------ .../Windows/WindowManager+Placement.swift | 21 ++++++++++++++++--- .../CmuxNextApp/Windows/WindowManager.swift | 3 --- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index 874f6c001bdb..3cf441ff47f2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -162,13 +162,7 @@ final class SidebarBridge { guard let self else { return } self.showProfiles(profiles, active: active, launching: launching) self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) - // A cold/update layout can omit the current row when the create - // arrives. Retry deferred default placements after this pass has - // realized the fresh rows, before pending stand-ins are forgotten. - // Only when a placement waits; inside a placement's own rows refresh the call returns at once. - if !services.windows.pendingPlacements.isEmpty { - services.windows.applyPendingPlacements(live: Set(services.windows.pendingPlacements.keys)) - } + services.windows.retryDeferredPlacements() // before pending stand-ins are forgotten // A workspace the window lists shows its own row from here on (cx-9a7wx). No create waits: no walk of every row. guard !PendingWorkspaceRows.shared.rows.isEmpty else { return } PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 403cf760e655..beba273b5843 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -24,10 +24,19 @@ struct PendingPlacement { // personal order in the home session, else `move-workspace-to-group` on the // owning daemon. extension WindowManager { + /// Retries the placements a cold or updated layout deferred (`awaitsCurrentAnchor`), once a + /// sidebar rows pass has realized the fresh rows; nothing to do while none waits. + func retryDeferredPlacements() { + guard !pendingPlacements.isEmpty else { return } + applyPendingPlacements(live: Set(pendingPlacements.keys)) + } + func applyPendingPlacements(live: Set) { - guard !applyingPlacements else { return } - applyingPlacements = true - defer { applyingPlacements = false } + // A placement's rows refresh runs the sidebar's rows pass, whose callback retries deferred + // placements: a nested call returns at once (no re-entry). + guard !PlacementPass.running else { return } + PlacementPass.running = true + defer { PlacementPass.running = false } // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. let ready = pendingPlacementOrder.filter { live.contains($0) && services.machines.workspace(id: $0)?.0.isProvisional == false } // A placement waiting on its current row keeps its entry and its place in the order. @@ -78,3 +87,9 @@ extension WindowManager { then: then, windows: self) } } + +/// Whether `applyPendingPlacements` is running (one main-actor pass at a time). +@MainActor +private enum PlacementPass { + static var running = false +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift index 2ee4ff2d8e7e..858fa8e02405 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift @@ -57,9 +57,6 @@ final class WindowManager { var pendingPlacements: [String: PendingPlacement] = [:] /// Registration order survives a daemon snapshot that reports several creates together. var pendingPlacementOrder: [String] = [] - /// `applyPendingPlacements` is running: its rows refresh runs the sidebar's rows pass, whose - /// callback retries deferred placements, so a nested call returns at once (no re-entry). - var applyingPlacements = false /// Windows none of whose workspaces a machine reports yet (just created, /// or saved with workspaces that are gone or on a Cloud machine still /// connecting): kept off screen until their content is installed, so no From 77f747c6004854788f03d5a0c6f799a22bb25e6d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:28:15 -0700 Subject: [PATCH 12/22] The placement re-entry flag is PendingWorkspaceRows instance state, not a static Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../CmuxNextApp/Windows/PendingWorkspaceRows.swift | 2 ++ .../Windows/WindowManager+Placement.swift | 13 ++++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift index 1d5761dd44e7..efe8bd251819 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift @@ -35,6 +35,8 @@ final class PendingWorkspaceRows { /// Each window's sidebar, redrawn in the turn a row comes or goes: the row draws over the rows the /// sidebar shows already, not after a rows pass walks every workspace on a later hop. let redraws = SidebarRedraws() + /// `WindowManager.applyPendingPlacements` is running (it refreshes a rows pass that retries placements). + @ObservationIgnored var placing = false func add(_ row: Row, name: String) { rows.append(row) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index beba273b5843..d4761398a812 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -34,9 +34,10 @@ extension WindowManager { func applyPendingPlacements(live: Set) { // A placement's rows refresh runs the sidebar's rows pass, whose callback retries deferred // placements: a nested call returns at once (no re-entry). - guard !PlacementPass.running else { return } - PlacementPass.running = true - defer { PlacementPass.running = false } + let rows = PendingWorkspaceRows.shared + guard !rows.placing else { return } + rows.placing = true + defer { rows.placing = false } // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. let ready = pendingPlacementOrder.filter { live.contains($0) && services.machines.workspace(id: $0)?.0.isProvisional == false } // A placement waiting on its current row keeps its entry and its place in the order. @@ -87,9 +88,3 @@ extension WindowManager { then: then, windows: self) } } - -/// Whether `applyPendingPlacements` is running (one main-actor pass at a time). -@MainActor -private enum PlacementPass { - static var running = false -} From ff4377eb79dc5dd3d9e4e41f1324516fa9b913b9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:44:29 -0700 Subject: [PATCH 13/22] fix: a placement retry before membership names the owner keeps its entry The rows-pass retry runs every pending placement, including one made with no window whose owner comes from membership reconciliation. Dropping it there lost its slot and callback for good. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../CmuxNextApp/Windows/WindowManager+Placement.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index d4761398a812..7952dd69717b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -45,9 +45,10 @@ extension WindowManager { var refreshed: Set = [] for id in ready { guard let pending = pendingPlacements[id] else { continue } + // A rows-pass retry can run before membership names the owner: keep the entry for that pass. + guard let window = pending.window ?? registry.value.owner(of: id) else { continue } pendingPlacements[id] = nil - guard let window = pending.window ?? registry.value.owner(of: id), let bridge = controller(for: window)?.sidebar, - let daemon = services.machines.daemon(forWorkspace: id) else { continue } + guard let bridge = controller(for: window)?.sidebar, let daemon = services.machines.daemon(forWorkspace: id) else { continue } // One store-backed rows pass per window; each following placement // reads the model with the preceding optimistic moves applied. let sections = Self.placementSections(in: bridge, refresh: refreshed.insert(window).inserted) From da901d8b59120541ecb2e02f80f1f5238b2b0fc9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:27:02 -0700 Subject: [PATCH 14/22] test: a band of another space does not hide the current row from cold Cmd-N shownInBand takes the window's space; this commit only plumbs it, so the new test fails here. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Windows/NewWorkspaceDefaultSlot.swift | 10 +++++----- .../Windows/WindowManager+Placement.swift | 3 ++- .../NewWorkspaceBandRoomTests.swift | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index e7d66ad006bf..c1976a4df9b3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -140,18 +140,18 @@ enum NewWorkspacePlacements { let placement = windows.services.settings?.snapshot.newWorkspacePlacement ?? CmuxConfigSnapshot.newWorkspacePlacementFallback let window = windowID ?? windows.registry.value.mostRecentOpen() let current = window.flatMap { windows.states[$0]?.workspaceID } - let layout = window.flatMap { windows.controller(for: $0)?.sidebar.model.layout } + let model = window.flatMap { windows.controller(for: $0)?.sidebar.model } let currentMachine = current.flatMap { id in - layout.map { shownInBand(id, layout: $0, machines: windows.services.machines) } == true + model.map { shownInBand(id, layout: $0.layout, room: $0.activeProfileID?.rawValue, machines: windows.services.machines) } == true ? nil : windows.services.machines.daemon(forWorkspace: id)?.machineID } return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) } /// Whether workspace `id` shows outside the window's workspace list: an item of a section of - /// `layout` (pinned, the top region, a custom section), or a legacy pin (`workspace-pin-v1`, the - /// Pinned list section while the sidebar layout is unavailable). - static func shownInBand(_ id: String, layout: SidebarLayoutDocument, machines: MachineRegistry) -> Bool { + /// `layout` that shows in the window's space `room` (pinned, the top region, a custom section), or a + /// legacy pin (`workspace-pin-v1`, the Pinned list section while the sidebar layout is unavailable). + static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, machines: MachineRegistry) -> Bool { if machines.workspace(id: id)?.0.pinned == true { return true } guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } return layout.sections.contains { $0.items.contains { $0.ref == ref } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 7952dd69717b..9948253a437a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -57,7 +57,8 @@ extension WindowManager { // Waits for a current row a cold layout omits; one pinned since the press shows in the band, not the list. if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, let current = pending.byDefault?.current, services.machines.workspace(id: current) != nil, - !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, machines: services.machines) { + !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, + machines: services.machines) { pendingPlacements[id] = pending continue } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift new file mode 100644 index 000000000000..9a4269827a99 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift @@ -0,0 +1,17 @@ +@testable import CmuxNextApp +@testable import CmuxNextSidebar +import Testing + +/// Cold Cmd-N waits for the current row only while the window's list should show it: a workspace +/// pinned in a band of another space still lists in this one, so it does not count as in the band. +@MainActor @Suite struct NewWorkspaceBandRoomTests { + @Test func aBandOfAnotherSpaceDoesNotHideTheCurrentRow() throws { + let (_, refs, ref) = ChiefWorkspaceRowsTests.pinnedChiefWorkspace("band room", harness: nil) + let id = try #require(refs.workspace(for: ref)).0.id + let other = LayoutSection(id: LayoutSectionID("sec_other"), region: .top, room: "space-b", + items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) + let layout = SidebarLayoutDocument(sections: [other]) + #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", machines: refs.machines)) + #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", machines: refs.machines)) + } +} From b904024a405c70ae22160ea5312eb204f4c68173 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:27:04 -0700 Subject: [PATCH 15/22] fix: cold Cmd-N counts only bands shown in the window's space A workspace pinned in another space's band still lists in this window, so a cold layout that omits it keeps the placement waiting instead of falling back. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index c1976a4df9b3..679414058035 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -154,7 +154,7 @@ enum NewWorkspacePlacements { static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, machines: MachineRegistry) -> Bool { if machines.workspace(id: id)?.0.pinned == true { return true } guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } - return layout.sections.contains { $0.items.contains { $0.ref == ref } } + return layout.sections.contains { $0.isVisible(inRoom: room) && $0.items.contains { $0.ref == ref } } } /// Places workspace `id` at `slot`, else by `byDefault`, in window From dbad7132c7d19e15c2e7582c5e246502350ded8a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:47:57 -0700 Subject: [PATCH 16/22] test: another space's pin does not hide the current row from cold Cmd-N shownInBand takes whether pins are layout tiles; this commit only plumbs it, so aPinOfAnotherSpaceDoesNotHideTheCurrentRow fails here. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Windows/NewWorkspaceDefaultSlot.swift | 9 +++++---- .../Windows/WindowManager+Placement.swift | 2 +- .../ChiefWorkspaceRowsTests.swift | 4 ++-- .../NewWorkspaceBandRoomTests.swift | 19 +++++++++++++++++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 679414058035..2b641886cebb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -142,16 +142,17 @@ enum NewWorkspacePlacements { let current = window.flatMap { windows.states[$0]?.workspaceID } let model = window.flatMap { windows.controller(for: $0)?.sidebar.model } let currentMachine = current.flatMap { id in - model.map { shownInBand(id, layout: $0.layout, room: $0.activeProfileID?.rawValue, machines: windows.services.machines) } == true + model.map { shownInBand(id, layout: $0.layout, room: $0.activeProfileID?.rawValue, + legacy: windows.services.sidebarLayout.unavailableReason != nil, machines: windows.services.machines) } == true ? nil : windows.services.machines.daemon(forWorkspace: id)?.machineID } return NewWorkspaceDefaultSlot(placement: placement, current: current, currentMachine: currentMachine) } /// Whether workspace `id` shows outside the window's workspace list: an item of a section of - /// `layout` that shows in the window's space `room` (pinned, the top region, a custom section), or a - /// legacy pin (`workspace-pin-v1`, the Pinned list section while the sidebar layout is unavailable). - static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, machines: MachineRegistry) -> Bool { + /// `layout` that shows in the window's space `room` (pinned, the top region, a custom section), or, while + /// the sidebar layout is unavailable (`legacy`), a pin (`workspace-pin-v1`, the Pinned list section). + static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, legacy: Bool, machines: MachineRegistry) -> Bool { if machines.workspace(id: id)?.0.pinned == true { return true } guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } return layout.sections.contains { $0.isVisible(inRoom: room) && $0.items.contains { $0.ref == ref } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 9948253a437a..616e4c35e06c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -58,7 +58,7 @@ extension WindowManager { if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, let current = pending.byDefault?.current, services.machines.workspace(id: current) != nil, !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, - machines: services.machines) { + legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines) { pendingPlacements[id] = pending continue } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift index 7778da74687d..d44a2e949ef0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ChiefWorkspaceRowsTests.swift @@ -55,7 +55,7 @@ struct ChiefWorkspaceRowsTests { /// The Chief's daemon holding one subagent workspace `name` (`ws_…0011` in session /// `cmux-chief-6b68ac4a`), an agent chat in it when `harness` is set, pinned to the top. - static func pinnedChiefWorkspace(_ name: String, harness: String?) -> (SidebarLayoutDocument, WorkspaceLayoutRefs, LayoutItemRef) { + static func pinnedChiefWorkspace(_ name: String, harness: String?, pinned: Bool = false) -> (SidebarLayoutDocument, WorkspaceLayoutRefs, LayoutItemRef) { _ = NSApplication.shared let machines = MachineRegistry(local: DaemonService()) let session = ServerMachineSession(reach: chief, binary: nil, paths: .init(root: URL(fileURLWithPath: "/private/tmp/cn-chief-top-unused")), @@ -64,7 +64,7 @@ struct ChiefWorkspaceRowsTests { var tab = TabSnapshot(surface: 1, kind: harness == nil ? .pty : .conversation, cwd: nil) tab.conversation = harness.map { ConversationTabRef(agentSession: AgentSessionRef(host: "install:chief-top", session: "s1", harness: $0)) } let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [PaneSnapshot(id: 1, tabs: [tab])]) - let workspace = WorkspaceSnapshot(id: 1, key: nil, resourceID: "ws_00000000000000000000000000000011", name: name, screens: [screen]) + let workspace = WorkspaceSnapshot(id: 1, key: nil, resourceID: "ws_00000000000000000000000000000011", name: name, screens: [screen], pinned: pinned) session.daemon.store.apply(snapshot: DaemonTree(registryID: "cmux-chief-6b68ac4a", workspaces: [workspace])) let ref = LayoutItemRef.workspace("cmux-chief-6b68ac4a:ws_00000000000000000000000000000011") let top = LayoutSection(id: LayoutSectionID("sec_top"), region: .top, items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift index 9a4269827a99..9d2f8ee39667 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewWorkspaceBandRoomTests.swift @@ -11,7 +11,22 @@ import Testing let other = LayoutSection(id: LayoutSectionID("sec_other"), region: .top, room: "space-b", items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) let layout = SidebarLayoutDocument(sections: [other]) - #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", machines: refs.machines)) - #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", machines: refs.machines)) + #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", legacy: false, machines: refs.machines)) + #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", legacy: false, machines: refs.machines)) + } +} + +extension NewWorkspaceBandRoomTests { + /// The daemon's pin flag decides only while pins are not layout tiles (`SidebarTopProjection.legacy`): + /// with the layout, a pin of another space still lists in this one. + @Test func aPinOfAnotherSpaceDoesNotHideTheCurrentRow() throws { + let (_, refs, ref) = ChiefWorkspaceRowsTests.pinnedChiefWorkspace("band pin", harness: nil, pinned: true) + let id = try #require(refs.workspace(for: ref)).0.id + let other = LayoutSection(id: LayoutSectionID("sec_other"), region: .top, room: "space-b", + items: [LayoutItem(id: LayoutItemID("itm_pin"), ref: ref)]) + let layout = SidebarLayoutDocument(sections: [other]) + #expect(!NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-a", legacy: false, machines: refs.machines)) + #expect(NewWorkspacePlacements.shownInBand(id, layout: layout, room: "space-b", legacy: false, machines: refs.machines)) + #expect(NewWorkspacePlacements.shownInBand(id, layout: SidebarLayoutDocument(sections: []), room: "space-a", legacy: true, machines: refs.machines)) } } From 31e291ae6901a63e05fa0099356f7d8356dc6919 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:48:01 -0700 Subject: [PATCH 17/22] fix: cold Cmd-N reads the pin flag only while pins are not layout tiles The list hides a pinned workspace by the layout's bands of its space once the layout is available, so the daemon's pin flag (any space) no longer counts. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index 2b641886cebb..a1bebdc1af5e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -153,7 +153,8 @@ enum NewWorkspacePlacements { /// `layout` that shows in the window's space `room` (pinned, the top region, a custom section), or, while /// the sidebar layout is unavailable (`legacy`), a pin (`workspace-pin-v1`, the Pinned list section). static func shownInBand(_ id: String, layout: SidebarLayoutDocument, room: String?, legacy: Bool, machines: MachineRegistry) -> Bool { - if machines.workspace(id: id)?.0.pinned == true { return true } + // The list's own projection (`SidebarTopProjection`): the pin flag only while pins are not layout tiles. + if legacy { return machines.workspace(id: id)?.0.pinned == true } guard let ref = WorkspaceLayoutRefs(machines: machines).ref(forWorkspace: id) else { return false } return layout.sections.contains { $0.isVisible(inRoom: room) && $0.items.contains { $0.ref == ref } } } From dd6a54cc966877493c5cf32a82d23d67d5db14e6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 01:08:21 -0700 Subject: [PATCH 18/22] fix: a deferred placement keeps its entry while its window has no controller yet A retry from another window's rows pass could drop a pending placement whose window was still being reconciled. And a current row that left the list after the press (closed, or now in a band) resolves the rule as if there were none, so a remote create still gets its top fallback instead of no move. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../Windows/WindowManager+Placement.swift | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 616e4c35e06c..73761a8cba0d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -47,25 +47,33 @@ extension WindowManager { guard let pending = pendingPlacements[id] else { continue } // A rows-pass retry can run before membership names the owner: keep the entry for that pass. guard let window = pending.window ?? registry.value.owner(of: id) else { continue } + // A window the registry still lists whose controller a reconcile has not made yet keeps its entry too. + guard let bridge = controller(for: window)?.sidebar, let daemon = services.machines.daemon(forWorkspace: id) else { + if registry.value.window(window) == nil { pendingPlacements[id] = nil } + continue + } pendingPlacements[id] = nil - guard let bridge = controller(for: window)?.sidebar, let daemon = services.machines.daemon(forWorkspace: id) else { continue } // One store-backed rows pass per window; each following placement // reads the model with the preceding optimistic moves applied. let sections = Self.placementSections(in: bridge, refresh: refreshed.insert(window).inserted) let section = SectionID.machine(MachineID(daemon.machineID)) let home = NewWorkspacePlacements.home(services.machines) - // Waits for a current row a cold layout omits; one pinned since the press shows in the band, not the list. - if pending.byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, - let current = pending.byDefault?.current, services.machines.workspace(id: current) != nil, - !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, - legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines) { - pendingPlacements[id] = pending - continue + // Waits for a current row a cold layout omits. One closed or pinned since the press (it shows in the + // band, not the list) has no row to wait for: the rule resolves as if it never had one. + var byDefault = pending.byDefault + if byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, let current = byDefault?.current { + if services.machines.workspace(id: current) != nil, + !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, + legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines) { + pendingPlacements[id] = pending + continue + } + byDefault?.currentMachine = nil } // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. - let slot = pending.slot ?? (daemon.isLocal ? pending.byDefault?.move(for: id, section: section, in: sections, home: home) - : pending.byDefault?.slot(for: id, section: section, in: sections, home: home)) + let slot = pending.slot ?? (daemon.isLocal ? byDefault?.move(for: id, section: section, in: sections, home: home) + : byDefault?.slot(for: id, section: section, in: sections, home: home)) if let position = slot?.position(moving: [id], section: section, in: sections) { bridge.place([SidebarWorkspaceID(id)], at: position, in: sections) } From 393195c11ec5a8ffca051e5a4613af33d81d179e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 07:29:09 -0700 Subject: [PATCH 19/22] Test: cold Cmd-N waits per window, not on another window's current row (red) Sol's two P2s on #27014 at d199d41f: the stand-in's wait reads the global SidebarDocStore.shownVersion although each window builds its rows a hop after show, and a placement re-pends forever when the window will never list its current workspace (another window owns it). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../ColdCmdNWindowRowsTests.swift | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift new file mode 100644 index 000000000000..7dd6d35df988 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift @@ -0,0 +1,93 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Cold Cmd-N's wait for the current row (`NewWorkspaceDefaultSlot.awaitsCurrentAnchor`) is a property of the +/// window: its rows are built a hop after the document's `show`, so the document being caught up says nothing +/// about them (Sol on #27014). And a current row the window will never list (another window owns it) is no row to +/// wait for: the placement resolves by the documented fallback and its `then` runs. +@MainActor @Suite(.serialized) struct ColdCmdNWindowRowsTests { + private final class Remote: SidebarDocRemote { + var isAvailable: Bool { true } + func get() async throws -> Data { Data(#"{"revision":"1","sections":[],"items":[]}"#.utf8) } + func move(_ items: [String], to target: SidebarDocTarget, key: String) async throws -> SidebarDocReply { reply } + func pin(_ items: [String], key: String) async throws -> SidebarDocReply { reply } + func unpin(_ items: [String], key: String) async throws -> SidebarDocReply { reply } + func adopt(_ refs: [SidebarDocWorkspaceRef], key: String) async throws -> SidebarDocReply { reply } + func apply(ops: Data, key: String) async throws -> SidebarDocReply { reply } + private var reply: SidebarDocReply { SidebarDocReply(revision: 2, inverse: Data("[]".utf8)) } + } + + @MainActor private final class Ran { + var value = false + } + + /// An active document store, made the app's for `body`; `show` false leaves it a projection behind (cold). + static func withDocument(shown: Bool, _ body: (SidebarDocStore) async throws -> Void) async throws { + let previous = SidebarDocStore.current + defer { SidebarDocStore.current = previous } + let store = SidebarDocStore(remote: Remote()) + store.refresh() + await RapidWorkspaceCreateTests.wait(within: .seconds(10)) { store.isActive } + try #require(store.isActive) + if shown { + store.show(live: []) + try #require(store.shown != nil && store.shownVersion == store.version) + } + SidebarDocStore.current = store + try await body(store) + } + + static func probe(window: String, current: String, machine: String) -> PendingWorkspaceRows.Row { + let id = CmuxNextSidebar.MachineID(machine) + return PendingWorkspaceRows.Row( + id: "probe", window: window, machine: id, + face: SidebarMapping.shared.creatingRow(id: "probe", machine: id, name: "probe", opensPage: true), + slot: nil, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: current, currentMachine: machine)) + } + + /// The document caught up (`shownVersion == version`), but this window's rows were not built from its + /// projection: they omit the current row. The stand-in waits, rather than drawing at the top or bottom. + @Test func aWindowWhoseRowsLagTheDocumentKeepsTheStandInWaiting() async throws { + _ = NSApplication.shared + try await Self.withDocument(shown: true) { _ in + let model = SidebarModel() + let rows = SidebarRows(model: model) + let stale = [SidebarRowSection(kind: .machine(SidebarMachine(id: CmuxNextSidebar.MachineID("m"), name: "This Mac", kind: .local)), nodes: [])] + rows.show(stale, creating: ([Self.probe(window: "w", current: "current", machine: "m")], nil)) + let drawn = model.sections.flatMap(\.workspaces).map(\.id.rawValue) + #expect(drawn.isEmpty, "rows a projection behind drew the cold stand-in at \(drawn)") + } + } + + /// The window's current workspace moved to another window: this one will never list it, so the cold wait + /// resolves at once by the fallback and runs `then`. + @Test func aCurrentWorkspaceAnotherWindowOwnsDoesNotHoldThePlacement() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + windows.registry.apply { $0.openWindow(id: "other", workspaceIDs: ["k1"]) } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + #expect(NewWorkspaceDefaultSlot.layoutCatchingUp()) + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + #expect(windows.pendingPlacements["k2"] == nil, "the placement waits for a row another window lists") + #expect(ran.value) + } + } +} From a1544728ca32c6e285f798396cfa3bae01121636 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 07:33:53 -0700 Subject: [PATCH 20/22] Cold Cmd-N waits on the window's own rows, and never forever Sol's P2s on #27014 at d199d41f: - The wait for the current row read the global SidebarDocStore.shownVersion, but each window builds its rows a hop after the document's show. The store now counts shown's changes (shownChanges); each window's rows record the count they were built at (SidebarRows.layoutCatchingUp), and the stand-in and the placement both ask the window's rows. - A placement re-pended while the current workspace was live and not in the band, even when this window will never list it. It now waits only for a row the window lists (no other window owns it, its space shows it), and at most NewWorkspacePlacements.anchorWaitLimit (3 s; the manager's one-shot DemandTimer retries), then resolves by the documented fallback and runs then. Sidebar-Look: no visual change Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../CmuxNextApp/SidebarBridge+Intents.swift | 3 +- .../Sources/CmuxNextApp/SidebarBridge.swift | 16 ++++--- .../Sources/CmuxNextApp/SidebarRows.swift | 19 ++++++-- .../Windows/NewWorkspaceDefaultSlot.swift | 42 +++++++++++------ .../Windows/PendingWorkspaceRows.swift | 9 ++-- .../Windows/WindowManager+Placement.swift | 32 ++++++++++--- .../CmuxNextApp/Windows/WindowManager.swift | 2 + .../Store/SidebarDocStore.swift | 5 ++- .../ColdCmdNRowSlotTests.swift | 8 ++-- .../ColdCmdNWindowRowsTests.swift | 45 +++++++++++++++++++ 10 files changed, 141 insertions(+), 40 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift index 7a7e00e16908..8842348056e5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge+Intents.swift @@ -184,6 +184,7 @@ extension SidebarBridge { func resync() { guard let state else { return } model.ungroupedFirst = !usesMixedOrder + let shownAt = SidebarDocStore.current?.shownChanges var sections = Self.sections(services.machines, members: services.windows.registry.members(of: state.id), profile: state.profileID, hidesHome: Self.hidesHome(services.sidebarLayout.document), selection: state.selection, newTabPages: Self.newTabPages(services.agentTabs, in: services.machines.local.store), muted: services.notifications.preferences.mutedWorkspaces, @@ -192,7 +193,7 @@ extension SidebarBridge { .applying(to: sections) // A connecting SSH machine keeps its pending row through a resync (cx-gaq9). if !model.groupsByComputer { sections = SSHConnectingRows.adding(services.machines, to: sections) } - rows.show(sections) + rows.show(sections, shownAt: shownAt) model.profiles = SidebarSpaceBar.profiles(services.machines.local) groupFlow.openPendingEditor() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index 3fa8e089e293..208e1158d649 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -155,13 +155,16 @@ final class SidebarBridge { groupConfig: services.settings?.snapshot.workspaceGroups ?? WorkspaceGroupConfig(), harnessIcons: { [weak services] in services?.harnessIconLookup ?? .none }) // Not the creates' rows (PendingWorkspaceRows): they draw from the press (its redraws below), and a walk reads them as it shows. - return ((PendingWorkspaceRows.shared.listing(listed, in: windowState.id, machines: machines), services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), + // The document projection these rows were built from (`SidebarRows.layoutCatchingUp`). + return ((PendingWorkspaceRows.shared.listing(listed, in: windowState.id, machines: machines), services.sidebarCustomization.rowDecorations, + SidebarDocStore.current?.shownChanges), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), SidebarRows.visibleLayout(layout.document, window: windowState.id, profile: windowState.profileID, machines: machines, registry: registry.value, showAppStore: services.settings?.snapshot.previewFeatures == true)) }) { [weak self] value in - let ((sections, decorations), launching, failed, profiles, active, shown) = value + let ((sections, decorations, shownAt), launching, failed, profiles, active, shown) = value guard let self else { return } self.showProfiles(profiles, active: active, launching: launching) - self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) + self.show(SidebarSection.decorated(feed.fill(sections), decorations), shownAt: shownAt, layout: shown, launching: launching, failed: failed, + creating: PendingWorkspaceRows.shared.rows(in: windowState.id)) services.windows.retryDeferredPlacements() // before pending stand-ins are forgotten // A workspace the window lists shows its own row from here on (cx-9a7wx). No create waits: no walk of every row. guard !PendingWorkspaceRows.shared.rows.isEmpty else { return } @@ -228,6 +231,7 @@ final class SidebarBridge { model.profiles = saved.sidebarProfiles model.activeProfileID = saved.sidebarActiveProfileID } + let shownAt = SidebarDocStore.current?.shownChanges let (sections, launching, failed) = Self.liveSections(services.machines, registry: windows.registry, window: state, creations: services.cloud.creations, hidesHome: Self.hidesHome(services.sidebarLayout.document), newTabPages: Self.newTabPages(services.agentTabs, in: services.machines.local.store), @@ -236,13 +240,13 @@ final class SidebarBridge { tabTitle: Self.tabTitle(services), groupConfig: services.settings?.snapshot.workspaceGroups ?? WorkspaceGroupConfig(), harnessIcons: { [weak services] in services?.harnessIconLookup ?? .none }) - show(sections, layout: SidebarRows.visibleLayout(services.sidebarLayout.document, window: state.id, profile: state.profileID, machines: services.machines, + show(sections, shownAt: shownAt, layout: SidebarRows.visibleLayout(services.sidebarLayout.document, window: state.id, profile: state.profileID, machines: services.machines, registry: windows.registry.value, showAppStore: services.settings?.snapshot.previewFeatures == true), launching: launching, failed: failed) } /// Shows `live` with loading sections filled from the seed, and the /// band's `layout` in the same turn, then saves it. - private func show(_ live: [SidebarRowSection], layout: SidebarLayoutDocument, launching: Bool, failed: Set, + private func show(_ live: [SidebarRowSection], shownAt: UInt64?, layout: SidebarLayoutDocument, launching: Bool, failed: Set, creating: [PendingWorkspaceRows.Row] = []) { var sections = seed.merge(live, launching: launching, failed: failed) // After the merge, so a reconnecting machine's saved rows stand in first (cx-gaq9). @@ -255,7 +259,7 @@ final class SidebarBridge { // A pass that lists what the rows show already (a daemon reorder the personal or document order hides, a store // snapshot laid over again) sets no sections: no arrange and compare of every row in the main thread's frame. let elsewhere = model.docDrag.flatMap { drag in SidebarDocStore.current?.shown?.bandGroupsListedElsewhere(drag: drag) } - rows.show(sections, creating: (creating, NewWorkspacePlacements.home(services.machines)), unlessShown: hadDocDrag == (model.docDrag != nil), + rows.show(sections, shownAt: shownAt, creating: (creating, NewWorkspacePlacements.home(services.machines)), unlessShown: hadDocDrag == (model.docDrag != nil), elsewhere: elsewhere, showAppStore: services.settings?.snapshot.previewFeatures == true) selectionHold.flush() organizationQueue.drain(loaded: usesPersonalOrganization, local: services.machines.local, run: handle, refuse: refuseOrganization) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift index dba70fe968be..4aeb9372ef85 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift @@ -15,6 +15,9 @@ import CmuxNextSidebar final class SidebarRows { private let model: SidebarModel private var live: [SidebarRowSection] = [] + /// The sidebar document's `shownChanges` `live` was built at (nil: not from its projection): the creates' + /// rows that wait for the window's current row wait while `live` is a projection behind (`layoutCatchingUp`). + private var liveShownAt: UInt64? private var pending = SidebarPendingEdits() /// A group's fold the user asked for that the home store has not shown /// yet (cx-qno.17): the newest click wins over a live recompute, so a @@ -98,19 +101,24 @@ final class SidebarRows { /// rows of the creates in flight (`creating`, when given). `unlessShown`: nothing to do when `live` and `creating` /// are what the rows show already (patches included), so a structural pass that lists nothing new sets no sections /// (sidebar churn: at 400 workspaces each set arranged and compared every row). A changed `elsewhere` shows again. - func show(_ live: [SidebarRowSection]? = nil, creating: (rows: [PendingWorkspaceRows.Row], home: String?)? = nil, unlessShown: Bool = false, - elsewhere: Set? = nil, showAppStore: Bool? = nil) { + /// `shownAt`: the sidebar document's `shownChanges` `live` was built at; a newer one with creates in flight shows + /// again, since a create waiting for the current row may draw now. + func show(_ live: [SidebarRowSection]? = nil, shownAt: UInt64? = nil, creating: (rows: [PendingWorkspaceRows.Row], home: String?)? = nil, + unlessShown: Bool = false, elsewhere: Set? = nil, showAppStore: Bool? = nil) { let sameVisibility = showAppStore == nil || showAppStore == self.showAppStore if let showAppStore { self.showAppStore = showAppStore } let sameElsewhere = elsewhere == nil || elsewhere == self.elsewhere if let elsewhere { self.elsewhere = elsewhere } - if unlessShown, let live, let creating, live == self.live, creating == self.creating, sameElsewhere, sameVisibility { return } + let reprojected = live != nil && shownAt != liveShownAt && !(creating ?? self.creating).rows.isEmpty + if live != nil { liveShownAt = shownAt } + if unlessShown, !reprojected, let live, let creating, live == self.live, creating == self.creating, sameElsewhere, sameVisibility { return } if let live { self.live = live } if let creating { self.creating = creating } #if DEBUG if let fixture { return model.setSections(fixture) } #endif - var sections = PendingWorkspaceRows.adding(self.creating.rows, to: pending.apply(to: self.live), home: self.creating.home) + var sections = PendingWorkspaceRows.adding(self.creating.rows, to: pending.apply(to: self.live), home: self.creating.home, + catchingUp: layoutCatchingUp) for (group, fold) in folds { guard let (s, n) = SidebarEdits.locateGroup(group, in: sections), case var .group(shown) = sections[s].nodes[n] else { continue } guard shown.isCollapsed != fold.collapsed else { @@ -125,6 +133,9 @@ final class SidebarRows { model.setSections(sections) } + /// The live rows are a projection behind the sidebar document (`NewWorkspaceDefaultSlot.layoutCatchingUp`). + var layoutCatchingUp: Bool { NewWorkspaceDefaultSlot.layoutCatchingUp(rowsAt: liveShownAt) } + /// Row content of listed workspaces changed (`SidebarRowFeed`): written in place, or shown again when it moves rows. func patch(_ changed: [SidebarWorkspace]) { live = SidebarSection.patching(changed, in: live) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift index a1bebdc1af5e..38e245522901 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/NewWorkspaceDefaultSlot.swift @@ -42,12 +42,11 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { /// The current row is expected in this machine section but is not in the /// stale layout yet. Returning true keeps Cmd-N pending until the row is /// realized, instead of resolving the slot against an empty layout. Only - /// while the sidebar document's drawn projection is behind its version - /// (`layoutCatchingUp`): once it has caught up, a row still missing was + /// while the window's rows are a projection behind the sidebar document + /// (`catchingUp`, `layoutCatchingUp(rowsAt:)`): once they have caught up, a row still missing was /// left out on purpose (a filter, another space, a close) and the slot /// falls back as documented. - func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil, - catchingUp: Bool = Self.layoutCatchingUp()) -> Bool { + func awaitsCurrentAnchor(section: SectionID, in sections: [SidebarRowSection], home: String? = nil, catchingUp: Bool) -> Bool { guard catchingUp, placement == .afterCurrent, let current, let currentMachine, current != home, case let .machine(machine) = section, machine.rawValue == currentMachine, @@ -55,16 +54,18 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { return true } - /// Whether the sidebar document's drawn projection is behind its version (a cold or updated - /// layout a hop before its rows show). - static func layoutCatchingUp() -> Bool { + /// Whether a window's rows built at `rowsAt` (the document's `shownChanges` then; nil: not built from its + /// projection) are behind the sidebar document: its drawn projection is behind its version, or the window's + /// rows pass has not run since the projection changed (a cold or updated layout a hop before its rows show). + static func layoutCatchingUp(rowsAt: UInt64? = nil) -> Bool { guard let store = SidebarDocStore.current, store.isActive else { return false } - return store.shown == nil || store.shownVersion != store.version + return store.shown == nil || store.shownVersion != store.version || rowsAt != store.shownChanges } /// The slot for new workspace `id` in `section` of a window's `sections`; - /// nil keeps the daemon's place. `home` is the home workspace's id. - func slot(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?) -> WorkspaceSlot? { + /// nil keeps the daemon's place. `home` is the home workspace's id. `catchingUp`: the window's rows are a + /// projection behind (`awaitsCurrentAnchor`). + func slot(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?, catchingUp: Bool = false) -> WorkspaceSlot? { switch placement { case .bottom: if let current, current != id, current != home, @@ -73,7 +74,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } return nil case .afterCurrent: - if awaitsCurrentAnchor(section: section, in: sections, home: home) { return nil } + if awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) { return nil } if let current, current != id, current != home, WorkspaceSlot.below(current).position(moving: [id], section: section, in: sections) != nil { return .below(current) @@ -89,13 +90,13 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable { } /// The move the app sends once the daemon reports new workspace `id`, or nil when the daemon's - /// create already put it at ``slot(for:section:in:home:)``: the daemon places every new workspace + /// create already put it at ``slot(for:section:in:home:catchingUp:)``: the daemon places every new workspace /// by `workspaces.newPlacement` in the commit that creates it (first below Home under `top`, /// last under `bottom`), so a second write would be a second state change and a second render /// for the same order (plans/cmux-next/sidebar-ordering-DESIGN.md). Only a slot the daemon does /// not know is moved to: inside the current workspace's group, after the window's current one. - func move(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?) -> WorkspaceSlot? { - guard let slot = slot(for: id, section: section, in: sections, home: home) else { return nil } + func move(for id: String, section: SectionID, in sections: [SidebarRowSection], home: String?, catchingUp: Bool = false) -> WorkspaceSlot? { + guard let slot = slot(for: id, section: section, in: sections, home: home, catchingUp: catchingUp) else { return nil } if slot == Self.top(for: id, section: section, in: sections, home: home) { return nil } return slot } @@ -159,6 +160,19 @@ enum NewWorkspacePlacements { return layout.sections.contains { $0.isVisible(inRoom: room) && $0.items.contains { $0.ref == ref } } } + /// Whether window `windowID` lists workspace `id` once its rows catch up: no other window owns it and the + /// window's space shows it. A cold Cmd-N waits only for such a row. + static func lists(_ id: String, in windowID: String, windows: WindowManager) -> Bool { + if let owner = windows.registry.value.owner(of: id), owner != windowID { return false } + guard let profile = windows.controller(for: windowID)?.state.profileID else { return true } + let machines = windows.services.machines + return WindowProfiles.shows(id, profile: profile, machines: machines, membership: WindowProfiles.membership(machines)) + } + + /// How long a placement waits for the window's current row (`awaitsCurrentAnchor`) before it resolves by the + /// fallback: a cold layout shows its rows well within it; a row that never comes cannot hold the placement. + static let anchorWaitLimit: Duration = .seconds(3) + /// Places workspace `id` at `slot`, else by `byDefault`, in window /// `windowID` (nil: the window that lists it): now when it is mirrored, /// else once the daemon reports it. `then` runs after, with the sidebar. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift index efe8bd251819..fe5050f9e3ba 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/PendingWorkspaceRows.swift @@ -123,8 +123,9 @@ final class PendingWorkspaceRows { /// `sections` with each pending row the window does not list yet, at /// its slot (`NewWorkspaceDefaultSlot`), in press order. `home` is the - /// home workspace's id. - static func adding(_ rows: [Row], to sections: [SidebarRowSection], home: String?) -> [SidebarRowSection] { + /// home workspace's id. `catchingUp`: the window's `sections` are a projection behind the sidebar document + /// (`SidebarRows.layoutCatchingUp`). + static func adding(_ rows: [Row], to sections: [SidebarRowSection], home: String?, catchingUp: Bool) -> [SidebarRowSection] { guard !rows.isEmpty else { return sections } var sections = sections let listed = Set(sections.flatMap(\.workspaces).map(\.id.rawValue)) @@ -137,9 +138,9 @@ final class PendingWorkspaceRows { if let explicit = row.slot { slot = explicit } else if let byDefault = row.byDefault { - if let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home) { + if let resolved = byDefault.slot(for: row.id, section: section, in: sections, home: home, catchingUp: catchingUp) { slot = resolved - } else if byDefault.awaitsCurrentAnchor(section: section, in: sections, home: home) { + } else if byDefault.awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) { continue } else { slot = .bottom(anchor: nil) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index 73761a8cba0d..edf93f9a3206 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -1,6 +1,7 @@ import CmuxNextActions import CmuxNextBridge import CmuxNextSidebar +import CmuxNextWakeups /// A new workspace's sidebar slot, kept until the daemon reports it. struct PendingPlacement { @@ -14,6 +15,9 @@ struct PendingPlacement { /// The action run that asked for the placement: `then` runs later, /// outside its task, and keeps its view-change permission. var run: ActionRunScope? = ActionRunScope.current + /// When the placement first waited for the window's current row (`awaitsCurrentAnchor`): it waits at most + /// `NewWorkspacePlacements.anchorWaitLimit`, then resolves by the fallback. + var anchorWaitSince: ContinuousClock.Instant? } // Placing new workspaces (New Workspace Above/Below/at Top/Like This, a @@ -31,6 +35,12 @@ extension WindowManager { applyPendingPlacements(live: Set(pendingPlacements.keys)) } + /// One retry once a deferred placement's wait runs out, so it resolves even when no rows pass comes + /// (`anchorRetry`, a one-shot deadline; a pass that finds one still waiting sets the next). + private func retryDeferredPlacements(after delay: Duration) { + anchorRetry.scheduleIfIdle(after: delay) { @MainActor [weak self] in self?.retryDeferredPlacements() } + } + func applyPendingPlacements(live: Set) { // A placement's rows refresh runs the sidebar's rows pass, whose callback retries deferred // placements: a nested call returns at once (no re-entry). @@ -58,22 +68,30 @@ extension WindowManager { let sections = Self.placementSections(in: bridge, refresh: refreshed.insert(window).inserted) let section = SectionID.machine(MachineID(daemon.machineID)) let home = NewWorkspacePlacements.home(services.machines) - // Waits for a current row a cold layout omits. One closed or pinned since the press (it shows in the - // band, not the list) has no row to wait for: the rule resolves as if it never had one. + let catchingUp = bridge.rows.layoutCatchingUp + // Waits for a current row this window's cold rows omit. One closed or pinned since the press (it shows in + // the band, not the list), or one this window does not list (another window's, another space's), has no + // row to wait for: the rule resolves as if it never had one. So does a wait past its limit. var byDefault = pending.byDefault - if byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home) == true, let current = byDefault?.current { + if byDefault?.awaitsCurrentAnchor(section: section, in: sections, home: home, catchingUp: catchingUp) == true, let current = byDefault?.current { + let since = pending.anchorWaitSince ?? .now if services.machines.workspace(id: current) != nil, !NewWorkspacePlacements.shownInBand(current, layout: bridge.model.layout, room: bridge.model.activeProfileID?.rawValue, - legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines) { - pendingPlacements[id] = pending + legacy: services.sidebarLayout.unavailableReason != nil, machines: services.machines), + NewWorkspacePlacements.lists(current, in: window, windows: self), + ContinuousClock.now < since + NewWorkspacePlacements.anchorWaitLimit { + var waiting = pending + waiting.anchorWaitSince = since + pendingPlacements[id] = waiting + retryDeferredPlacements(after: since + NewWorkspacePlacements.anchorWaitLimit - .now) continue } byDefault?.currentMachine = nil } // This Mac's daemon placed the workspace by the setting in its create: no second write for that // slot. Another machine's create writes no row in this Mac's order, so the app places it. - let slot = pending.slot ?? (daemon.isLocal ? byDefault?.move(for: id, section: section, in: sections, home: home) - : byDefault?.slot(for: id, section: section, in: sections, home: home)) + let slot = pending.slot ?? (daemon.isLocal ? byDefault?.move(for: id, section: section, in: sections, home: home, catchingUp: catchingUp) + : byDefault?.slot(for: id, section: section, in: sections, home: home, catchingUp: catchingUp)) if let position = slot?.position(moving: [id], section: section, in: sections) { bridge.place([SidebarWorkspaceID(id)], at: position, in: sections) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift index 858fa8e02405..a21f09fed53c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager.swift @@ -57,6 +57,8 @@ final class WindowManager { var pendingPlacements: [String: PendingPlacement] = [:] /// Registration order survives a daemon snapshot that reports several creates together. var pendingPlacementOrder: [String] = [] + /// Retries the placements whose wait for the window's current row ran out (`applyPendingPlacements`). + let anchorRetry = DemandTimer(owner: "WindowManager.anchorRetry") /// Windows none of whose workspaces a machine reports yet (just created, /// or saved with workspaces that are gone or on a Cloud machine still /// connecting): kept off screen until their content is installed, so no diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift index db5a11a33d78..ddcd00da159d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarDocStore.swift @@ -31,7 +31,10 @@ public final class SidebarDocStore { /// it. False keeps the older path. public private(set) var isActive = false /// What the sidebar draws from the document (`show(live:)`); nil while inactive. - public internal(set) var shown: SidebarDocProjection? + public internal(set) var shown: SidebarDocProjection? { didSet { shownChanges &+= 1 } } + /// Counts the changes of `shown`. A window records the count its rows were built at: its rows pass runs a hop + /// after `show`, so a caught-up document says nothing about one window's rows. + @ObservationIgnored public private(set) var shownChanges: UInt64 = 0 /// The `version` `shown` was built at: behind `version` while an applied change (a Cmd-N move) waits for the next /// `show`, a hop later. @ObservationIgnored public internal(set) var shownVersion: UInt64 = 0 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift index bc7aeda10908..e05c3dc89903 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNRowSlotTests.swift @@ -90,13 +90,15 @@ struct ColdCmdNRowSlotTests { // The document's drawn projection is a hop behind, as on a cold launch. let document = try #require(SidebarDocStore.current) document.shownVersion = document.version &- 1 - let coldFrame = PendingWorkspaceRows.adding([probe], to: staleSections, home: nil) + let catchingUp = NewWorkspaceDefaultSlot.layoutCatchingUp(rowsAt: document.shownChanges) + #expect(catchingUp) + let coldFrame = PendingWorkspaceRows.adding([probe], to: staleSections, home: nil, catchingUp: catchingUp) #expect(coldFrame.flatMap(\.workspaces).isEmpty, "a stale cold layout must not draw a top or bottom frame") let settledSections = [SidebarRowSection( kind: .machine(SidebarMachine(id: MachineID(machine), name: "This Mac", kind: .local)), nodes: [.workspace(SidebarWorkspace(id: WorkspaceID(current.id), machineID: MachineID(machine), title: "current"))] )] - let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil) + let settledFrame = PendingWorkspaceRows.adding([probe], to: settledSections, home: nil, catchingUp: catchingUp) #expect(settledFrame.flatMap(\.workspaces).map(\.id.rawValue) == [current.id, "cold-probe"]) #expect(!probeRule.awaitsCurrentAnchor(section: .machine(MachineID(machine)), in: staleSections, catchingUp: false), "once the layout caught up, a current row left out on purpose (filter, space, close) uses the normal fallback") @@ -105,7 +107,7 @@ struct ColdCmdNRowSlotTests { id: "bottom-probe", window: window, machine: MachineID(machine), face: SidebarMapping.shared.creatingRow(id: "bottom-probe", machine: MachineID(machine), name: "bottom-probe", opensPage: true), slot: nil, byDefault: bottomRule) - let bottomFrame = PendingWorkspaceRows.adding([bottom], to: staleSections, home: nil) + let bottomFrame = PendingWorkspaceRows.adding([bottom], to: staleSections, home: nil, catchingUp: catchingUp) #expect(bottomFrame.flatMap(\.workspaces).map(\.id.rawValue) == ["bottom-probe"], "bottom placement keeps its immediate loose-row stand-in") diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift index 7dd6d35df988..7aa594a65cda 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift @@ -64,6 +64,23 @@ import Testing } } + /// Rows built from the document's current projection that still omit the current row left it out on purpose: + /// the stand-in draws at the fallback slot. + @Test func rowsBuiltFromTheCurrentProjectionDrawTheStandInAtTheFallback() async throws { + _ = NSApplication.shared + try await Self.withDocument(shown: true) { store in + let model = SidebarModel() + let rows = SidebarRows(model: model) + let stale = [SidebarRowSection(kind: .machine(SidebarMachine(id: CmuxNextSidebar.MachineID("m"), name: "This Mac", kind: .local)), nodes: [])] + let probe = Self.probe(window: "w", current: "current", machine: "m") + rows.show(stale, creating: ([probe], nil)) + #expect(model.sections.flatMap(\.workspaces).isEmpty) + rows.show(stale, shownAt: store.shownChanges, creating: ([probe], nil), unlessShown: true) + #expect(!rows.layoutCatchingUp) + #expect(model.sections.flatMap(\.workspaces).map(\.id.rawValue) == ["probe"]) + } + } + /// The window's current workspace moved to another window: this one will never list it, so the cold wait /// resolves at once by the fallback and runs `then`. @Test func aCurrentWorkspaceAnotherWindowOwnsDoesNotHoldThePlacement() async throws { @@ -90,4 +107,32 @@ import Testing #expect(ran.value) } } + + /// A current row the window could list (no window owns it yet) that never comes: the wait ends at its limit, + /// with no rows pass to retry it, and the placement resolves by the fallback. + @Test func aCurrentRowThatNeverComesStopsWaitingAtTheLimit() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + #expect(windows.pendingPlacements["k2"] != nil, "a row the window may still list is waited for") + #expect(!ran.value) + await RapidWorkspaceCreateTests.wait(within: NewWorkspacePlacements.anchorWaitLimit + .seconds(10)) { ran.value } + #expect(ran.value, "the placement waited past its limit") + #expect(windows.pendingPlacements.isEmpty) + } + } } From 273f097612efd308257184fce7040653861ba8a5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 08:35:45 -0700 Subject: [PATCH 21/22] test: a waiting cold Cmd-N placement whose workspace closes is dropped Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../ColdCmdNWindowRowsTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift index 7aa594a65cda..b00b3f5e0f48 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ColdCmdNWindowRowsTests.swift @@ -108,6 +108,35 @@ import Testing } } + /// A placement waiting for the current row whose new workspace closes (or whose create is undone) meanwhile: + /// its entry goes with it, rather than waiting forever for a workspace the daemon no longer has. + @Test func aWaitingPlacementWhoseWorkspaceClosesIsDropped() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try PlacementStaleRowsTests.identity(mixedOrder: false) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1", "k2"], personal: ["k1", "k2"])) + let windows = services.windows + windows.ordersWindowsIn = false + let controller = try #require(windows.openWindow(workspaces: ["k2"])) + defer { controller.teardown(); controller.window?.close() } + controller.sidebar.resync() + let machine = try #require(services.machines.daemon(forWorkspace: "k1")?.machineID) + try await Self.withDocument(shown: false) { _ in + let ran = Ran() + NewWorkspacePlacements.expect("k2", in: controller.state.id, byDefault: NewWorkspaceDefaultSlot(placement: .afterCurrent, current: "k1", currentMachine: machine), + then: { _, _ in ran.value = true }, windows: windows) + try #require(windows.pendingPlacements["k2"] != nil, "the placement did not wait") + store.apply(snapshot: PlacementStaleRowsTests.tree(["k1"], personal: ["k1"])) + windows.retryDeferredPlacements() + #expect(windows.pendingPlacements["k2"] == nil, "a placement for closed workspace k2 is kept") + #expect(!windows.pendingPlacementOrder.contains("k2")) + #expect(!ran.value, "then ran for a workspace that is gone") + } + } + /// A current row the window could list (no window owns it yet) that never comes: the wait ends at its limit, /// with no rows pass to retry it, and the placement resolves by the fallback. @Test func aCurrentRowThatNeverComesStopsWaitingAtTheLimit() async throws { From ba200c3f6aff1b4324507ebf3a4de9b45ef3f791 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 08:35:47 -0700 Subject: [PATCH 22/22] fix: a cold Cmd-N placement waiting on its anchor ends when its workspace is gone Sidebar-Look: no visual change Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0cf32b32a03b27dcdf4d --- .../CmuxNextApp/Windows/WindowManager+Placement.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift index edf93f9a3206..4c17f46fd08c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowManager+Placement.swift @@ -48,6 +48,11 @@ extension WindowManager { guard !rows.placing else { return } rows.placing = true defer { rows.placing = false } + // A placement that waited for its current row was for a workspace the daemon had: once that one is gone (closed, + // its create undone), nothing will place it. + for (id, pending) in pendingPlacements where pending.anchorWaitSince != nil && services.machines.workspace(id: id) == nil { + pendingPlacements[id] = nil + } // A stand-in Cmd-N shows waits: a placement moves a workspace the daemon has. let ready = pendingPlacementOrder.filter { live.contains($0) && services.machines.workspace(id: $0)?.0.isProvisional == false } // A placement waiting on its current row keeps its entry and its place in the order.