Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
649f602
test: catch overlapping Cloud unread indicators
austinywang Sep 21, 2026
a4acf3f
fix: separate Cloud unread indicators from row icons
austinywang Sep 21, 2026
05ba773
fix: keep Cloud attention indicators in the leading slot
austinywang Sep 22, 2026
a49053c
Merge origin/main into 13366-cloud-notification-layout
austinywang Sep 22, 2026
15b4b5d
ci: keep Cloud layout suites in the strict macOS gate
austinywang Sep 22, 2026
20d4e69
test: align compact Cloud spacing with attention slots
austinywang Sep 22, 2026
696afe4
test: account for clipped narrow Cloud rows
austinywang Sep 22, 2026
f5c168d
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 13366…
austinywang Sep 22, 2026
84303f0
fix: avoid empty Cloud attention space until unread
austinywang Sep 23, 2026
d454a9c
fix: import Cloud font scaling support
austinywang Sep 23, 2026
403a815
Merge remote-tracking branch 'origin/main' into 13366-cloud-notificat…
austinywang Sep 23, 2026
ab0d82a
fix: remove empty Cloud notification gutter
austinywang Sep 23, 2026
8b71061
test: measure Cloud machine title columns robustly
austinywang Sep 23, 2026
5985511
test: scope title raster workaround to ledger rows
austinywang Sep 23, 2026
efe16fa
test: measure rendered Cloud row geometry without style heuristics
austinywang Sep 23, 2026
ac3f20c
Merge remote-tracking branch 'origin/main' into 13366-cloud-notificat…
austinywang Sep 23, 2026
0ce01ea
Merge remote-tracking branch 'origin/main' into 13366-cloud-notificat…
austinywang Sep 23, 2026
368c1fb
Merge remote-tracking branch 'origin/main' into 13366-cloud-notificat…
austinywang Sep 23, 2026
156cce4
Merge remote-tracking branch 'origin/main' into 13366-cloud-notificat…
austinywang Sep 23, 2026
f64ccb6
test: cover Cloud notification layout at minimum font magnification
austinywang Sep 23, 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
7 changes: 6 additions & 1 deletion Sources/Cloud/CloudTreeCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ final class CloudTreeCellView: NSTableCellView {
displayHost.isHidden = false
displayHost.rootView = AnyView(
CloudTreeRowContentView(kind: node.kind, style: style)
.modifier(CloudSidebarRowDecoration(isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention))
.modifier(CloudSidebarRowDecoration(
isPinned: node.isPinned,
showsAttentionSlot: node.showsAttentionSlot,
hasUnreadNotification: node.hasUnreadAttention,
attentionSlot: style.rowGrid.attentionSlot
))
.frame(maxWidth: .infinity, alignment: .leading)
)
// An in-place row reload reuses this cell; the new content can be wider
Expand Down
2 changes: 2 additions & 0 deletions Sources/Cloud/CloudTreeRowGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import CoreGraphics
struct CloudTreeRowGrid: Equatable, Sendable {
var disclosureSlot: CGFloat = 16
var disclosureGap: CGFloat = 2
/// Width of the leading unread-indicator column on rows that can carry attention.
var attentionSlot: CGFloat = 12
var dotGap: CGFloat = 4
var detailGap: CGFloat = 5
var trailingGap: CGFloat = 10
Expand Down
35 changes: 17 additions & 18 deletions Sources/Cloud/Sidebar/CloudSidebarRowDecoration.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import CmuxFoundation
import SwiftUI

/// An optional leading pin and an unread badge over the icon, with no empty
/// leading column. Read/unread changes never move the row's icon or title.
/// An unread badge in the leading identity column, followed by an optional pin.
/// Read rows keep the compact identity edge; unread rows reserve the badge slot.
/// Immutable input keeps AppKit cell reuse independent of observable stores.
struct CloudSidebarRowDecoration: ViewModifier {
let isPinned: Bool
let showsAttentionSlot: Bool
let hasUnreadNotification: Bool
var attentionSlot: CGFloat = CloudTreeStyle.compact.rowGrid.attentionSlot
@Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification

func body(content: Content) -> some View {
// Keep the pin in the same compact leading cluster as the row icon.
// Four points made the unread badge spill past the narrow sidebar's
// identity column; two points matches the tree's shared gaps.
// Keep read rows flush with the outline's content edge. A row earns the
// leading slot only while it has unread attention, so the compact tree
// does not carry an empty gutter between the caret and its identity.
HStack(spacing: 2) {
if showsAttentionSlot && hasUnreadNotification {
Circle()
.fill(Color.accentColor)
.frame(width: 6, height: 6)
.accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification"))
.help(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification"))
.frame(width: GlobalFontMagnification.scaledSize(attentionSlot, percent: magnification))
.allowsHitTesting(false)
}
if isPinned {
CmuxSystemSymbolImage(
magnified: "pin.fill",
Expand All @@ -24,19 +36,6 @@ struct CloudSidebarRowDecoration: ViewModifier {
.accessibilityLabel(String(localized: "taskManager.row.pinned", defaultValue: "Pinned"))
}
content
.overlay(alignment: .topLeading) {
if showsAttentionSlot {
Circle()
.fill(Color.accentColor)
.frame(width: 6, height: 6)
.opacity(hasUnreadNotification ? 1 : 0)
.accessibilityHidden(!hasUnreadNotification)
.accessibilityLabel(String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification"))
.help(hasUnreadNotification
? String(localized: "cloudTree.organization.unread", defaultValue: "Unread notification") : "")
.allowsHitTesting(false)
}
}
}
}
}
6 changes: 3 additions & 3 deletions Sources/Cloud/Sidebar/CloudTreeNode+Organization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ extension CloudTreeNode {

var showsAttentionSlot: Bool {
switch kind {
// Surface, resource, and empty-state rows share a leading attention
// column so every nested row uses the same horizontal rhythm.
case .workspace, .localWorkspace, .terminal, .display, .browser, .port, .resource, .placeholder: return true
// Only rows that can carry the Cloud unread projection reserve the
// leading slot. Other nested rows keep their compact identity edge.
case .workspace, .terminal: return true
default: return false
}
}
Expand Down
9 changes: 5 additions & 4 deletions cmuxTests/CloudSidebarAcceptanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct CloudSidebarAcceptanceTests {
@Test func compactGeometry() throws {
let geometry = CloudTreeCompactLayoutTests()
for width in [220.0, 360.0] {
for percent in [100, 150] { try geometry.compactRows(width: width, percent: percent) }
for percent in [50, 100, 150] { try geometry.compactRows(width: width, percent: percent) }
}
let layout = CloudTreeWorkspaceTitleLayoutTests()
try layout.disclosureSpacing(style: .compact)
Expand All @@ -25,8 +25,8 @@ struct CloudSidebarAcceptanceTests {
@Test func pinsAndUnreadState() throws {
let pins = CloudSidebarPinGeometryTests()
for width in [220.0, 380.0] { try pins.machinePinRepaintsImmediately(width: width) }
for percent in [75, 100, 150, 200] {
try pins.noEmptyAttentionGutter(percent: percent)
for percent in [50, 75, 100, 150, 200] {
try pins.attentionSlotPrecedesContent(percent: percent)
for width in [100.0, 320.0] { try pins.leadingPin(width: width, percent: percent) }
}
try pins.pinMagnification()
Expand All @@ -36,8 +36,9 @@ struct CloudSidebarAcceptanceTests {
}
let attention = CloudSidebarAttentionLayoutTests()
for width in [140.0, 300.0] {
for kind in ["workspace", "terminal"] { try attention.attentionPrecedesIcon(width: width, kind: kind) }
for kind in ["workspace", "terminal"] { try attention.attentionPlacement(width: width, kind: kind) }
}
for width in [220.0, 380.0] { try attention.outlineAttentionTransitions(width: width) }
try attention.collapsedFolderAttention()
Comment thread
austinywang marked this conversation as resolved.
try attention.collapsedFolderIsInvalidatedByDescendantReadChanges()
}
Expand Down
158 changes: 134 additions & 24 deletions cmuxTests/CloudSidebarAttentionLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxFoundation
import Testing
#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand All @@ -7,11 +8,25 @@ import Testing
#endif

@MainActor
@Suite("Cloud sidebar attention layout")
@Suite("Cloud sidebar attention layout", .serialized)
struct CloudSidebarAttentionLayoutTests {
@Test("Read and unread rows differ only in the icon badge, even pinned and narrow",
@Test("Unread indicators use a leading slot while read rows stay compact",
arguments: [140.0, 300.0], ["workspace", "terminal"])
func attentionPrecedesIcon(width: Double, kind: String) throws {
func attentionPlacement(width: Double, kind: String) throws {
for percent in [50, 75, 100, 150, 200] {
for pinned in [false, true] {
try attentionPlacement(width: width, kind: kind, percent: percent, pinned: pinned)
}
}
}

private func attentionPlacement(width: Double, kind: String, percent: Int, pinned: Bool) throws {
let oldPercent = UserDefaults.standard.object(forKey: GlobalFontMagnification.percentKey)
UserDefaults.standard.set(percent, forKey: GlobalFontMagnification.percentKey)
defer {
if let oldPercent { UserDefaults.standard.set(oldPercent, forKey: GlobalFontMagnification.percentKey) }
else { UserDefaults.standard.removeObject(forKey: GlobalFontMagnification.percentKey) }
}
let fixture = CloudSidebarOrderingFixture()
defer { fixture.close() }
fixture.coordinator.apply(nodes: fixture.nodes())
Expand All @@ -20,38 +35,132 @@ struct CloudSidebarAttentionLayoutTests {
let unreadNode = try #require(CloudTreeNodeBuilder.flattened(fixture.nodes(unread: ["term_ws_1"]))
.first { $0.id == readNode.id })
let cell = try #require(outline.view(atColumn: 0, row: outline.row(forItem: readNode), makeIfNecessary: true) as? CloudTreeCellView)
let host = NSView(frame: NSRect(x: 0, y: 0, width: width, height: 40))
let height = CloudTreeStyle.compact.rowHeight * Double(percent) / 100
let host = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height))
let window = NSWindow(contentRect: host.frame, styleMask: [], backing: .buffered, defer: false)
window.contentView = host
defer { window.contentView = nil }
cell.removeFromSuperview()
cell.frame = host.bounds
host.addSubview(cell)
readNode.isPinned = true
unreadNode.isPinned = true
readNode.isPinned = pinned
unreadNode.isPinned = pinned
let read = try render(cell, node: readNode, fixture: fixture)
let unread = try render(cell, node: unreadNode, fixture: fixture)
#if compiler(>=6.2)
let name = "attention-\(kind)-\(Int(width))-\(percent)-pinned-\(pinned)"
Attachment.record(try #require(read.representation(using: .png, properties: [:])), named: name + "-read.png")
Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: name + "-unread.png")
#endif
try expectSeparateIndicator(read: read, unread: unread, in: cell, percent: percent)
cell.prepareForReuse()
let cleared = try render(cell, node: readNode, fixture: fixture)
#expect(cleared.tiffRepresentation == read.tiffRepresentation,
"A reused cell must remove the dot and restore the compact read layout")
}

private func expectSeparateIndicator(
read: NSBitmapImageRep, unread: NSBitmapImageRep, in cell: CloudTreeCellView, percent: Int
) throws {
#expect(read.pixelsWide == unread.pixelsWide)
#expect(read.pixelsHigh == unread.pixelsHigh)
var changedX: [Int] = []
for y in 0..<min(read.pixelsHigh, unread.pixelsHigh) {
for x in 0..<min(read.pixelsWide, unread.pixelsWide) {
let a = try #require(read.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
let b = try #require(unread.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
let difference = abs(a.redComponent - b.redComponent) + abs(a.greenComponent - b.greenComponent)
+ abs(a.blueComponent - b.blueComponent) + abs(a.alphaComponent - b.alphaComponent)
if difference > 0.15 { changedX.append(x) }
let scale = CGFloat(unread.pixelsWide) / cell.bounds.width
let readRuns = occupiedRuns(in: read)
let unreadRuns = occupiedRuns(in: unread)
let leadingSlot = GlobalFontMagnification.scaledSize(
CloudTreeStyle.compact.rowGrid.attentionSlot, percent: percent
)
let readStart = CGFloat(try #require(readRuns.first?.lowerBound)) / scale
let unreadDot = try #require(unreadRuns.first)
let unreadContent = try #require(unreadRuns.dropFirst().first)
#expect(readStart <= 8, "Read rows keep their compact leading edge: \(readStart)")
let dotCenter = CGFloat(unreadDot.lowerBound + unreadDot.upperBound) / (2 * scale)
#expect(abs(dotCenter - leadingSlot / 2) <= 1,
"The unread dot is centered in the leading slot at every font scale")
#expect(CGFloat(unreadDot.count) / scale >= 5 && CGFloat(unreadDot.count) / scale <= 7,
"The unread dot remains six points wide")
let contentShift = CGFloat(unreadContent.lowerBound) / scale - readStart
#expect(abs(contentShift - leadingSlot - 2) <= 1,
"Only unread rows add the leading slot and its gap: \(contentShift)")
#expect(CGFloat(unreadContent.lowerBound - unreadDot.upperBound) / scale >= 2,
"The dot stays separate from the pin or icon")
let dotRows = (0..<unread.pixelsHigh).filter { y in
unreadDot.contains { x in (unread.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2 }
}
let top = try #require(dotRows.first)
let bottom = try #require(dotRows.last) + 1
#expect(abs(CGFloat(top + bottom - unread.pixelsHigh) / 2) <= scale,
"The dot stays vertically centered instead of overlapping the icon's upper corner")
#expect(CGFloat(bottom - top) / scale >= 5 && CGFloat(bottom - top) / scale <= 7,
"The complete six-point dot remains visible")
}

private func occupiedRuns(in bitmap: NSBitmapImageRep) -> [Range<Int>] {
var runs: [Range<Int>] = []
var start: Int?
for x in 0..<bitmap.pixelsWide {
let occupied = (0..<bitmap.pixelsHigh).contains { y in
(bitmap.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.2
}
if occupied {
if start == nil { start = x }
} else if let first = start {
runs.append(first..<x)
start = nil
}
}
let right = try #require(changedX.max(), "The unread indicator must actually render")
let scale = Double(unread.pixelsWide) / width
#expect(Double(right) / scale < 20,
"The badge stays over the leading icon; pin, title and trailing controls cannot shift")
let cleared = try render(cell, node: readNode, fixture: fixture)
#expect(cleared.tiffRepresentation == read.tiffRepresentation)
#if compiler(>=6.2)
Attachment.record(try #require(unread.representation(using: .png, properties: [:])), named: "leading-dot-\(kind)-\(Int(width)).png")
#endif
if let first = start { runs.append(first..<bitmap.pixelsWide) }
return runs
}

@Test("The real outline repaints unread and cleared rows without changing disclosure geometry",
arguments: [220.0, 380.0])
func outlineAttentionTransitions(width: Double) throws {
let fixture = CloudSidebarOrderingFixture()
defer { fixture.close() }
fixture.window.setContentSize(NSSize(width: width, height: 560))
fixture.coordinator.apply(style: .compact)
fixture.coordinator.apply(nodes: fixture.nodes())
let outline = try #require(fixture.coordinator.outlineView)
outline.expandItem(nil, expandChildren: true)
let rows = CloudTreeNodeBuilder.flattened(fixture.coordinator.nodes).filter {
$0.structureTag == "workspace" || $0.structureTag == "terminal"
}
for pinned in [false, true] {
if pinned {
for node in rows { #expect(fixture.coordinator.organize(.pin, nodeID: node.id)) }
}
let indexes = try rows.map { node -> Int in
let row = outline.row(forItem: node)
try #require(row >= 0)
return row
}
let disclosure = indexes.map { outline.frameOfOutlineCell(atRow: $0) }
let read = try indexes.map { try captureRow($0, in: outline) }
fixture.coordinator.apply(nodes: fixture.nodes(unread: ["term_ws_1", "term_ws_2"]))
try fixture.attachScreenshot(named: "outline-unread-\(Int(width))-pinned-\(pinned)")
for (index, row) in indexes.enumerated() {
let unread = try captureRow(row, in: outline)
let cell = try #require(outline.view(atColumn: 0, row: row, makeIfNecessary: true) as? CloudTreeCellView)
try expectSeparateIndicator(read: read[index], unread: unread, in: cell, percent: 100)
#expect(outline.frameOfOutlineCell(atRow: row) == disclosure[index])
}
fixture.coordinator.apply(nodes: fixture.nodes())
for (index, row) in indexes.enumerated() {
let cleared = try captureRow(row, in: outline)
#expect(cleared.tiffRepresentation == read[index].tiffRepresentation)
}
try fixture.attachScreenshot(named: "outline-cleared-\(Int(width))-pinned-\(pinned)")
}
}

private func captureRow(_ row: Int, in outline: CloudTreeNSOutlineView) throws -> NSBitmapImageRep {
let cell = try #require(outline.view(atColumn: 0, row: row, makeIfNecessary: true) as? CloudTreeCellView)
cell.setHovered(true)
cell.layoutSubtreeIfNeeded()
let bitmap = try #require(cell.bitmapImageRepForCachingDisplay(in: cell.bounds))
cell.cacheDisplay(in: cell.bounds, to: bitmap)
return bitmap
}

@Test("Collapsed folders retain descendant attention and hover controls at narrow widths")
Expand Down Expand Up @@ -101,7 +210,8 @@ struct CloudSidebarAttentionLayoutTests {
}

private func render(_ cell: CloudTreeCellView, node: CloudTreeNode, fixture: CloudSidebarOrderingFixture) throws -> NSBitmapImageRep {
cell.configure(node: node, machineActions: fixture.coordinator.machineActions, nodeActions: fixture.coordinator.nodeActions)
cell.configure(node: node, machineActions: fixture.coordinator.machineActions,
nodeActions: fixture.coordinator.nodeActions, style: .compact)
cell.layoutSubtreeIfNeeded()
cell.displayIfNeeded()
let bitmap = try #require(cell.bitmapImageRepForCachingDisplay(in: cell.bounds))
Expand Down
Loading
Loading