From b4ea25bd03e56a6bebbe1de8baa43604dd72556a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:20:44 +0000 Subject: [PATCH 01/12] test(cmux-next sidebar): rows keep one gap after groups and pins, and the pin lines up Red: under a group's last row, a folder, the Pinned block and the pinned chats the layout adds its own padding to the row spacing, and a pinned row's pin ink starts inside the rows' content inset. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Views/Sections/SidebarItemRowView.swift | 9 + .../SidebarUniformRowSpacingTests.swift | 202 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift index 2734216e35bb..124cfbd3a439 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift @@ -292,6 +292,15 @@ class SidebarItemRowView: NSView { layoutActivity(onGlyph: wellFrame) } + /// The cache key of the pin's ink (`SidebarGlyphInk`). + private static let pinGlyph = "pin|\(IconName.statePinned.rawValue)" + + /// The pin's drawn ink in the row's coordinates; nil without a pin (tests). + var pinInkFrame: CGRect? { + guard !pin.isHidden, let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else { return nil } + return ink.offsetBy(dx: pin.frame.minX, dy: pin.frame.minY) + } + /// The item's registry icon at `side` points; without one, its SF Symbol at the matching text size. private func glyphImage(side: CGFloat) -> NSImage? { if let favicon = info.favicon { return NSImage(cgImage: favicon.image, size: NSSize(width: side, height: side)) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift new file mode 100644 index 000000000000..e800a445ac80 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift @@ -0,0 +1,202 @@ +import AppKit +@testable import CmuxNextDesign +@testable import CmuxNextSidebar +import Testing + +/// Leo 2026-10-21, on the cmux NEXT sidebar: "ALL THIS RELATIVELY UNNECESSARY SPACING AFTER GROUPS AND +/// AFTER PINS". Rows keep one gap, the list's row spacing, between every two of them: under a group's +/// last member, under a folder's last row, under the Pinned block's last row and under the last pinned +/// chat, as between any two rows. A pinned workspace row's pin starts where an unpinned row's content +/// starts and centers on its row as that row's content does. +@MainActor @Suite(.serialized) +struct SidebarUniformRowSpacingTests { + static let group = GroupID("grp") + static let pins = [LayoutItemID("itm_pin_1"), LayoutItemID("itm_pin_2")] + static let pinnedKey = SidebarRowKey.block(SidebarLayoutDocument.pinnedSectionID.rawValue) + + static func ws(_ id: String) -> SidebarWorkspace { SidebarWorkspace(id: WorkspaceID(id), title: id) } + + /// A machine section: group `grp` (w1, w2), then loose w3 and w4. + static func machine() -> CmuxNextSidebar.SidebarSection { + let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) + return CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: [ + .group(SidebarGroup(id: group, name: "Group", workspaces: [ws("w1"), ws("w2")])), + .workspace(ws("w3")), .workspace(ws("w4")), + ]) + } + + static func pinnedBlock() -> CmuxNextSidebar.SidebarSection { + CmuxNextSidebar.SidebarSection(kind: .custom(SidebarCustomSection(id: SidebarLayoutDocument.pinnedSectionID.rawValue)), nodes: []) + } + + static func row(_ layout: SidebarLayout, _ id: String) throws -> SidebarRow { + try #require(layout.row(for: .workspace(WorkspaceID(id))), "no row for \(id)") + } + + // MARK: The layout (pure) + + @Test(arguments: SidebarDensity.allCases) func aGroupsLastRowAndTheNextRowKeepTheRowSpacing(_ density: SidebarDensity) throws { + let saved = DesignSettings.shared.sidebarSections.density + DesignSettings.shared.sidebarSections.density = density + defer { DesignSettings.shared.sidebarSections.density = saved } + let m = SidebarLayoutMetrics.standard + let layout = SidebarLayout.make(sections: [Self.machine()], metrics: m) + let header = try #require(layout.row(for: .group(Self.group))) + let (w1, w2, w3, w4) = try (Self.row(layout, "w1"), Self.row(layout, "w2"), Self.row(layout, "w3"), Self.row(layout, "w4")) + #expect(abs(w1.y - header.maxY - m.rowSpacing) < 0.01) + #expect(abs(w2.y - w1.maxY - m.rowSpacing) < 0.01) + #expect(abs(w3.y - w2.maxY - m.rowSpacing) < 0.01, "the group's end adds \(w3.y - w2.maxY - m.rowSpacing) pt to the row spacing") + #expect(abs(w4.y - w3.maxY - m.rowSpacing) < 0.01) + } + + @Test func aFoldedGroupAndAnOpenedOneEndTheSameWay() throws { + let m = SidebarLayoutMetrics.standard + var collapsed = Self.machine() + if case var .group(group) = collapsed.nodes[0] { + group.isCollapsed = true + collapsed.nodes[0] = .group(group) + } + let layout = SidebarLayout.make(sections: [collapsed], metrics: m) + let header = try #require(layout.row(for: .group(Self.group))) + let w3 = try Self.row(layout, "w3") + #expect(abs(w3.y - header.maxY - m.rowSpacing) < 0.01) + // The fold's splice lays the open group out as `make` does. + let opened = SidebarLayout.make(sections: [Self.machine()], metrics: m) + guard case let .group(group) = Self.machine().nodes[0] else { return } + let spliced = try #require(layout.folding(group, collapsed: false, in: Self.machine(), metrics: m, options: SidebarLayoutOptions())) + #expect(spliced.rows == opened.rows) + #expect(spliced.totalHeight == opened.totalHeight) + } + + @Test func aFoldersLastRowAndTheNextFolderKeepTheRowSpacing() throws { + let m = SidebarLayoutMetrics.standard + var a = Self.ws("a"), b = Self.ws("b") + (a.folder, b.folder) = ("~/one", "~/two") + let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) + var options = SidebarLayoutOptions() + options.groupsByFolder = true + let layout = SidebarLayout.make(sections: [CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: [.workspace(a), .workspace(b)])], + metrics: m, options: options) + let rowA = try Self.row(layout, "a") + let second = try #require(layout.row(for: .folder(.machine(.local), "~/two"))) + #expect(abs(second.y - rowA.maxY - m.rowSpacing) < 0.01, "a folder's end adds \(second.y - rowA.maxY - m.rowSpacing) pt") + } + + @Test func theRowUnderThePinnedBlockKeepsTheRowSpacing() throws { + let m = SidebarLayoutMetrics.standard + var options = SidebarLayoutOptions() + options.blockHeights = [SidebarLayoutDocument.pinnedSectionID.rawValue: 52] + let layout = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) + let block = try #require(layout.row(for: Self.pinnedKey)) + let header = try #require(layout.row(for: .group(Self.group))) + #expect(abs(header.y - block.maxY - m.rowSpacing) < 0.01, "the pins' end adds \(header.y - block.maxY - m.rowSpacing) pt") + } + + @Test func theFirstWorkspaceRowFollowsTheLastPinnedChat() throws { + let m = SidebarLayoutMetrics.standard + var options = SidebarLayoutOptions() + options.sessionTree = SidebarSessionTreeState() + let history = SidebarChatHistoryState(pinned: [SidebarHistoryChat(id: "c", title: "c", updatedAt: Date(timeIntervalSince1970: 0))]) + options.chatHistory = history + let layout = SidebarLayout.make(sections: [Self.machine()], metrics: m, options: options) + let pinnedY = try #require(layout.pinnedChatsY) + let header = try #require(layout.row(for: .group(Self.group))) + // The pinned block's rows end with the row spacing (`pinnedHeight`), as list rows do. + #expect(abs(header.y - (pinnedY + history.pinnedHeight(m))) < 0.01) + } + + // MARK: The drawn list + + final class Harness { + let model: SidebarModel + let sidebar: SidebarView + let window: NSWindow + var list: SidebarListView { sidebar.list } + + init() { + let pinned = LayoutSection(id: SidebarLayoutDocument.pinnedSectionID, showsTitle: false, region: .top, look: .builtIn, + items: SidebarUniformRowSpacingTests.pins.enumerated().map { index, id in + LayoutItem(id: id, ref: .workspace("pinned-\(index)")) + }) + model = SidebarModel(sections: [SidebarUniformRowSpacingTests.pinnedBlock(), SidebarUniformRowSpacingTests.machine()], + activeWorkspaceID: nil) + model.layout = SidebarLayoutDocument(sections: [pinned]) + model.docDrag = SidebarDocDrag(item: { _ in nil }, section: { _ in nil }, begin: { _ in [:] }, drop: { _, _ in }, + adoptRef: { _ in nil }) + sidebar = SidebarView(model: model, design: DesignSettings.shared) + sidebar.frame = NSRect(x: 0, y: 0, width: 260, height: 420) + window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 400, height: 420), styleMask: [.titled], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView?.addSubview(sidebar) + sidebar.layoutSubtreeIfNeeded() + list.reload(animated: false) + sidebar.layoutSubtreeIfNeeded() + list.fitToClip() + sidebar.layoutSubtreeIfNeeded() + list.rowViews.values.forEach { $0.layoutSubtreeIfNeeded() } + } + + func pinnedRow(_ id: LayoutItemID) throws -> SidebarItemRowView { + let block = try #require(list.rowViews[SidebarUniformRowSpacingTests.pinnedKey] as? SidebarBlockRowView, "the list draws no Pinned block") + let row = try #require(block.region.itemViews[id], "no pinned row \(id)") + row.layoutSubtreeIfNeeded() + return row + } + + func workspaceRow(_ id: String) throws -> WorkspaceRowView { + let row = try #require(list.rowViews[.workspace(WorkspaceID(id))] as? WorkspaceRowView, "no row for \(id)") + row.layoutSubtreeIfNeeded() + return row + } + + func inList(_ view: NSView) -> CGRect { list.convert(view.bounds, from: view) } + } + + init() { Motion.reduceMotionOverride = true } + + @Test(arguments: SidebarDensity.allCases) func everyDrawnRowKeepsTheOneGap(_ density: SidebarDensity) throws { + let saved = DesignSettings.shared.sidebarSections.density + DesignSettings.shared.sidebarSections.density = density + defer { + DesignSettings.shared.sidebarSections.density = saved + Motion.reduceMotionOverride = nil + } + let h = Harness() + defer { h.window.orderOut(nil) } + let spacing = h.list.displayed.rowSpacing + #expect(spacing > 0) + let lastPin = h.inList(try h.pinnedRow(Self.pins[1])) + let header = try #require(h.list.displayed.row(for: .group(Self.group)), "no group header") + #expect(abs(header.y - lastPin.maxY - spacing) < 0.5, + "under the pins: \(header.y - lastPin.maxY) pt, the row spacing is \(spacing) pt") + let (w2, w3, w4) = try (h.inList(h.workspaceRow("w2")), h.inList(h.workspaceRow("w3")), h.inList(h.workspaceRow("w4"))) + #expect(abs(w3.minY - w2.maxY - spacing) < 0.5, "under the group: \(w3.minY - w2.maxY) pt, the row spacing is \(spacing) pt") + #expect(abs(w4.minY - w3.maxY - spacing) < 0.5) + } + + @Test(arguments: SidebarDensity.allCases) func thePinStartsAndCentersAsAnUnpinnedRowsContent(_ density: SidebarDensity) throws { + let saved = DesignSettings.shared.sidebarSections.density + DesignSettings.shared.sidebarSections.density = density + defer { + DesignSettings.shared.sidebarSections.density = saved + Motion.reduceMotionOverride = nil + } + let h = Harness() + defer { h.window.orderOut(nil) } + let pinned = try h.pinnedRow(Self.pins[0]) + let ink = try #require(pinned.pinInkFrame, "the pinned workspace row draws no pin") + let pin = h.list.convert(ink, from: pinned) + let pinnedFrame = h.inList(pinned) + let unpinned = try h.workspaceRow("w3") + // An unpinned row's leading slot: its icon, or where its title starts without one. + let content = h.list.convert(unpinned.icon.frame, from: unpinned) + let unpinnedFrame = h.inList(unpinned) + #expect(abs(pin.minX - content.minX) < 0.5, "the pin's ink starts at \(pin.minX), an unpinned row's content at \(content.minX)") + #expect(abs((pin.midY - pinnedFrame.minY) - (content.midY - unpinnedFrame.minY)) < 0.5, + "the pin centers \(pin.midY - pinnedFrame.minY) pt into its row, an unpinned row's content \(content.midY - unpinnedFrame.minY) pt") + #expect(abs(pinnedFrame.height - unpinnedFrame.height) < 0.01) + // The pinned row's title still starts after its pin. + #expect(pinned.titleFrame.minX >= pin.maxX) + } +} From 5ca9bed64401ef7fc26b628be527477cd503f489 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 22:23:18 +0000 Subject: [PATCH 02/12] fix(cmux-next sidebar): one row spacing after groups, folders and pins; pin lines up Rows keep one gap, the list row spacing, under a group, a Group by Folder folder, the Pinned block and the pinned chats: groupBottomPadding is gone, a list block has no bottom padding (the list puts the row spacing under it), and the pinned chats add no section gap. The pinned row's pin is placed by its measured ink, so it starts where an unpinned row's content starts and centers on its row. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Logic/SidebarChatHistoryPlacement.swift | 5 ++-- .../Logic/SidebarEmptyArea.swift | 5 ++-- .../Logic/SidebarLayout+Fold.swift | 12 +++++----- .../CmuxNextSidebar/Logic/SidebarLayout.swift | 15 +++++++----- .../Logic/SidebarLayoutMetrics.swift | 6 ++--- .../Views/LayoutMetrics+Tokens.swift | 1 - .../Views/Sections/SidebarItemRowView.swift | 17 ++++++++++++-- .../Views/SidebarDensityTokens.swift | 5 ++-- .../Views/SidebarListView+Blocks.swift | 23 +++++++++---------- 9 files changed, 51 insertions(+), 38 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarChatHistoryPlacement.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarChatHistoryPlacement.swift index 894d3764b156..1da975775cf6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarChatHistoryPlacement.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarChatHistoryPlacement.swift @@ -17,11 +17,12 @@ nonisolated struct SidebarChatHistoryPlacement { spacing = m.sectionSpacing } - /// Pinned at `y`, once, with a section's spacing under it. + /// Pinned at `y`, once. Its rows end with the row spacing, so the first workspace row follows its last + /// pinned chat as the next row does any other (no section gap after the pins, Leo 2026-10-21). mutating func placePinned(_ y: inout CGFloat) { guard pinnedY == nil, pinnedHeight > 0 else { return } pinnedY = y - y += pinnedHeight + spacing + y += pinnedHeight } /// Earlier a section's spacing under `y`, once; Pinned first when no computer took it. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarEmptyArea.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarEmptyArea.swift index 2368f9f9c6aa..78df657d4dd6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarEmptyArea.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarEmptyArea.swift @@ -17,7 +17,7 @@ public nonisolated struct SidebarEmptyAreaTarget: Hashable, Sendable { extension SidebarLayout { /// The new-workspace target for a double-click at `y`, or nil on a row. /// The row above decides: inside an expanded group (between its rows, - /// or within its bottom padding) the group; anywhere else the end of the + /// or in the row spacing under its last row) the group; anywhere else the end of the /// section of that row. Above every row, the first machine section. /// The pinned section takes no new workspace. public func emptyAreaTarget(at y: CGFloat, metrics: SidebarLayoutMetrics) -> SidebarEmptyAreaTarget? { @@ -28,8 +28,7 @@ extension SidebarLayout { let above = rows[index] guard above.section != .pinned else { return nil } if let group = groupOpening(below: above) { - let endsGroup = above.isLastInGroup || above.key == .group(group) && above.childCount == 0 - let extent = above.maxY + metrics.rowSpacing + (endsGroup ? metrics.groupBottomPadding : 0) + let extent = above.maxY + metrics.rowSpacing if y < extent { return SidebarEmptyAreaTarget(section: above.section, group: group) } } return SidebarEmptyAreaTarget(section: above.section) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Fold.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Fold.swift index 6ee6a15118fc..658c91dc577f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Fold.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Fold.swift @@ -41,7 +41,7 @@ nonisolated extension SidebarLayout { // The group's block: its header and the member rows right after it. var end = headerIndex + 1 while end < rows.count, rows[end].group == group.id, !rows[end].key.isGroupHeader { end += 1 } - let oldEnd = Self.blockEnd(header: header, last: end > headerIndex + 1 ? rows[end - 1] : nil, collapsed: header.isCollapsed, metrics: m) + let oldEnd = Self.blockEnd(header: header, last: end > headerIndex + 1 ? rows[end - 1] : nil, metrics: m) let shown = group.workspaces.filter { !o.excludedWorkspaces.contains($0.id) } let shownIDs = Set(shown.map(\.id)) @@ -96,7 +96,7 @@ nonisolated extension SidebarLayout { } } } - let newEnd = Self.blockEnd(header: header, last: built.last, collapsed: collapsed, metrics: m) + let newEnd = Self.blockEnd(header: header, last: built.last, metrics: m) var folded = header folded.isCollapsed = collapsed setRow(folded, at: headerIndex) @@ -122,10 +122,10 @@ nonisolated extension SidebarLayout { return layout } - /// Where the list goes on after a group's block (`make`): an open group adds its bottom padding. - private static func blockEnd(header: SidebarRow, last: SidebarRow?, collapsed: Bool, metrics m: SidebarLayoutMetrics) -> CGFloat { - let after = (last ?? header).maxY + m.rowSpacing - return collapsed ? after : after + m.groupBottomPadding + /// Where the list goes on after a group's block (`make`): one row spacing under its last row, open or + /// folded (no padding under a group, Leo 2026-10-21). + private static func blockEnd(header: SidebarRow, last: SidebarRow?, metrics m: SidebarLayoutMetrics) -> CGFloat { + (last ?? header).maxY + m.rowSpacing } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index 89b53dc35504..50e062807fe4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -250,12 +250,16 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { let flat = o.flattensMachines && section.machine != nil && !Self.keepsHeaderInOneList(section) if flat && nodes.isEmpty && !gapHere && (listsAnyMachineRow || shownEmptyMachine) { continue } if flat && nodes.isEmpty { shownEmptyMachine = true } - // Consecutive computers in one list read as one list: no gap. A block brings its own padding. + // Consecutive computers in one list read as one list: no gap. A block brings its own top padding; under + // it the next row follows by the row spacing, as under any row (Leo 2026-10-21: no gap after the pins). if firstSection && blockHeight > 0 { y -= m.topPadding } - if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.topPadding : m.sectionSpacing } + if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.rowSpacing : m.sectionSpacing } firstSection = false if section.machine != nil { chats.placePinned(&y) } - previousWasBlock = blockHeight > 0 && nodes.isEmpty + // This section's own rows under its block (its groups, a drop's gap or its empty drop zone). + let rowsUnderBlock = blockHeight > 0 + && (!nodes.isEmpty || gapHere || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) + previousWasBlock = blockHeight > 0 && !rowsUnderBlock // Only a headerless computer joins the next one: rows after a headed section (a remote group, // the Chief's) never read as its members. previousWasMachine = section.machine != nil && flat @@ -265,7 +269,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { group: nil, siblingIndex: 0, parentIndex: nil, isLastInGroup: false, isCollapsed: false, childCount: 0, groupColor: nil )) - y += blockHeight + y += blockHeight + (rowsUnderBlock ? m.rowSpacing : 0) } // Headers stay for the pinned area and for one section per // computer (`sidebar.groupByComputer`, opt-in), never in one list. @@ -419,8 +423,8 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { } } } + // The next row follows the last member by the row spacing alone (Leo 2026-10-21: no padding after groups). openGapIfNeeded(section: section.id, group: group.id, index: members.count) - y += m.groupBottomPadding } } // Group by Folder lists groups and document items (links, tabs, apps) as they are. @@ -456,7 +460,6 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { )) y += m.groupHeaderHeight + m.rowSpacing for index in indices { emit(index, nodes[index]) } - y += m.groupBottomPadding } openGapIfNeeded(section: section.id, group: nil, index: nodes.count) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift index 6952615f5031..8756e4be1903 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift @@ -16,8 +16,9 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable { /// Workspace row with a secondary detail line. public var rowHeightWithSubtitle: CGFloat public var tabRowHeight: CGFloat + /// The one gap between consecutive rows: a group's last member, a folder's last row and the + /// Pinned block's last row take it too, never a padding of their own (Leo 2026-10-21). public var rowSpacing: CGFloat - public var groupBottomPadding: CGFloat public var emptySectionHeight: CGFloat /// One line of a row's status block (`WorkspaceRowContent.status`). public var statusLineHeight: CGFloat = 14 @@ -29,7 +30,7 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable { public init( topPadding: CGFloat, bottomPadding: CGFloat, sectionHeaderHeight: CGFloat, sectionSpacing: CGFloat, rowHeight: CGFloat, rowHeightWithSubtitle: CGFloat, tabRowHeight: CGFloat, rowSpacing: CGFloat, - groupBottomPadding: CGFloat, emptySectionHeight: CGFloat + emptySectionHeight: CGFloat ) { self.topPadding = topPadding self.bottomPadding = bottomPadding @@ -39,7 +40,6 @@ public nonisolated struct SidebarLayoutMetrics: Hashable, Sendable { self.rowHeightWithSubtitle = rowHeightWithSubtitle self.tabRowHeight = tabRowHeight self.rowSpacing = rowSpacing - self.groupBottomPadding = groupBottomPadding self.emptySectionHeight = emptySectionHeight } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/LayoutMetrics+Tokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/LayoutMetrics+Tokens.swift index 85ffc2560762..f387732adad6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/LayoutMetrics+Tokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/LayoutMetrics+Tokens.swift @@ -17,7 +17,6 @@ extension SidebarLayoutMetrics { rowHeightWithSubtitle: SidebarDensityTokens.rowHeightWithSubtitle, tabRowHeight: row, rowSpacing: Metrics.space1, - groupBottomPadding: SidebarDensityTokens.groupBottomPadding, emptySectionHeight: row ) let font = SidebarStyle.subtitleFont diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift index 124cfbd3a439..c265c350cf62 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/Sections/SidebarItemRowView.swift @@ -213,13 +213,14 @@ class SidebarItemRowView: NSView { // The glyph lines up with the text of workspace rows (their inset plus the pill inset). // A pinned workspace row's pin leads, and its icon and title follow it. let pinSide = Metrics.smallIconSize - let lead = (style == .chip ? Metrics.space2 : inset * 2) + (info.leadingPin ? pinSide + Metrics.space2 : 0) + let pinFrame = info.leadingPin ? pinGlyphFrame(side: pinSide, leading: inset * 2, height: b.height) : .zero + let lead = (style == .chip ? Metrics.space2 : inset * 2) + (info.leadingPin ? pinFrame.maxX - inset * 2 + Metrics.space2 : 0) + (style == .chip ? 0 : CGFloat(info.indent) * Metrics.space3) let iconFrame = style.isIconOnly ? NSRect(x: (b.width - side) / 2, y: (b.height - side) / 2, width: side, height: side) : NSRect(x: lead, y: (b.height - side) / 2, width: side, height: side) pin.isHidden = !info.leadingPin - pin.frame = NSRect(x: inset * 2, y: (b.height - pinSide) / 2, width: pinSide, height: pinSide) + pin.frame = pinFrame // Icon-only items keep their pill around the glyph (a full-width fill reads // as a selected row); the profile avatar's pill and click fill its square. let pillFrame = style.isIconOnly ? (showsAvatar ? b : iconFrame) @@ -295,6 +296,18 @@ class SidebarItemRowView: NSView { /// The cache key of the pin's ink (`SidebarGlyphInk`). private static let pinGlyph = "pin|\(IconName.statePinned.rawValue)" + /// The leading pin's frame: its drawn ink, not its square, starts at `leading` (where an unpinned workspace + /// row's icon or title starts) and centers on the row, on the device pixel grid. The pin's square leaves a + /// margin on each side of its ink, so the square at the rows' inset drew the pin in from them (Leo 2026-10-21). + private func pinGlyphFrame(side: CGFloat, leading: CGFloat, height: CGFloat) -> NSRect { + let scale = window?.backingScaleFactor ?? 2 + let snap = { (value: CGFloat) in (value * scale).rounded() / scale } + guard let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else { + return NSRect(x: leading, y: snap((height - side) / 2), width: side, height: side) + } + return NSRect(x: snap(leading - ink.minX), y: snap(height / 2 - ink.midY), width: side, height: side) + } + /// The pin's drawn ink in the row's coordinates; nil without a pin (tests). var pinInkFrame: CGRect? { guard !pin.isHidden, let image = pin.image, let ink = SidebarGlyphInk.shared.box(of: image, glyph: Self.pinGlyph) else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarDensityTokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarDensityTokens.swift index febe5bac0692..8736ef5db2ef 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarDensityTokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarDensityTokens.swift @@ -20,13 +20,13 @@ import CoreGraphics /// | horizontal inset | 6 | 8 | `space3`→`space4` | /// | section gap | 8 | 12 | `space4`→`space5` | /// | list top padding | 4 | 6 | `space2`→`space3` | -/// | group bottom padding | 6 | 8 | `space3`→`space4` | /// /// Row title and detail text size and weight do not change (Interface Size /// owns the text size); a group header's name keeps its existing rule and /// follows its bar (`SidebarStyle.groupHeaderFont`: 11 pt on the 24 pt row, /// 12 pt on the 28 pt row). Row spacing stays `space1` so selection fills -/// keep their rhythm. Values are read at layout time, never cached, so a +/// keep their rhythm, and it is the only gap between rows: a group's or the +/// Pinned block's last row adds no padding (Leo 2026-10-21). Values are read at layout time, never cached, so a /// change applies live. enum SidebarDensityTokens { static var density: SidebarDensity { DesignSettings.shared.sidebarSections.density } @@ -51,5 +51,4 @@ enum SidebarDensityTokens { static var rowCornerRadius: CGFloat { Metrics.itemCornerRadius } static var sectionSpacing: CGFloat { isComfortable ? Metrics.space5 : Metrics.space4 } static var topPadding: CGFloat { isComfortable ? Metrics.space3 : Metrics.space2 } - static var groupBottomPadding: CGFloat { isComfortable ? Metrics.space4 : Metrics.space3 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Blocks.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Blocks.swift index ba0c22fee3b4..870faba5c7ef 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Blocks.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Blocks.swift @@ -49,9 +49,11 @@ struct SidebarListBlocks: Equatable { var appHeights: [LayoutSectionID: CGFloat] /// Section `id` without `hiding` and with a gap at `gap` (an index among the items left), as wide as the first hidden item. - /// `joinsAbove`/`joinsBelow`: a block drawn right before or after it, so the two keep one band's spacing (a section - /// gap and its line between them, the band's padding only at the run's ends). - func content(_ id: String, hiding: Set, gap: Int?, joinsAbove: Bool = false, joinsBelow: Bool = false) -> SidebarRegionView.Content? { + /// `joinsAbove`: a block drawn right before it, so the two keep one band's spacing (a section gap and its line between + /// them, the band's padding only at the run's top). A block has no bottom padding in the list: the list puts its row + /// spacing (or the next block's top edge) under the last item, so the rows under the pins follow as rows follow + /// rows (Leo 2026-10-21). + func content(_ id: String, hiding: Set, gap: Int?, joinsAbove: Bool = false) -> SidebarRegionView.Content? { guard var section = sections[id] else { return nil } section.items.removeAll { hiding.contains($0.id.rawValue) } var infos = infos @@ -64,7 +66,7 @@ struct SidebarListBlocks: Equatable { } var edges = metrics if joinsAbove { (edges.topPadding, edges.leadingSeparator) = (metrics.sectionGap, true) } - if joinsBelow { edges.bottomPadding = 0 } + edges.bottomPadding = 0 return SidebarRegionView.Content(sections: [section], infos: infos, collapsed: collapsed, look: look, metrics: edges, drawsLines: drawsLines, appHeights: appHeights) } @@ -118,16 +120,15 @@ extension SidebarListViewComponents { /// Block `id`'s content as drawn (with the drag's or the held drop's gap) or as the hit test's base (without it). func blockContent(_ id: String, includeGap: Bool) -> SidebarRegionView.Content? { let gap = includeGap ? shownDrag?.blockTarget.flatMap { $0.section == id ? $0.index : nil } : nil - let (above, below) = blockJoins(id) - return blocks?.content(id, hiding: blockHidden, gap: gap, joinsAbove: above, joinsBelow: below) + return blocks?.content(id, hiding: blockHidden, gap: gap, joinsAbove: joinsBlockAbove(id)) } - /// Whether block `id` has another block right above or below it in the list (no rows between them), by the + /// Whether block `id` has another block right above it in the list (no rows between them), by the /// layout's own rules: a collapsed section or one whose groups the filter or the drag leave out lists no rows, /// and a section whose items lay out to nothing draws no block. - func blockJoins(_ id: String) -> (above: Bool, below: Bool) { + func joinsBlockAbove(_ id: String) -> Bool { let sections = model.sections - guard let index = sections.firstIndex(where: { $0.id == .custom(id) }) else { return (false, false) } + guard let index = sections.firstIndex(where: { $0.id == .custom(id) }) else { return false } let matches = model.filterMatches var excludedGroup: GroupID? if case let .group(group)? = shownDrag?.payload { excludedGroup = group } @@ -146,9 +147,7 @@ extension SidebarListViewComponents { guard let id = docID(section), let content = blocks?.content(id, hiding: blockHidden, gap: nil) else { return false } return SidebarRegionView.layout(of: content, width: bounds.width).height > 0 } - let above = index > 0 && listsNoRows(sections[index - 1]) && hasBlock(sections[index - 1]) - let below = listsNoRows(sections[index]) && index + 1 < sections.count && hasBlock(sections[index + 1]) - return (above, below) + return index > 0 && listsNoRows(sections[index - 1]) && hasBlock(sections[index - 1]) } /// Ends a settling drop once its move landed (the list's structure or the blocks changed); returns it. From cbbada1846ef21b31091860eb069ee54bc6ce771 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 00:10:25 +0000 Subject: [PATCH 03/12] fix(cmux-next sidebar): a drop's gap alone under a block shifts rows by gapShift Review follow-up on #23800: a drop gap that is the only thing under a list block counted as rows under it, so the rows below moved a section gap more than the drag's hit test assumes. The gap now opens right under the block. Also fixes a stale drag comment and compares the pinned title in list coordinates in the test. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../CmuxNextSidebar/Logic/SidebarLayout.swift | 5 +++-- .../Views/SidebarListView+Drag.swift | 2 +- .../SidebarUniformRowSpacingTests.swift | 20 ++++++++++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index 50e062807fe4..e29cf4a910fe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -256,9 +256,10 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.rowSpacing : m.sectionSpacing } firstSection = false if section.machine != nil { chats.placePinned(&y) } - // This section's own rows under its block (its groups, a drop's gap or its empty drop zone). + // This section's own rows under its block: its groups, or its empty drop zone (a drop's gap takes the zone's + // place). A gap alone opens right under the block, so the rows below move by exactly `gapShift`. let rowsUnderBlock = blockHeight > 0 - && (!nodes.isEmpty || gapHere || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) + && (!nodes.isEmpty || (!gapHere && emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) previousWasBlock = blockHeight > 0 && !rowsUnderBlock // Only a headerless computer joins the next one: rows after a headed section (a remote group, // the Chief's) never read as its members. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Drag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Drag.swift index d3db164f37e5..b5b9cfccde43 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Drag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+Drag.swift @@ -176,7 +176,7 @@ extension SidebarListViewComponents { if drag.groupHeadHeight != nil, drag.blockTarget == nil, let gapY = displayed.gapY, !drag.groupBlocks.isEmpty { var reorder = SidebarGroupReorder(blocks: drag.groupBlocks, shift: displayed.gapShift) let current = reorder.index(ofGapAt: gapY) - // The blocks below the gap as drawn: the gap plus the spacing between groups (6 pt more than `gapShift`). + // The blocks below the gap as drawn, measured: a section's gap can lie between two groups. let shown = SidebarGroupReorder.blocks(of: displayed.rows.filter { !drag.hiddenKeys.contains($0.key) }) if current < min(shown.count, drag.groupBlocks.count) { reorder.shift = shown[current].lowerBound - drag.groupBlocks[current].lowerBound } let card = drag.lift.frame diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift index e800a445ac80..11a907a10927 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift @@ -92,6 +92,24 @@ struct SidebarUniformRowSpacingTests { #expect(abs(header.y - block.maxY - m.rowSpacing) < 0.01, "the pins' end adds \(header.y - block.maxY - m.rowSpacing) pt") } + /// A drop's gap alone under the Pinned block moves the rows below by exactly the gap's shift, as the drag's hit + /// test assumes (`SidebarDragGeometry.baseY`). + @Test func aGapUnderThePinnedBlockMovesTheRowsBelowByItsShift() throws { + let m = SidebarLayoutMetrics.standard + var options = SidebarLayoutOptions() + options.blockHeights = [SidebarLayoutDocument.pinnedSectionID.rawValue: 52] + let base = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) + options.gap = DropPosition(section: .custom(SidebarLayoutDocument.pinnedSectionID.rawValue), group: nil, index: 0) + options.gapHeight = m.rowHeight + let open = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) + let gapY = try #require(open.gapY) + let block = try #require(open.row(for: Self.pinnedKey)) + #expect(gapY >= block.maxY) + let before = try #require(base.row(for: .group(Self.group))) + let after = try #require(open.row(for: .group(Self.group))) + #expect(abs(after.y - before.y - open.gapShift) < 0.01, "the rows below moved \(after.y - before.y) pt, the gap's shift is \(open.gapShift) pt") + } + @Test func theFirstWorkspaceRowFollowsTheLastPinnedChat() throws { let m = SidebarLayoutMetrics.standard var options = SidebarLayoutOptions() @@ -197,6 +215,6 @@ struct SidebarUniformRowSpacingTests { "the pin centers \(pin.midY - pinnedFrame.minY) pt into its row, an unpinned row's content \(content.midY - unpinnedFrame.minY) pt") #expect(abs(pinnedFrame.height - unpinnedFrame.height) < 0.01) // The pinned row's title still starts after its pin. - #expect(pinned.titleFrame.minX >= pin.maxX) + #expect(h.list.convert(pinned.titleFrame, from: pinned).minX >= pin.maxX) } } From cce18bc8b9b972f6bcbecb9acc55c396dbe1760d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 00:25:11 +0000 Subject: [PATCH 04/12] fix(cmux-next sidebar): a gap in an empty drop zone's place keeps the rows below still Review follow-up on #23800: with every item of a titled section dragged out, the drop's gap in its empty zone's place now opens where the zone was (one row spacing under the block), as on base, instead of moving the rows below up by a section gap. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Sources/CmuxNextSidebar/Logic/SidebarLayout.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index e29cf4a910fe..fb09a526f6d0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -256,10 +256,11 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.rowSpacing : m.sectionSpacing } firstSection = false if section.machine != nil { chats.placePinned(&y) } - // This section's own rows under its block: its groups, or its empty drop zone (a drop's gap takes the zone's - // place). A gap alone opens right under the block, so the rows below move by exactly `gapShift`. + // This section's own rows under its block: its groups, or its empty drop zone (or the drop's gap in the zone's + // place, so the rows below stay put). Any other gap alone opens right under the block and moves the rows + // below by exactly `gapShift`. let rowsUnderBlock = blockHeight > 0 - && (!nodes.isEmpty || (!gapHere && emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) + && (!nodes.isEmpty || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) previousWasBlock = blockHeight > 0 && !rowsUnderBlock // Only a headerless computer joins the next one: rows after a headed section (a remote group, // the Chief's) never read as its members. From affbe513a3c99a756ffd6b098a02a2765dcca6ca Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 02:05:28 -0700 Subject: [PATCH 05/12] cmux NEXT sidebar: Group by Status sections end on the row spacing too (base #20905 used the removed groupBottomPadding) Merging feat-cmux-next brought #20905's status layout, which still added SidebarLayoutMetrics.groupBottomPadding after each status section; this PR removes that padding, so the app no longer compiled. A status section now ends like a group: the next header follows the last row by the row spacing. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Logic/SidebarLayout+Status.swift | 2 +- .../SidebarStatusGroupingTests.swift | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift index 9e54bd09cbdd..e21a954c7b24 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift @@ -62,7 +62,7 @@ nonisolated extension SidebarLayout { y += m.tabRowHeight + m.rowSpacing } } - y += m.groupBottomPadding + // The next status group follows the last member by the row spacing alone (Leo 2026-10-21: no padding after groups). } return rows } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarStatusGroupingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarStatusGroupingTests.swift index 27b251704b6e..18c42857f675 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarStatusGroupingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarStatusGroupingTests.swift @@ -179,6 +179,19 @@ import Testing #expect(!keys.contains { if case .showMore = $0 { true } else { false } }) } + /// A status section's last row and the next section's header keep the row spacing alone, as under a group's last + /// member (Leo 2026-10-21: "ALL THIS RELATIVELY UNNECESSARY SPACING AFTER GROUPS"). + @Test func aStatusSectionsLastRowAndTheNextHeaderKeepTheRowSpacing() throws { + let m = SidebarLayoutMetrics.standard + var o = SidebarLayoutOptions() + o.grouping.setByStatus(true) + let layout = SidebarLayout.make(sections: Sidebar.sections([Self.agent("build", working: true), SidebarWorkspace(id: WorkspaceID("shell"), title: "shell")]), + metrics: m, options: o) + let build = try #require(layout.row(for: .workspace(WorkspaceID("build")))) + let idle = try #require(layout.row(for: .status(.idle))) + #expect(abs(idle.y - build.maxY - m.rowSpacing) < 0.01, "the section's end adds \(idle.y - build.maxY - m.rowSpacing) pt to the row spacing") + } + /// The sections are the Priority classifier's (`AgentPriorityRanking.state` through `SidebarLaneStatus`), not one /// of the sidebar's own: acpmux lanes in, the rows the row feed maps, laid out under their states. @Test func theSectionsComeFromThePriorityClassifier() throws { From ba6883f9083bf736da37628113d7764cd1875203 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 06:24:37 -0700 Subject: [PATCH 06/12] cmux NEXT sidebar: the folder spacing test sets Group by Folder through SidebarGrouping (base made groupsByFolder read-only) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift index 11a907a10927..3c2c799157cb 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift @@ -74,7 +74,7 @@ struct SidebarUniformRowSpacingTests { (a.folder, b.folder) = ("~/one", "~/two") let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) var options = SidebarLayoutOptions() - options.groupsByFolder = true + options.grouping.by = .folder let layout = SidebarLayout.make(sections: [CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: [.workspace(a), .workspace(b)])], metrics: m, options: options) let rowA = try Self.row(layout, "a") From 909a694ac00eedbf3766aff12a9e8f1a084c3cd8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 22 Oct 2026 07:28:43 -0700 Subject: [PATCH 07/12] Sidebar empty group test: the next group follows by the row gap alone Base's SidebarEmptyGroupTests (5819b62deb8) expected the removed groupBottomPadding. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16040-fd0b-7dd0-9487-1a3a198d9f5c&host=cosmic-gray --- .../Tests/CmuxNextAppTests/SidebarEmptyGroupTests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarEmptyGroupTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarEmptyGroupTests.swift index 96d810a23dc9..43e870e0ff45 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarEmptyGroupTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarEmptyGroupTests.swift @@ -43,10 +43,9 @@ import Testing #expect(placeholder.depth == header.depth + 1, "one level in, as a member") #expect(placeholder.y == header.maxY + SidebarLayoutMetrics.standard.rowSpacing) #expect(placeholder.isLastInGroup) - // The next group starts after the empty row and the group's bottom padding, not right under the header. + // The next group starts one row gap after the empty row, not right under the header. let next = try #require(layout.row(for: .group(perf))) - let m = SidebarLayoutMetrics.standard - #expect(next.y == placeholder.maxY + m.rowSpacing + m.groupBottomPadding) + #expect(next.y == placeholder.maxY + SidebarLayoutMetrics.standard.rowSpacing) } @Test func aCollapsedEmptyGroupStaysAHeaderAlone() { From 18d0424812d4e7a86aedd67b01b6001650f2bf5d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 19:46:45 +0000 Subject: [PATCH 08/12] docs(cmux-next sidebar): log #23800's row gaps in the owner note The target look gains a Row gaps row and the log names the three earlier commits of this branch that changed the sidebar views without a trailer. Sidebar-Look: ALL THIS RELATIVELY UNNECESSARY SPACING AFTER GROUPS AND AFTER PINS Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-b6dd9baa949f76f9d9ab --- plans/cmux-next/sidebar-owner.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plans/cmux-next/sidebar-owner.md b/plans/cmux-next/sidebar-owner.md index 79e9d819318b..4906c8fc0318 100644 --- a/plans/cmux-next/sidebar-owner.md +++ b/plans/cmux-next/sidebar-owner.md @@ -42,6 +42,7 @@ Enforced (Oct 23, Lawrence: "ensure this will always be true, and future agents | Nesting | Groups nest deeply (16 levels). The indent stops growing at 6 steps so a label keeps room. | Lawrence 11240 | | Minimal rows | Name plus one state mark (spinner, unread dot, needs-you dot). No times, PR badge, status words or Now line by default. | Lawrence 10-21 | | Heights | Group headers, workspace rows and band rows have one height at each density. | Lawrence 10-19, 10-22 | +| Row gaps | Two consecutive rows are one row spacing (`space1`) apart everywhere in the list: under a group's last member, a folder's last row (Group by Folder), the Pinned block's last row and the session tree's last pinned chat, folded or open. No padding of their own after groups, folders or pins (`groupBottomPadding` is gone). A leading pin's ink starts where an unpinned row's icon or title starts and centers on the row. Gaps between headed sections are unchanged. | Leo 2026-10-21 ("ALL THIS RELATIVELY UNNECESSARY SPACING AFTER GROUPS AND AFTER PINS"), https://github.com/manaflow-ai/cmux/pull/23800 | | Performance | Terminal typing stays smooth while the sidebar rearranges (p95 frame under 8 ms). | Lawrence 11215 | | Pinned workspace tile | A pinned workspace's tile shows its color as its list row does: a dot before the title when it has no icon. Its right-click, and its hover more button, open the workspace's own menu (name field, color swatches with No Color, Unpin), then Show/Hide Label; on hover it shows a workspace row's more button and close x at its trailing end. | Austin 2026-10-25 ("i dont think the pinned worokspace color menu sahows up there properly", "i dont see the three dots and stuff an dthen x button when i hover on it"), https://github.com/manaflow-ai/cmux/pull/23958 | | Archived rows | An archived workspace, group or link stays in its group (or its section), last, and draws dimmed: its ink, icon and group bar at `SidebarStyle.archivedAlpha` (0.45). Row height, insets, rail and controls do not change. Unarchive restores it. plans/cmux-next/archiving-DESIGN.md. | Aziz 2026-10-23 ("archiving moves the item to the end of its group (or root, if ungrouped) and shows it dimmed; unarchive restores it") | @@ -196,6 +197,7 @@ the gap, no row left out of place after drop or cancel). | [62c427ab5bd..a1af264c7a7](https://github.com/manaflow-ai/cmux/compare/62c427ab5bd...a1af264c7a7) | The Sidebar-Look gate and SidebarLookContractTests | | [89dbae323b5..30d164e1ec6](https://github.com/manaflow-ai/cmux/compare/89dbae323b5...30d164e1ec6) | Row close back to the trailing x (takes back #23783's dog-ear); a row without an icon starts its title at the inset (takes back c779988d57d's empty slot); look gallery test | | #24945 Update Ready card switch | The Automatic Updates switch starts its row at the card's text edge and the label follows (Leo 2026-10-22: "he preferred the checkbox on the left"; 2026-10-24: "I thought we said we were gonna put the toggle on the left"). The switch's size, colours, slide and cap-midline alignment, and every other part of the card, are unchanged. No row, rail or indent change. | +| #23800 row gaps (b4ea25bd03e red, 5ca9bed6440 fix, cbbada1846e drop gap under a block) | One row spacing after groups, folders, the Pinned block and pinned chats; the pin placed by its ink at the rows' content x. `Sidebar-Look: ALL THIS RELATIVELY UNNECESSARY SPACING AFTER GROUPS AND AFTER PINS` (Leo 2026-10-21). Look change: row gaps only; rail, indent, heights and trailing column unchanged (SidebarUniformRowSpacingTests). | ## Every sidebar package commit, Oct 19-23 From 47a426ae6f07cc1f423298200f7390dff78f1620 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 19:56:10 +0000 Subject: [PATCH 09/12] test(cmux-next sidebar): the row under the Pinned drop zone keeps the row spacing A drag that empties the Pinned block shows its drop zone (or the drop's gap in its place); the next row adds a section gap on top of the zone's row spacing (10 / 14 pt instead of 2). Fails on the parent commit. The view tests also restore the motion override they set instead of setting it for the whole suite. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-b6dd9baa949f76f9d9ab --- .../SidebarUniformRowSpacingTests.swift | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift index 3c2c799157cb..73d5822db677 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift @@ -110,6 +110,32 @@ struct SidebarUniformRowSpacingTests { #expect(abs(after.y - before.y - open.gapShift) < 0.01, "the rows below moved \(after.y - before.y) pt, the gap's shift is \(open.gapShift) pt") } + /// A drag that empties the Pinned block shows its drop zone under the block; the next row follows the zone (or + /// the drop's gap in its place) by the row spacing, not a section gap on top of it. + @Test(arguments: [false, true]) func theRowUnderThePinnedDropZoneKeepsTheRowSpacing(_ gapInZone: Bool) throws { + let m = SidebarLayoutMetrics.standard + let pinnedID = SidebarLayoutDocument.pinnedSectionID.rawValue + var options = SidebarLayoutOptions() + options.blockHeights = [pinnedID: 24] + options.showEmptyPinned = true + options.emptyDocSections = [pinnedID] + if gapInZone { + options.gap = DropPosition(section: .custom(pinnedID), group: nil, index: 0) + options.gapHeight = m.rowHeight + } + let layout = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) + let block = try #require(layout.row(for: Self.pinnedKey)) + let zoneEnd: CGFloat + if gapInZone { + zoneEnd = try #require(layout.gapY) + m.rowHeight + } else { + zoneEnd = try #require(layout.row(for: .emptySection(.custom(pinnedID)))).maxY + } + #expect(zoneEnd - m.rowSpacing >= block.maxY) + let header = try #require(layout.row(for: .group(Self.group))) + #expect(abs(header.y - zoneEnd - m.rowSpacing) < 0.01, "the drop zone's end adds \(header.y - zoneEnd - m.rowSpacing) pt") + } + @Test func theFirstWorkspaceRowFollowsTheLastPinnedChat() throws { let m = SidebarLayoutMetrics.standard var options = SidebarLayoutOptions() @@ -171,15 +197,10 @@ struct SidebarUniformRowSpacingTests { func inList(_ view: NSView) -> CGRect { list.convert(view.bounds, from: view) } } - init() { Motion.reduceMotionOverride = true } - @Test(arguments: SidebarDensity.allCases) func everyDrawnRowKeepsTheOneGap(_ density: SidebarDensity) throws { - let saved = DesignSettings.shared.sidebarSections.density - DesignSettings.shared.sidebarSections.density = density - defer { - DesignSettings.shared.sidebarSections.density = saved - Motion.reduceMotionOverride = nil - } + let (saved, savedMotion) = (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) + (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) = (density, true) + defer { (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) = (saved, savedMotion) } let h = Harness() defer { h.window.orderOut(nil) } let spacing = h.list.displayed.rowSpacing @@ -194,12 +215,9 @@ struct SidebarUniformRowSpacingTests { } @Test(arguments: SidebarDensity.allCases) func thePinStartsAndCentersAsAnUnpinnedRowsContent(_ density: SidebarDensity) throws { - let saved = DesignSettings.shared.sidebarSections.density - DesignSettings.shared.sidebarSections.density = density - defer { - DesignSettings.shared.sidebarSections.density = saved - Motion.reduceMotionOverride = nil - } + let (saved, savedMotion) = (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) + (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) = (density, true) + defer { (DesignSettings.shared.sidebarSections.density, Motion.reduceMotionOverride) = (saved, savedMotion) } let h = Harness() defer { h.window.orderOut(nil) } let pinned = try h.pinnedRow(Self.pins[0]) From d9c103d22e79e5609d797973bca2ac1b6ff92fc8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 19:56:32 +0000 Subject: [PATCH 10/12] fix(cmux-next sidebar): no section gap under the Pinned drop zone The drop zone (or the drop's gap in its place) under an emptied block already ends with the row spacing; the next section added a section gap on top of it. The layout now carries what follows a block: the row spacing under a bare block, nothing under its drop zone, a section gap under its groups. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-b6dd9baa949f76f9d9ab --- .../Sources/CmuxNextSidebar/Logic/SidebarLayout.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index 71fd057ffbbc..488ab73cef1b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -151,8 +151,9 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // first one keeps its empty placeholder, a drop target). let listsAnyMachineRow = sections.contains { $0.machine != nil && !$0.nodes.isEmpty } var previousWasMachine = false - // A block brings the band's own padding: the list's top padding goes after the blocks, as below the band. - var previousWasBlock = false + // After a block with no groups under it, what the next section adds: the row spacing under the block, or + // nothing under its drop zone (or the drop's gap there), whose row already ends with it. nil: a section gap. + var afterBlock: CGFloat? var shownEmptyMachine = false func openGapIfNeeded(section: SectionID, group: GroupID?, index: Int) { @@ -171,7 +172,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // Group by Status lays every computer's rows out once, in place of their sections (`SidebarLayout+Status`). if o.grouping.byStatus && !filtering && section.machine != nil { if !statusShown { rows += statusRows(sections, spaced: !firstSection, chats: &chats, options: o, metrics: m, y: &y) } - (statusShown, firstSection, previousWasMachine, previousWasBlock) = (true, false, false, false) + (statusShown, firstSection, previousWasMachine, afterBlock) = (true, false, false, nil) continue } // Nodes that remain, with their visible children. @@ -235,7 +236,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // Consecutive computers in one list read as one list: no gap. A block brings its own top padding; under // it the next row follows by the row spacing, as under any row (Leo 2026-10-21: no gap after the pins). if firstSection && blockHeight > 0 { y -= m.topPadding } - if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += previousWasBlock ? m.rowSpacing : m.sectionSpacing } + if !firstSection && !(flat && previousWasMachine) && blockHeight == 0 { y += afterBlock ?? m.sectionSpacing } firstSection = false if section.machine != nil { chats.placePinned(&y) } // This section's own rows under its block: its groups, or its empty drop zone (or the drop's gap in the zone's @@ -243,7 +244,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // below by exactly `gapShift`. let rowsUnderBlock = blockHeight > 0 && (!nodes.isEmpty || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) - previousWasBlock = blockHeight > 0 && !rowsUnderBlock + afterBlock = blockHeight > 0 && nodes.isEmpty ? (rowsUnderBlock ? 0 : m.rowSpacing) : nil // Only a headerless computer joins the next one: rows after a headed section (a remote group, // the Chief's) never read as its members. previousWasMachine = section.machine != nil && flat From e0f6eaba338ea65e75a66a59fe7bbc9ce1945dce Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 20:04:36 +0000 Subject: [PATCH 11/12] test(cmux-next sidebar): the drop zone of an untitled block and Group by Status keep the row spacing An emptied section without a title has a 0 pt block and its drop zone alone; Group by Status puts a section gap under the Pinned block or its drop zone. Both add a section gap where the row spacing belongs. Fails on the parent commit. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-b6dd9baa949f76f9d9ab --- .../SidebarUniformRowSpacingTests.swift | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift index 73d5822db677..9654a9fe78ad 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarUniformRowSpacingTests.swift @@ -110,13 +110,14 @@ struct SidebarUniformRowSpacingTests { #expect(abs(after.y - before.y - open.gapShift) < 0.01, "the rows below moved \(after.y - before.y) pt, the gap's shift is \(open.gapShift) pt") } - /// A drag that empties the Pinned block shows its drop zone under the block; the next row follows the zone (or - /// the drop's gap in its place) by the row spacing, not a section gap on top of it. - @Test(arguments: [false, true]) func theRowUnderThePinnedDropZoneKeepsTheRowSpacing(_ gapInZone: Bool) throws { + /// A drag that empties the Pinned block shows its drop zone under the block (or alone, for a section without a + /// title, whose block is then 0 pt tall); the next row follows the zone (or the drop's gap in its place) by the + /// row spacing, not a section gap on top of it. + @Test(arguments: [false, true], [CGFloat(0), 24]) func theRowUnderThePinnedDropZoneKeepsTheRowSpacing(_ gapInZone: Bool, _ blockHeight: CGFloat) throws { let m = SidebarLayoutMetrics.standard let pinnedID = SidebarLayoutDocument.pinnedSectionID.rawValue var options = SidebarLayoutOptions() - options.blockHeights = [pinnedID: 24] + options.blockHeights = [pinnedID: blockHeight] options.showEmptyPinned = true options.emptyDocSections = [pinnedID] if gapInZone { @@ -124,18 +125,34 @@ struct SidebarUniformRowSpacingTests { options.gapHeight = m.rowHeight } let layout = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) - let block = try #require(layout.row(for: Self.pinnedKey)) let zoneEnd: CGFloat if gapInZone { zoneEnd = try #require(layout.gapY) + m.rowHeight } else { zoneEnd = try #require(layout.row(for: .emptySection(.custom(pinnedID)))).maxY } - #expect(zoneEnd - m.rowSpacing >= block.maxY) + if blockHeight > 0 { + let block = try #require(layout.row(for: Self.pinnedKey)) + #expect(zoneEnd - m.rowSpacing >= block.maxY) + } let header = try #require(layout.row(for: .group(Self.group))) #expect(abs(header.y - zoneEnd - m.rowSpacing) < 0.01, "the drop zone's end adds \(header.y - zoneEnd - m.rowSpacing) pt") } + /// Group by Status lists the rows under the Pinned block (or its drop zone) by the row spacing too. + @Test(arguments: [false, true]) func theFirstStatusHeaderUnderThePinsKeepsTheRowSpacing(_ dropZone: Bool) throws { + let m = SidebarLayoutMetrics.standard + let pinnedID = SidebarLayoutDocument.pinnedSectionID.rawValue + var options = SidebarLayoutOptions() + options.grouping.setByStatus(true) + options.blockHeights = [pinnedID: 24] + if dropZone { (options.showEmptyPinned, options.emptyDocSections) = (true, [pinnedID]) } + let layout = SidebarLayout.make(sections: [Self.pinnedBlock(), Self.machine()], metrics: m, options: options) + let above = try #require(dropZone ? layout.row(for: .emptySection(.custom(pinnedID))) : layout.row(for: Self.pinnedKey)) + let header = try #require(layout.rows.first { if case .status = $0.key { true } else { false } }, "no status header") + #expect(abs(header.y - above.maxY - m.rowSpacing) < 0.01, "the pins' end adds \(header.y - above.maxY - m.rowSpacing) pt") + } + @Test func theFirstWorkspaceRowFollowsTheLastPinnedChat() throws { let m = SidebarLayoutMetrics.standard var options = SidebarLayoutOptions() From 06251dd5948606ebf251264818e936b66165791e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 26 Oct 2026 20:04:55 +0000 Subject: [PATCH 12/12] fix(cmux-next sidebar): row spacing under an untitled drop zone and above Group by Status A document section that ends in its drop zone (or the drop's gap) adds nothing more under it, with or without a block above the zone, and Group by Status takes the same spacing as any section under a block: the row spacing under a bare block, none under a drop zone. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-b6dd9baa949f76f9d9ab --- .../CmuxNextSidebar/Logic/SidebarLayout+Status.swift | 6 +++--- .../Sources/CmuxNextSidebar/Logic/SidebarLayout.swift | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift index e21a954c7b24..ddc03a573034 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout+Status.swift @@ -9,14 +9,14 @@ import Foundation /// Done shows under its state and a long section lists every row. nonisolated extension SidebarLayout { /// The rows of `sections`' computers (the pinned area and the document's sections lay out as before), from `y`; - /// `spaced` puts the section spacing above them. + /// `spacing` goes above them: the section spacing, the row spacing under a block, or none. static func statusRows( - _ sections: [SidebarSection], spaced: Bool, chats: inout SidebarChatHistoryPlacement, + _ sections: [SidebarSection], spacing: CGFloat, chats: inout SidebarChatHistoryPlacement, options o: SidebarLayoutOptions, metrics m: SidebarLayoutMetrics, y: inout CGFloat ) -> [SidebarRow] { let machines = sections.filter { $0.machine != nil } guard let first = machines.first else { return [] } - if spaced { y += m.sectionSpacing } + y += spacing chats.placePinned(&y) var members: [SidebarStatusSection: [(ws: SidebarWorkspace, section: SidebarSection, index: Int)]] = [:] for section in machines { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index 488ab73cef1b..49d9d29956ce 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -151,7 +151,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // first one keeps its empty placeholder, a drop target). let listsAnyMachineRow = sections.contains { $0.machine != nil && !$0.nodes.isEmpty } var previousWasMachine = false - // After a block with no groups under it, what the next section adds: the row spacing under the block, or + // After a document section with no groups, what the next section adds: the row spacing under its block, or // nothing under its drop zone (or the drop's gap there), whose row already ends with it. nil: a section gap. var afterBlock: CGFloat? var shownEmptyMachine = false @@ -171,7 +171,7 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { if let lastMachine, sectionIndex > lastMachine { chats.placeEarlier(&y) } // Group by Status lays every computer's rows out once, in place of their sections (`SidebarLayout+Status`). if o.grouping.byStatus && !filtering && section.machine != nil { - if !statusShown { rows += statusRows(sections, spaced: !firstSection, chats: &chats, options: o, metrics: m, y: &y) } + if !statusShown { rows += statusRows(sections, spacing: firstSection ? 0 : afterBlock ?? m.sectionSpacing, chats: &chats, options: o, metrics: m, y: &y) } (statusShown, firstSection, previousWasMachine, afterBlock) = (true, false, false, nil) continue } @@ -244,7 +244,9 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { // below by exactly `gapShift`. let rowsUnderBlock = blockHeight > 0 && (!nodes.isEmpty || (emptyDropZone && docID.map(o.emptyDocSections.contains) == true)) - afterBlock = blockHeight > 0 && nodes.isEmpty ? (rowsUnderBlock ? 0 : m.rowSpacing) : nil + // An untitled section emptied by a drag has a 0 pt block and its drop zone (or the gap) alone. + let endsInZone = isDocSection && nodes.isEmpty && (blockHeight == 0 || rowsUnderBlock) + afterBlock = endsInZone ? 0 : blockHeight > 0 && nodes.isEmpty ? m.rowSpacing : nil // Only a headerless computer joins the next one: rows after a headed section (a remote group, // the Chief's) never read as its members. previousWasMachine = section.machine != nil && flat