Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3cc0d65
test: reproduce cold Cmd-N placement after stale layout
teamleaderleo Oct 28, 2026
a2af5fd
fix: defer cold Cmd-N until its current row is realized
teamleaderleo Oct 28, 2026
34f95d0
test: cover cold default slot anchor wait
teamleaderleo Oct 28, 2026
ee3614e
fix: preserve cold placement fallbacks
teamleaderleo Oct 28, 2026
a0211a5
fix: correct cold slot regression types
teamleaderleo Oct 28, 2026
1013a05
fix: a current workspace shown in a sidebar band does not defer Cmd-N…
teamleaderleo Oct 30, 2026
83fb148
Merge remote-tracking branch 'origin/feat-cmux-next' into dna-27014
teamleaderleo Oct 30, 2026
f28c7d8
fix: a legacy-pinned current workspace does not defer Cmd-N placement…
teamleaderleo Oct 30, 2026
090ba63
fix: Cmd-N waits for a missing current row only while the sidebar lay…
teamleaderleo Oct 30, 2026
6574874
fix: a placement retry inside a placement's own rows refresh returns …
teamleaderleo Oct 30, 2026
02cebad
lint: currentMachine is implicitly nil
teamleaderleo Oct 30, 2026
19ed3fe
Placement retry lives beside the placements; the god files do not grow
teamleaderleo Oct 30, 2026
77f747c
The placement re-entry flag is PendingWorkspaceRows instance state, n…
teamleaderleo Oct 30, 2026
ff4377e
fix: a placement retry before membership names the owner keeps its entry
teamleaderleo Oct 30, 2026
da901d8
test: a band of another space does not hide the current row from cold…
teamleaderleo Oct 30, 2026
b904024
fix: cold Cmd-N counts only bands shown in the window's space
teamleaderleo Oct 30, 2026
dbad713
test: another space's pin does not hide the current row from cold Cmd-N
teamleaderleo Oct 30, 2026
31e291a
fix: cold Cmd-N reads the pin flag only while pins are not layout tiles
teamleaderleo Oct 30, 2026
dd6a54c
fix: a deferred placement keeps its entry while its window has no con…
teamleaderleo Oct 30, 2026
d199d41
Merge branch 'feat-cmux-next' into codex/cmdn-cold-followup
teamleaderleo Oct 30, 2026
393195c
Test: cold Cmd-N waits per window, not on another window's current ro…
teamleaderleo Oct 30, 2026
a154472
Cold Cmd-N waits on the window's own rows, and never forever
teamleaderleo Oct 30, 2026
a4bd66e
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo Oct 30, 2026
273f097
test: a waiting cold Cmd-N placement whose workspace closes is dropped
teamleaderleo Oct 30, 2026
ba200c3
fix: a cold Cmd-N placement waiting on its anchor ends when its works…
teamleaderleo Oct 30, 2026
378f4e0
Merge branch 'feat-cmux-next' into codex/cmdn-cold-followup
teamleaderleo Oct 30, 2026
75a3832
Merge remote-tracking branch 'origin/feat-cmux-next' into dna-27014
teamleaderleo Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ extension SidebarBridge {
func resync() {
guard let state else { return }
model.ungroupedFirst = !SidebarDocWiring.isActive && !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,
Expand All @@ -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()
}
Expand Down
17 changes: 11 additions & 6 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,17 @@ 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 }
PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue)),
Expand Down Expand Up @@ -227,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),
Expand All @@ -235,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<MachineID>,
private func show(_ live: [SidebarRowSection], shownAt: UInt64?, layout: SidebarLayoutDocument, launching: Bool, failed: Set<MachineID>,
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).
Expand All @@ -254,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)
Expand Down
19 changes: 15 additions & 4 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>? = 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<String>? = 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 {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,47 @@ 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.
/// 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?

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. Only
/// 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) -> 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 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 || 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,
Expand All @@ -37,6 +74,7 @@ struct NewWorkspaceDefaultSlot: Hashable, Sendable {
}
return nil
case .afterCurrent:
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)
Expand All @@ -52,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
}
Expand Down Expand Up @@ -102,9 +140,39 @@ 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 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,
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, 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 {
// 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 } }
}

/// 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.
Expand Down
Loading
Loading