From fda9370109e8e52d7cd5a145ef334917eb3f441a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 22:23:51 -0700 Subject: [PATCH 01/11] test: rows and tabs show one status icon on the Debug side Red: with Debug Settings sidebar.rowBadge on the new Status icon style, a sidebar row still draws its kind icon and corner badge, a tab keeps its mark on the icon's corner, and sidebar.statusIconSide is not a Debug choice yet. SidebarRowBadgeStyle moves to CmuxNextDesign so tabs can follow it. Co-Authored-By: Claude Opus 5.5 --- .../SidebarRowBadgeStyle.swift | 32 +++++ .../SidebarStatusIconSide.swift | 19 +++ .../StatusIndicatorAppearance.swift | 7 +- ...geStyle.swift => WorkspaceRowBadges.swift} | 30 +--- .../Store/SidebarTunablesWatch.swift | 1 + .../StatusIconSideTests.swift | 131 ++++++++++++++++++ 6 files changed, 190 insertions(+), 30 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarStatusIconSide.swift rename Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/{SidebarRowBadgeStyle.swift => WorkspaceRowBadges.swift} (73%) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift new file mode 100644 index 000000000000..07652176435b --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift @@ -0,0 +1,32 @@ +/// How a workspace row shows what waits in it (Debug Settings `sidebar.rowBadge`). Leo and +/// Lawrence 2026-10-14: no count numbers on NEXT rows. The default follows T3 Code's thread list: +/// the row leads with its harness logo or kind icon, and one small badge on that icon's corner says +/// the most urgent state; the count reads in the hover card. `stack` is Leo's alternative, one +/// glyph per pending item overlapping in the trailing slot; `trailing` keeps the count badge and +/// the trailing status glyph (the look before); `status` drops the kind icon and badge for one +/// status icon on a side, and tabs follow it (`StatusIndicatorConfig.statusSide`). +public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Hashable, TunableChoice { + /// One badge on the leading icon's bottom-right corner. + case corner + /// Overlapping glyphs in the trailing slot, one per pending item, up to `SidebarRowBadgeStackCap`. + case stack + /// The count badge and the trailing status glyph. + case trailing + /// No kind icon and no badge: one status icon (the most urgent state) on the side + /// `SidebarStatusIconSide` names; tabs draw theirs the same way (Leo and Lawrence 2026-10-14). + case status + + public var tunableTitle: String { + switch self { + case .corner: "Corner badge on the icon" + case .stack: "Glyph stack (one per item)" + case .trailing: "Count and trailing glyph (before)" + case .status: "Status icon on the left or right" + } + } + + public static let tunable = Tunable.choice( + "sidebar.rowBadge", .sidebar, "Row badge", + help: "How a workspace row shows unread items and agent states: a badge on the leading icon's corner, a stack of glyphs, the count with a trailing glyph, or one status icon on the side Status Icon Side names, on rows and tabs alike.", + default: .corner, code: "SidebarRowBadgeStyle.tunable") +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarStatusIconSide.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarStatusIconSide.swift new file mode 100644 index 000000000000..02e4ee3841b9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarStatusIconSide.swift @@ -0,0 +1,19 @@ +/// The side of a workspace row and a tab its status icon sits on when the row badge style is +/// `status` (Debug Settings `sidebar.statusIconSide`; Leo and Lawrence 2026-10-14). A Debug choice +/// until it is promoted to Settings. +public nonisolated enum SidebarStatusIconSide: String, Hashable, Sendable, CaseIterable, TunableChoice { + case left + case right + + public var tunableTitle: String { + switch self { + case .left: "Left" + case .right: "Right" + } + } + + public static let tunable = Tunable.choice( + "sidebar.statusIconSide", .sidebar, "Status icon side", + help: "With the Status icon row badge, which side of a sidebar row and a tab shows its status icon (working, needs input, done, error).", + default: .left, code: "SidebarStatusIconSide.tunable") +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift index 7dcdb5199929..9aa36a88022f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift @@ -27,11 +27,15 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { public var terminalFontFamily: String? /// The status icon set (Debug Settings `status.iconSet`, cx-kxa2). public var iconSet: StatusIconSet + /// The side tabs draw their status mark on when rows show a status icon (`SidebarRowBadgeStyle.status`, + /// Debug Settings `sidebar.statusIconSide`); nil keeps the mark on the icon's corner. + public var statusSide: SidebarStatusIconSide? public init(settings: StatusIndicatorSettings = StatusIndicatorSettings(), styleOverride: StatusIndicatorStyle? = nil, arcLength: Double = 0.72, trackOpacity: Double = 0.22, dotScale: Double = 0.5, pulseLow: Double = 0.35, nativeSteps: Int = 8, animatesLoops: Bool = true, spinnerPeriod: Double? = nil, pulsePeriod: Double? = nil, - terminalFontFamily: String? = nil, iconSet: StatusIconSet = .current) { + terminalFontFamily: String? = nil, iconSet: StatusIconSet = .current, + statusSide: SidebarStatusIconSide? = nil) { self.settings = settings self.styleOverride = styleOverride self.arcLength = arcLength @@ -44,6 +48,7 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { self.pulsePeriod = pulsePeriod self.terminalFontFamily = terminalFontFamily self.iconSet = iconSet + self.statusSide = statusSide } /// The style for a report that asked for `hint`: the Debug Settings diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowBadgeStyle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift similarity index 73% rename from Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowBadgeStyle.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift index 067a17ac4617..a692b2012955 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRowBadgeStyle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift @@ -1,32 +1,4 @@ -public import CmuxNextDesign - -/// How a workspace row shows what waits in it (Debug Settings `sidebar.rowBadge`). Leo and -/// Lawrence 2026-10-14: no count numbers on NEXT rows. The default follows T3 Code's thread list: -/// the row leads with its harness logo or kind icon, and one small badge on that icon's corner says -/// the most urgent state; the count reads in the hover card. `stack` is Leo's alternative, one -/// glyph per pending item overlapping in the trailing slot; `trailing` keeps the count badge and -/// the trailing status glyph (the look before). -public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Hashable, TunableChoice { - /// One badge on the leading icon's bottom-right corner. - case corner - /// Overlapping glyphs in the trailing slot, one per pending item, up to `SidebarRowBadgeStackCap`. - case stack - /// The count badge and the trailing status glyph. - case trailing - - public var tunableTitle: String { - switch self { - case .corner: "Corner badge on the icon" - case .stack: "Glyph stack (one per item)" - case .trailing: "Count and trailing glyph (before)" - } - } - - public static let tunable = Tunable.choice( - "sidebar.rowBadge", .sidebar, "Row badge", - help: "How a workspace row shows unread items and agent states: a badge on the leading icon's corner, a stack of glyphs, or the count with a trailing glyph.", - default: .corner, code: "SidebarRowBadgeStyle.tunable") -} +import CmuxNextDesign /// A row's pending items, most urgent first: a failure, then a permission (or sign-in) request, /// then a question, then work in progress, then each unread item (done). The corner badge draws diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift index 26c4053a29f7..c04e7093cdf8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextDesign import Observation /// The sidebar tunables workspace rows draw from (agent mark, presence dots, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift new file mode 100644 index 000000000000..1f5a0ae0dab9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -0,0 +1,131 @@ +import AppKit +@testable import CmuxNextDesign +@testable import CmuxNextSidebar +@testable import CmuxNextTabs +import Testing + +/// Leo and Lawrence's team call (2026-10-14): no kind icon and no corner badge on a sidebar row; +/// one status icon (working, needs input, done, error) on the left or the right of the row, and +/// tabs the same so rows and tabs match. Both are Debug Settings choices until promoted: the +/// `sidebar.rowBadge` Status icon style, and `sidebar.statusIconSide` for its side. +@MainActor @Suite(.serialized) struct StatusIconSideTests { + private static let busy = WorkspaceID("ws-status-busy") + private static let done = WorkspaceID("ws-status-done") + + // MARK: Debug Settings + + @Test func debugSettingsListsTheSideNextToTheRowBadgeAndTodaysLookStays() { + let keys = SidebarTunables.all.map(\.key) + #expect(keys.contains(SidebarStatusIconSide.tunable.key)) + #expect(SidebarStatusIconSide.tunable.defaultValue == .left) + #expect(SidebarRowBadgeStyle.allCases.contains(.status)) + #expect(SidebarRowBadgeStyle.tunable.defaultValue == .corner) + } + + @Test(arguments: [SidebarStatusIconSide.left, .right]) + func tabsFollowTheRowsStatusLook(_ side: SidebarStatusIconSide) throws { + try Self.withStatusLook(side) { + #expect(StatusIndicatorConfig.current.statusSide == side) + } + #expect(StatusIndicatorConfig.current.statusSide == nil, "the corner badge keeps tabs on the corner") + } + + // MARK: Sidebar rows + + @Test(arguments: [SidebarStatusIconSide.left, .right]) + func aRowShowsOneStatusIconOnTheSettingsSideAndNoKindIcon(_ side: SidebarStatusIconSide) throws { + try Self.withStatusLook(side) { + let sidebar = Self.sidebar() + let row = try Self.row(sidebar, Self.busy) + #expect(row.icon.isHidden, "no kind icon") + #expect(row.rowBadge.isHidden, "no corner badge") + #expect(!row.activity.isHidden && row.activity.state == .error, "the most urgent state: \(row.activity.state)") + let title = row.title.frame.minX + WorkspaceRowView.labelInset + switch side { + case .left: + #expect(row.activity.frame.maxX <= title, "the icon \(row.activity.frame) sits before the title at \(title)") + case .right: + #expect(row.activity.frame.minX > row.bounds.midX, "the icon \(row.activity.frame) sits on the right") + } + } + } + + @Test func unreadWorkShowsTheDoneIcon() throws { + try Self.withStatusLook(.left) { + let row = try Self.row(Self.sidebar(), Self.done) + #expect(row.activity.state == .success) + #expect(row.badge.isHidden, "the unread count gives way to the done icon") + } + } + + @Test func leftRowsKeepTheirTitlesOnOneColumn() throws { + try Self.withStatusLook(.left) { + let sidebar = Self.sidebar() + let busy = try Self.row(sidebar, Self.busy), quiet = try Self.row(sidebar, WorkspaceID("ws-status-quiet")) + #expect(abs(busy.title.frame.minX - quiet.title.frame.minX) < 0.5) + } + } + + // MARK: Tabs + + @Test(arguments: [SidebarStatusIconSide.left, .right]) + func aTabShowsItsStatusIconOnTheSameSide(_ side: SidebarStatusIconSide) throws { + try Self.withStatusLook(side) { + let cell = TabCell(item: TabItem(id: TabID("t"), title: "build", status: .needsInput)) + cell.frame = CGRect(x: 0, y: 0, width: 220, height: 30) + cell.layoutLayers() + let glyph = try #require(cell.statusGlyphLayer, "a needs-input tab draws a status icon") + #expect(cell.badgeLayer == nil, "no dot on the icon's corner") + let icon = cell.iconLayer.frame + switch side { + case .left: + #expect(glyph.frame.maxX <= icon.minX, "the icon \(glyph.frame) sits before the tab icon \(icon)") + case .right: + #expect(glyph.frame.minX >= icon.maxX + Metrics.space2, "the icon \(glyph.frame) sits after the title, not on \(icon)") + } + } + } + + // MARK: Fixtures + + /// Runs `body` with the Status icon row badge on `side`, then restores today's look. The store is + /// active without a file, so nothing is written; the tunables watch and the status config are + /// refreshed right away, as the app does one main-actor hop later. + private static func withStatusLook(_ side: SidebarStatusIconSide, _ body: () throws -> Void) throws { + TunableStore.shared.activate(file: nil) + TunableStore.shared.register(SidebarTunables.all) + let style = SidebarRowBadgeStyle.tunable.key, sideKey = SidebarStatusIconSide.tunable.key + try #require(TunableStore.shared.set(style, SidebarRowBadgeStyle.status.tunableValue) == SidebarRowBadgeStyle.status.tunableValue) + try #require(TunableStore.shared.set(sideKey, side.tunableValue) == side.tunableValue) + SidebarTunablesWatch.shared.changed() + StatusIndicatorAppearance.shared.apply(StatusIndicatorConfig.current) + defer { + TunableStore.shared.set(style, nil) + TunableStore.shared.set(sideKey, nil) + SidebarTunablesWatch.shared.changed() + StatusIndicatorAppearance.shared.apply(StatusIndicatorConfig.current) + } + try body() + } + + /// A failing row with unread items, a row with unread done work, and a quiet row. + private static func sidebar() -> SidebarView { + let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) + let rows = [ + CmuxNextSidebar.SidebarWorkspace(id: busy, title: "api", kind: .terminal, unread: .count(2), activity: .error), + CmuxNextSidebar.SidebarWorkspace(id: done, title: "web", kind: .terminal, unread: .count(1)), + CmuxNextSidebar.SidebarWorkspace(id: WorkspaceID("ws-status-quiet"), title: "docs", kind: .terminal), + ] + let model = SidebarModel(sections: [CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: rows.map { .workspace($0) })]) + let sidebar = SidebarView(model: model, design: DesignSettings()) + sidebar.frame = NSRect(x: 0, y: 0, width: 260, height: 600) + sidebar.layoutSubtreeIfNeeded() + sidebar.list.reload(animated: false) + sidebar.list.rowViews.values.forEach { $0.layoutSubtreeIfNeeded() } + return sidebar + } + + private static func row(_ sidebar: SidebarView, _ id: WorkspaceID) throws -> WorkspaceRowView { + try #require(sidebar.list.rowViews[.workspace(id)] as? WorkspaceRowView) + } +} From e66d98db0b257b2ed949da1c5c101136ce0558d0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 22:23:54 -0700 Subject: [PATCH 02/11] feat: a Status icon row badge on the left or right, tabs to match Debug Settings sidebar.rowBadge gains Status icon: a row drops its kind icon and badge for one status icon (its most urgent state), on the side the new Debug choice sidebar.statusIconSide names (left or right, default left). Tabs follow it: the mark sits before the icon or at the title's end. The corner badge stays the default. Co-Authored-By: Claude Opus 5.5 --- .../StatusIndicatorAppearance.swift | 3 +- .../Logic/SidebarTunables.swift | 2 +- .../Store/SidebarTunablesWatch.swift | 8 +-- .../Views/WorkspaceRowView+Badges.swift | 7 +++ .../Views/WorkspaceRowView+StatusGlyph.swift | 22 +++++++++ .../Views/WorkspaceRowView.swift | 29 +++++------ .../CmuxNextTabs/TabCell+StatusBadge.swift | 49 ++++++++++++++++--- .../Sources/CmuxNextTabs/TabCell.swift | 4 +- 8 files changed, 93 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift index 9aa36a88022f..b006785a0445 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift @@ -71,7 +71,8 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { spinnerPeriod: Motion.period(.spinner), pulsePeriod: Motion.period(.pulse), terminalFontFamily: DesignSettings.shared.terminalFontFamily, - iconSet: StatusIconSet.tunable.value) + iconSet: StatusIconSet.tunable.value, + statusSide: SidebarRowBadgeStyle.tunable.value == .status ? SidebarStatusIconSide.tunable.value : nil) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarTunables.swift index d33d4e396e3b..c4652df6764b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarTunables.swift @@ -44,6 +44,6 @@ public nonisolated enum SidebarTunables { default: false, code: "SidebarTunables.rowBadgePreview") public static var all: [TunableDescriptor] { - [groupEdgeFraction, groupExitFraction, sectionTopFraction, workspaceOntoStart, workspaceOntoEnd].map(\.descriptor) + [agentMark.descriptor, presenceDots.descriptor, SidebarRowBadgeStyle.tunable.descriptor, SidebarRowBadgeStackCap.tunable.descriptor, rowBadgePreview.descriptor, NotificationBadgeLook.tunable.descriptor, NotificationIconLook.tunable.descriptor, updateCardButton.descriptor, SidebarChatsDesign.tunable.descriptor] + SidebarSectionTunables.all + [groupEdgeFraction, groupExitFraction, sectionTopFraction, workspaceOntoStart, workspaceOntoEnd].map(\.descriptor) + [agentMark.descriptor, presenceDots.descriptor, SidebarRowBadgeStyle.tunable.descriptor, SidebarStatusIconSide.tunable.descriptor, SidebarRowBadgeStackCap.tunable.descriptor, rowBadgePreview.descriptor, NotificationBadgeLook.tunable.descriptor, NotificationIconLook.tunable.descriptor, updateCardButton.descriptor, SidebarChatsDesign.tunable.descriptor] + SidebarSectionTunables.all } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift index c04e7093cdf8..bc81aeeb055c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Store/SidebarTunablesWatch.swift @@ -3,7 +3,7 @@ import CmuxNextDesign import Observation /// The sidebar tunables workspace rows draw from (agent mark, presence dots, -/// row badge, stack size), +/// row badge, stack size, the status icon's side), /// observed once for every row. Each `WorkspaceRowView.configure` used to arm /// its own observation, so every status tick left one more registration /// behind; now a change redraws the live rows from one registration. @@ -11,7 +11,8 @@ import Observation final class SidebarTunablesWatch { static let shared = SidebarTunablesWatch() - typealias Values = (agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackCap: SidebarRowBadgeStackCap) + typealias Values = (agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackCap: SidebarRowBadgeStackCap, + statusSide: SidebarStatusIconSide) private let rows = NSHashTable.weakObjects() private var current: Values? @@ -22,7 +23,8 @@ final class SidebarTunablesWatch { if let current { return current } let values = withObservationTracking { (SidebarTunables.agentMark.value, SidebarTunables.presenceDots.value, SidebarRowBadgeStyle.tunable.value, - SidebarRowBadgeStackCap.tunable.value) + SidebarRowBadgeStackCap.tunable.value, + SidebarStatusIconSide.tunable.value) } onChange: { Task { @MainActor in SidebarTunablesWatch.shared.changed() } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift index 4adddf7d37fd..40b4a6e7382f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift @@ -8,9 +8,11 @@ import Foundation /// shows the dim time since the last change. extension WorkspaceRowView { /// Applies `style` after the rest of `configure`: the row badge and the trailing time. `trailing` leaves the row as it was. + /// `status` draws the state as one status icon (`configure` set it): no badge, the time on the right. func configureBadges(_ ws: SidebarWorkspace, shown: WorkspaceRowContent, style: SidebarRowBadgeStyle, stackLimit: Int = SidebarRowBadgeStackCap.tunable.defaultValue.limit) { let badges = WorkspaceRowBadges(unread: ws.unread, activity: shown.activity, stackLimit: stackLimit) + activity.themedToolTip = nil guard style != .trailing, !isShowingPlaceholder else { rowBadge.configure([], look: .corner) return @@ -20,6 +22,11 @@ extension WorkspaceRowView { timeText = time timeLabel.stringValue = time } + if style == .status { + rowBadge.configure([], look: .corner) + activity.themedToolTip = Self.badgeSummary(badges, stacked: false) + return + } let corner = style == .corner rowBadge.configure(corner ? badges.first.map { [$0] } ?? [] : badges.stacked, look: corner ? .corner : .stack) rowBadge.themedToolTip = Self.badgeSummary(badges, stacked: !corner) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+StatusGlyph.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+StatusGlyph.swift index 2bed114587e8..d413f70f1ad2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+StatusGlyph.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+StatusGlyph.swift @@ -2,6 +2,28 @@ import AppKit import CmuxNextDesign extension WorkspaceRowView { + /// An AppKit label cell draws its text this far inside its frame; the + /// marquee label draws at its edge, so it sits this much further in. + static let labelInset = Metrics.space1 + /// Room for a two-digit count, so 9 to 10 tabs moves nothing else. + static let countWidth: CGFloat = 18 + /// The PR badge never takes more than this from the title. + static let badgeMaxWidth: CGFloat = 72 + + /// The state the activity indicator draws: `activity`, or with the `.status` row badge the + /// row's most urgent state, a done mark for unread work included. + func rowStatus(_ ws: SidebarWorkspace, activity: StatusIndicatorState) -> StatusIndicatorState { + statusSide == nil ? activity : WorkspaceRowBadges(unread: ws.unread, activity: activity).first ?? .idle + } + + /// Places a status icon on the left (the `.status` row badge) in a fixed slot at `x`, shown or + /// not, so every row's title starts on one column, and returns where the icon or title starts. + func layoutLeadingStatus(at x: CGFloat, centerY: CGFloat) -> CGFloat { + guard statusSide == .left else { return x } + activity.frame = statusGlyphFrame(trailing: x + SidebarStyle.statusGlyphSize, centerY: centerY) + return x + SidebarStyle.statusGlyphSize + Metrics.space2 + } + /// The status mark's frame (the shared indicator, or the agent mark standing in for it) ending /// at `trailing`: as tall as the unread badge, centered on `centerY`, the line the badge and /// the row's other trailing marks share (a compact row's middle, a one-line row's and a diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index 30156b84640a..8faad420bd31 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -22,6 +22,7 @@ final class WorkspaceRowView: SidebarRowView { private var activityState = StatusIndicatorState.idle let badge = UnreadBadgeView(), rowBadge = RowBadgeView() // the count, and its replacement (WorkspaceRowView+Badges) private var rowBadgeStyle = SidebarRowBadgeStyle.corner + private(set) var statusSide: SidebarStatusIconSide? // the `.status` row badge's side (WorkspaceRowView+StatusGlyph) /// The agents acting in the workspace (`sidebar.presenceDots`, cx-gg84). let presenceDots = PresenceDotsView() /// A muted workspace (`notifications.mutedWorkspaces`): a quiet bell-slash @@ -148,7 +149,7 @@ final class WorkspaceRowView: SidebarRowView { var group: GroupID? var fontSize: CGFloat var iconSize: CGFloat - var agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackLimit: Int + var agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackLimit: Int, statusSide: SidebarStatusIconSide var disclosure: SidebarTabDisclosure? var row: WorkspaceRowContent? } @@ -160,7 +161,7 @@ final class WorkspaceRowView: SidebarRowView { ws: ws, group: row.group, fontSize: SidebarStyle.titleFont.pointSize, iconSize: Metrics.smallIconSize, agentMark: tunables.agentMark, presenceDots: tunables.presenceDots, rowBadge: badgeStyle ?? tunables.rowBadge, stackLimit: tunables.stackCap.limit, - disclosure: row.tabDisclosure, row: row.content + statusSide: tunables.statusSide, disclosure: row.tabDisclosure, row: row.content ) guard needsConfigure(content) else { return } grouped = row.group != nil @@ -176,7 +177,9 @@ final class WorkspaceRowView: SidebarRowView { // A row badge style other than `.trailing` draws the unread count and activity in its // badge and leads with the kind icon (`configureBadges`): set those views once, here, so // a status tick does not flip the icon image and the activity indicator twice per configure. - let badgesLead = content.rowBadge != .trailing && !isShowingPlaceholder + // `.status` draws neither: one status icon on the `sidebar.statusIconSide` side. + statusSide = content.rowBadge == .status && !isShowingPlaceholder ? content.statusSide : nil + let badgesLead = statusSide == nil && content.rowBadge != .trailing && !isShowingPlaceholder if badgesLead, shown.icon == nil { icon.configure(icon: nil, fallback: ws.kind.iconName, brand: ws.kindBrand ?? ws.agentBrand) } else { @@ -189,14 +192,14 @@ final class WorkspaceRowView: SidebarRowView { subtitle.stringValue = row.detail ?? "" hasSubtitle = row.detail != nil (isExpanded, timeText, timeLabel.stringValue, metaLabel.stringValue) = (shown.expanded, shown.trailingText, shown.trailingText ?? "", shown.meta ?? "") - activity.configure(badgesLead ? .idle : shown.activity, style: badgesLead ? nil : ws.activityStyle) + activity.configure(badgesLead ? .idle : rowStatus(ws, activity: shown.activity), style: badgesLead ? nil : ws.activityStyle) activityState = shown.activity agentMarkVariant = content.agentMark let markImage = agentMarkVariant == .off || isShowingPlaceholder ? nil : ws.agentBrand.flatMap { AgentBrandCatalog.templateImage(brand: $0, size: agentMarkSize) } agentMark.image = markImage agentMark.isHidden = markImage == nil - badge.configure(badgesLead ? .none : ws.unread) + badge.configure(badgesLead || statusSide != nil ? .none : ws.unread) presenceDots.configure(content.presenceDots && !isShowingPlaceholder ? ws.presence : []) mutedMark.image = ws.muted && !isShowingPlaceholder ? NSImage.icon(symbol: "bell.slash", size: .iconRowSize(forLabelPointSize: SidebarStyle.indicatorSize - Metrics.space1)) : nil @@ -237,14 +240,6 @@ final class WorkspaceRowView: SidebarRowView { if hidden { title.stopMarquee() } } - /// An AppKit label cell draws its text this far inside its frame; the - /// marquee label draws at its edge, so it sits this much further in. - static let labelInset = Metrics.space1 - /// Room for a two-digit count, so 9 to 10 tabs moves nothing else. - static let countWidth: CGFloat = 18 - /// The PR badge never takes more than this from the title. - static let badgeMaxWidth: CGFloat = 72 - override func hoverChanged() { super.hoverChanged() needsLayout = true @@ -296,7 +291,7 @@ final class WorkspaceRowView: SidebarRowView { let (th, sh, top, cy) = textMetrics(head: b) // A custom workspace icon takes the leading slot; without one the // title starts at the leading inset (no default kind glyph). - let leading = SidebarStyle.horizontalInset + indent + let leading = layoutLeadingStatus(at: SidebarStyle.horizontalInset + indent, centerY: cy) let side: CGFloat switch iconKind { case nil: side = icon.isHidden ? 0 : SidebarStyle.iconBox // a kind icon or logo (`sidebar.rowBadge`) @@ -359,12 +354,12 @@ final class WorkspaceRowView: SidebarRowView { let overlayStart = [showClose ? closeButton.frame.minX : nil, showMore ? moreButton.frame.minX : nil, pinStart].compactMap { $0 }.min() let ind = SidebarStyle.indicatorSize trailing = layoutRowBadge(style: rowBadgeStyle, trailing: trailing, cy: cy) - let markReplacesStatus = !agentMark.isHidden && agentMarkVariant == .replacesStatus + let markReplacesStatus = !agentMark.isHidden && agentMarkVariant == .replacesStatus && statusSide == nil activity.isHidden = markReplacesStatus if markReplacesStatus { agentMark.frame = statusGlyphFrame(trailing: trailing, centerY: cy) trailing = agentMark.frame.minX - Metrics.space2 - } else if activity.showsGlyph { + } else if activity.showsGlyph, statusSide != .left { activity.frame = statusGlyphFrame(trailing: trailing, centerY: cy) trailing = activity.frame.minX - Metrics.space2 } @@ -391,7 +386,7 @@ final class WorkspaceRowView: SidebarRowView { width: max(0, textW - 2 * Self.labelInset) * placeholderFraction, height: barHeight) // The marquee fades glyphs out across the padding left of them. let inset = Self.labelInset - title.leadingPadding = textX + inset - (side > 0 ? icon.frame.maxX : indent) + title.leadingPadding = textX + inset - (side > 0 ? icon.frame.maxX : leading - SidebarStyle.horizontalInset) title.fadeWidth = SidebarStyle.titleFadeWidth let titleWidth = max(0, textW - inset) // draws at its edge, so it runs to `trailing` layoutTextLines(x: textX, inset: inset, titleWidth: titleWidth, width: textW, head: b, top: top, title: th, line: sh) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift index e5f1a307680d..054e898e38ea 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift @@ -6,7 +6,9 @@ import QuartzCore /// input, done and failed draw the set's mark (with the OSC 7501 blocked /// kind) at the icon's corner in place of the colored dot. The default set /// keeps the dot, so nothing changes until a set is picked. The unread dot -/// is not a status and stays a dot. +/// is not a status and stays a dot. When sidebar rows show a status icon (Debug Settings +/// `sidebar.rowBadge` Status icon), the mark sits beside the icon or at the title's end instead, +/// on the side `sidebar.statusIconSide` names (`StatusIndicatorConfig.statusSide`), as rows do. extension TabCell { /// The state the badge stands for; nil without a status. var statusBadgeState: StatusIndicatorState? { @@ -20,7 +22,7 @@ extension TabCell { /// The set's mark for the badge, or nil where the dot stays. func statusBadgePlan(config: StatusIndicatorConfig) -> StatusIndicatorPlan? { - guard config.iconSet != .current, let state = statusBadgeState else { return nil } + guard let state = statusBadgeState, config.statusSide != nil || config.iconSet != .current else { return nil } let plan = StatusIndicatorPlan.make(state, style: config.settings.style, animates: false, set: config.iconSet) return plan.glyph == .none ? nil : plan } @@ -49,15 +51,48 @@ extension TabCell { layoutLayers() } - /// Places the badge mark at the icon's top trailing corner, where the dot - /// sits; true when it draws (the dot is then skipped). + /// The side the mark draws on beside the icon or title (`StatusIndicatorConfig.statusSide`); nil at the icon's corner. + var statusSide: SidebarStatusIconSide? { + guard statusGlyphLayer != nil else { return nil } + return StatusIndicatorAppearance.shared.config.statusSide + } + + /// The side of a mark that sits beside the icon or the title. + func statusGlyphSide(iconSide: CGFloat) -> CGFloat { pixel(iconSide * 0.62) } + + /// The slot a mark on the left takes before the icon; 0 for any other place. + func statusLeadingSlot(iconSide: CGFloat) -> CGFloat { + statusSide == .left && visibility.showsIcon && !visibility.centersContent ? statusGlyphSide(iconSide: iconSide) + Metrics.space2 : 0 + } + + /// Places the badge mark before the icon (`left`) or at the icon's top trailing corner, where + /// the dot sits. A mark on the right waits for the title (`statusTitleEnd`). Returns whether + /// it draws (the dot is then skipped). func layoutStatusGlyph(iconFrame: CGRect, visible: Bool) -> Bool { guard let statusGlyphLayer else { return false } statusGlyphLayer.contentsScale = scale statusGlyphLayer.layer.opacity = visible ? 1 : 0 - let side = pixel(iconFrame.width * 0.62) - statusGlyphLayer.frame = CGRect(x: pixel(iconFrame.maxX - side + Metrics.space1), y: pixel(iconFrame.minY - Metrics.space1), - width: side, height: side) + let side = statusGlyphSide(iconSide: iconFrame.width) + switch statusSide { + case .right? where visibility.showsTitle: + return true + case .left?: + statusGlyphLayer.frame = CGRect(x: pixel(iconFrame.minX - Metrics.space2 - side), y: pixel(iconFrame.midY - side / 2), + width: side, height: side) + default: + // The corner, also for a tab too narrow for its title. + statusGlyphLayer.frame = CGRect(x: pixel(iconFrame.maxX - side + Metrics.space1), y: pixel(iconFrame.minY - Metrics.space1), + width: side, height: side) + } return visible } + + /// Places a mark on the right at `titleEnd` and returns where the title now ends. + func statusTitleEnd(iconFrame: CGRect, _ titleEnd: CGFloat) -> CGFloat { + guard let statusGlyphLayer, statusSide == .right else { return titleEnd } + let side = statusGlyphSide(iconSide: iconFrame.width) + statusGlyphLayer.layer.opacity = 1 + statusGlyphLayer.frame = CGRect(x: pixel(titleEnd - side), y: pixel(iconFrame.midY - side / 2), width: side, height: side) + return titleEnd - side - Metrics.space2 + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift index 7836b98c6152..f2da2c3ee270 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift @@ -296,7 +296,7 @@ final class TabCell { let midY = bounds.height / 2 let iconSide = m.iconSize - var iconX = m.contentLeadingInset + var iconX = m.contentLeadingInset + statusLeadingSlot(iconSide: iconSide) // a mark on the left (TabCell+StatusBadge) var closeRect: CGRect? if visibility.showsClose { let side = m.closeButtonSize @@ -372,7 +372,7 @@ final class TabCell { let span = max(0, bounds.width - m.contentTrailingInset - titleX) let lineHeight = ceil(titleFont.ascender - titleFont.descender + titleFont.leading) titleLayer.opacity = 1 - let titleEnd = closeRect.map { $0.minX - m.titleCloseSpacing } ?? (bounds.width - m.contentTrailingInset) + let titleEnd = statusTitleEnd(iconFrame: iconFrame, closeRect.map { $0.minX - m.titleCloseSpacing } ?? (bounds.width - m.contentTrailingInset)) let badgesEnd = layoutAudioBadge(titleX: titleX, titleEnd: layoutMachineBadge(titleX: titleX, titleEnd: titleEnd, midY: midY), midY: midY) let profileEnd = layoutProfileBadge(titleX: titleX, titleEnd: badgesEnd, midY: midY) let visibleEnd = layoutPresenceDot(titleX: titleX, titleEnd: profileEnd, midY: midY) From f56feaa33eb91ac4fdba3c3ad6e28d3b1ae9c93b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 23:30:14 -0700 Subject: [PATCH 03/11] fix: keep the public import for the row badge stack cap Co-Authored-By: Claude Opus 5.5 --- .../Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift index a692b2012955..91f3d45927d6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowBadges.swift @@ -1,4 +1,4 @@ -import CmuxNextDesign +public import CmuxNextDesign /// A row's pending items, most urgent first: a failure, then a permission (or sign-in) request, /// then a question, then work in progress, then each unread item (done). The corner badge draws From ff202a033be71e5c94ce8fa27a12ae4949eb53da Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 23:38:57 -0700 Subject: [PATCH 04/11] test: the Status icon is the default and a browser row keeps its icon Red: rows still default to the corner badge with a kind icon, and in the Status icon style a browser row without a favicon shows no icon. Co-Authored-By: Claude Opus 5.5 --- .../StatusIconSideTests.swift | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift index 1f5a0ae0dab9..8e0e533a5f8c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -7,19 +7,46 @@ import Testing /// Leo and Lawrence's team call (2026-10-14): no kind icon and no corner badge on a sidebar row; /// one status icon (working, needs input, done, error) on the left or the right of the row, and /// tabs the same so rows and tabs match. Both are Debug Settings choices until promoted: the -/// `sidebar.rowBadge` Status icon style, and `sidebar.statusIconSide` for its side. +/// `sidebar.rowBadge` Status icon style, and `sidebar.statusIconSide` for its side. Leo 2026-10-15: +/// the Status icon style is the default, agent chat rows draw no chat icon, and a browser row keeps +/// a browser-like icon (its favicon, else a globe) on the left. @MainActor @Suite(.serialized) struct StatusIconSideTests { private static let busy = WorkspaceID("ws-status-busy") private static let done = WorkspaceID("ws-status-done") + private static let chat = WorkspaceID("ws-status-chat") + private static let page = WorkspaceID("ws-status-page") // MARK: Debug Settings - @Test func debugSettingsListsTheSideNextToTheRowBadgeAndTodaysLookStays() { + @Test func debugSettingsListsTheSideNextToTheRowBadgeAndTheStatusIconIsTheDefault() { let keys = SidebarTunables.all.map(\.key) #expect(keys.contains(SidebarStatusIconSide.tunable.key)) #expect(SidebarStatusIconSide.tunable.defaultValue == .left) - #expect(SidebarRowBadgeStyle.allCases.contains(.status)) - #expect(SidebarRowBadgeStyle.tunable.defaultValue == .corner) + #expect(SidebarRowBadgeStyle.tunable.defaultValue == .status) + } + + @Test func aDefaultAgentChatRowShowsNoChatIconAndItsStatusOnTheLeft() throws { + TunableStore.shared.activate(file: nil) + TunableStore.shared.register(SidebarTunables.all) + TunableStore.shared.set(SidebarRowBadgeStyle.tunable.key, nil) + TunableStore.shared.set(SidebarStatusIconSide.tunable.key, nil) + SidebarTunablesWatch.shared.changed() + let row = try Self.row(Self.sidebar(), Self.chat) + #expect(row.icon.isHidden, "no chat icon") + #expect(row.statusSide == .left) + #expect(row.activity.frame.maxX <= row.title.frame.minX + WorkspaceRowView.labelInset) + } + + @Test(arguments: [SidebarStatusIconSide.left, .right]) + func aBrowserRowKeepsABrowserIconOnTheLeft(_ side: SidebarStatusIconSide) throws { + try Self.withStatusLook(side) { + let row = try Self.row(Self.sidebar(), Self.page) + #expect(!row.icon.isHidden, "a browser row shows a browser icon") + #expect(row.icon.frame.maxX <= row.title.frame.minX + WorkspaceRowView.labelInset) + if side == .left { + #expect(row.activity.frame.maxX <= row.icon.frame.minX, "the status icon sits before the browser icon") + } + } } @Test(arguments: [SidebarStatusIconSide.left, .right]) @@ -27,7 +54,7 @@ import Testing try Self.withStatusLook(side) { #expect(StatusIndicatorConfig.current.statusSide == side) } - #expect(StatusIndicatorConfig.current.statusSide == nil, "the corner badge keeps tabs on the corner") + #expect(StatusIndicatorConfig.current.statusSide == .left, "by default tabs draw the status icon on the left, as rows do") } // MARK: Sidebar rows @@ -108,13 +135,16 @@ import Testing try body() } - /// A failing row with unread items, a row with unread done work, and a quiet row. + /// A failing row with unread items, a row with unread done work, a quiet row, a working agent + /// chat and a browser page without a favicon yet. private static func sidebar() -> SidebarView { let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) let rows = [ CmuxNextSidebar.SidebarWorkspace(id: busy, title: "api", kind: .terminal, unread: .count(2), activity: .error), CmuxNextSidebar.SidebarWorkspace(id: done, title: "web", kind: .terminal, unread: .count(1)), CmuxNextSidebar.SidebarWorkspace(id: WorkspaceID("ws-status-quiet"), title: "docs", kind: .terminal), + CmuxNextSidebar.SidebarWorkspace(id: chat, title: "fix the build", kind: .harness, activity: .working(progress: nil)), + CmuxNextSidebar.SidebarWorkspace(id: page, title: "docs.cmux.com", kind: .browser), ] let model = SidebarModel(sections: [CmuxNextSidebar.SidebarSection(kind: .machine(machine), nodes: rows.map { .workspace($0) })]) let sidebar = SidebarView(model: model, design: DesignSettings()) From 7a083b86253b4d2b093297f17758744624eff26c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 14 Oct 2026 23:49:01 -0700 Subject: [PATCH 05/11] feat: the Status icon is the default row look, browser rows keep a globe Leo 2026-10-15: agent chat rows draw no chat icon by default; their status icon sits on the left (or the right, sidebar.statusIconSide), tabs match, and a browser row keeps its favicon, else a globe, on the left. The corner badge tests pin the corner look. Co-Authored-By: Claude Opus 5.5 --- .../StatusIndicator/SidebarRowBadgeStyle.swift | 7 ++++--- .../Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift | 4 ++-- .../CmuxNextAppTests/SidebarRowCornerBadgeTests.swift | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift index 07652176435b..0b04a0f326d9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/SidebarRowBadgeStyle.swift @@ -1,10 +1,11 @@ /// How a workspace row shows what waits in it (Debug Settings `sidebar.rowBadge`). Leo and -/// Lawrence 2026-10-14: no count numbers on NEXT rows. The default follows T3 Code's thread list: +/// Lawrence 2026-10-14: no count numbers on NEXT rows. `corner` follows T3 Code's thread list: /// the row leads with its harness logo or kind icon, and one small badge on that icon's corner says /// the most urgent state; the count reads in the hover card. `stack` is Leo's alternative, one /// glyph per pending item overlapping in the trailing slot; `trailing` keeps the count badge and /// the trailing status glyph (the look before); `status` drops the kind icon and badge for one -/// status icon on a side, and tabs follow it (`StatusIndicatorConfig.statusSide`). +/// status icon on a side, and tabs follow it (`StatusIndicatorConfig.statusSide`). Leo 2026-10-15: +/// `status` is the default. public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Hashable, TunableChoice { /// One badge on the leading icon's bottom-right corner. case corner @@ -28,5 +29,5 @@ public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Ha public static let tunable = Tunable.choice( "sidebar.rowBadge", .sidebar, "Row badge", help: "How a workspace row shows unread items and agent states: a badge on the leading icon's corner, a stack of glyphs, the count with a trailing glyph, or one status icon on the side Status Icon Side names, on rows and tabs alike.", - default: .corner, code: "SidebarRowBadgeStyle.tunable") + default: .status, code: "SidebarRowBadgeStyle.tunable") } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index 8faad420bd31..943462aad0ac 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -177,10 +177,10 @@ final class WorkspaceRowView: SidebarRowView { // A row badge style other than `.trailing` draws the unread count and activity in its // badge and leads with the kind icon (`configureBadges`): set those views once, here, so // a status tick does not flip the icon image and the activity indicator twice per configure. - // `.status` draws neither: one status icon on the `sidebar.statusIconSide` side. + // `.status` draws neither: one status icon on the `sidebar.statusIconSide` side; a browser row keeps a globe. statusSide = content.rowBadge == .status && !isShowingPlaceholder ? content.statusSide : nil let badgesLead = statusSide == nil && content.rowBadge != .trailing && !isShowingPlaceholder - if badgesLead, shown.icon == nil { + if badgesLead || (statusSide != nil && ws.kind == .browser), shown.icon == nil { icon.configure(icon: nil, fallback: ws.kind.iconName, brand: ws.kindBrand ?? ws.agentBrand) } else { icon.configure(icon: shown.icon) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowCornerBadgeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowCornerBadgeTests.swift index 83ddb2976e70..ad7cb32c2d4f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowCornerBadgeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowCornerBadgeTests.swift @@ -16,7 +16,7 @@ import Testing private static let failing = WorkspaceID("ws-failing") private static let now = Date() - private static func sidebar(_ style: SidebarRowBadgeStyle? = nil) -> SidebarView { + private static func sidebar(_ style: SidebarRowBadgeStyle? = .corner) -> SidebarView { // the corner look, not the default (`status`) let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) let agentRow = SidebarWorkspace(id: agent, title: "cmux-next", lastActivity: now.addingTimeInterval(-180), kind: .harness, unread: .count(1), activity: .success, agentBrand: "claude") From 0c3c57ade6cb8699343516abdc3d87f1fe749233 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:28:26 -0700 Subject: [PATCH 06/11] test: an agent chat tab drops its chat icon with the status icon Red: with the Status icon look an agent chat tab still draws its chat icon, its status mark sits outside the icon slot on the left, and on the right its title starts after the empty slot. TabItem.isAgentChat marks those tabs. Co-Authored-By: Claude Opus 5.5 --- .../Sources/CmuxNextTabs/TabItem.swift | 3 +++ .../StatusIconSideTests.swift | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift index 149993f7fd9c..be03c3860afc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift @@ -40,6 +40,9 @@ public struct TabItem: Identifiable, Hashable, Sendable { /// takes no hover card. A blank browser tab is not one (an incognito or /// app tab's record keeps about:blank while its page shows a site). public var isNewTabPage = false + /// An agent chat: with rows showing a status icon (`StatusIndicatorConfig.statusSide`) it + /// draws no chat icon or agent mark, as agent chat rows draw none (Leo 2026-10-15). + public var isAgentChat = false /// Group this tab belongs to. Ignored for pinned tabs and /// for ids missing from `TabStripModel.groups`. public var groupID: TabGroupID? diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift index 8e0e533a5f8c..6a48acc8eb1d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -1,5 +1,6 @@ import AppKit @testable import CmuxNextDesign +import CmuxNextIcons @testable import CmuxNextSidebar @testable import CmuxNextTabs import Testing @@ -113,6 +114,30 @@ import Testing } } + @Test(arguments: [SidebarStatusIconSide.left, .right]) + func anAgentChatTabDrawsNoChatIconAndATerminalTabKeepsItsIcon(_ side: SidebarStatusIconSide) throws { + try Self.withStatusLook(side) { + var chat = TabItem(id: TabID("chat"), title: "fix the build", icon: .icon(.agentChat), status: .needsInput) + chat.isAgentChat = true + let cell = TabCell(item: chat) + cell.frame = CGRect(x: 0, y: 0, width: 220, height: 30) + cell.layoutLayers() + #expect(cell.iconLayer.contents == nil, "no chat icon") + let glyph = try #require(cell.statusGlyphLayer) + let slot = cell.iconLayer.frame + switch side { + case .left: + #expect(abs(glyph.frame.midX - slot.midX) < 0.5, "the status icon \(glyph.frame) takes the icon's slot \(slot)") + case .right: + #expect(cell.titleLayer.frame.minX <= slot.minX + 0.5, "the title \(cell.titleLayer.frame) starts where the icon was \(slot)") + } + let terminal = TabCell(item: TabItem(id: TabID("term"), title: "zsh", icon: .icon(.terminal), status: .needsInput)) + terminal.frame = CGRect(x: 0, y: 0, width: 220, height: 30) + terminal.layoutLayers() + #expect(terminal.iconLayer.contents != nil, "a terminal tab keeps its icon") + } + } + // MARK: Fixtures /// Runs `body` with the Status icon row badge on `side`, then restores today's look. The store is From 54994c2da5ea1d31e903686bda7a119307827c63 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:30:03 -0700 Subject: [PATCH 07/11] feat: agent chat tabs drop the chat icon with the status icon Leo 2026-10-15: tabs match rows. With the Status icon look an agent chat tab draws no chat icon or agent mark: on the left its status icon takes the icon's slot, on the right its title starts there. Terminal and browser tabs keep their icons, browser tabs their favicon. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextBridge/TabItemMapping.swift | 1 + .../Sources/CmuxNextTabs/TabCell+Icon.swift | 1 + .../CmuxNextTabs/TabCell+StatusBadge.swift | 29 +++++++++++++++---- .../TabCell+StatusIndicator.swift | 1 + .../Sources/CmuxNextTabs/TabCell.swift | 2 +- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift index 228addd763c4..11a1575108e7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift @@ -34,6 +34,7 @@ public struct TabItemMapping { status: status(tab) ) item.isNewTabPage = isNewTabPage + item.isAgentChat = isConversation if busy.state.replacesTabIcon { item.indicator = busy.state } item.busyStyle = busy.style if item.status == .needsInput { item.blockedKind = StatusMapping.shared.blockedKind(tab) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift index 97ab71ea9df7..72c4333b2505 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift @@ -5,6 +5,7 @@ import CmuxNextIcons extension TabCell { /// The tab icon tinted `tint`, at the cell's icon size and scale. func iconImage(tint: NSColor) -> CGImage? { + if hidesAgentChatIcon { return nil } switch item.icon { case .none: // A pinned tab is icon-only: without an icon of its own it shows a pin. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift index 054e898e38ea..d1b6b2c44c72 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift @@ -31,7 +31,7 @@ extension TabCell { /// A cell with a status follows the shared config, so a set switch /// restyles it live. func updateStatusGlyph() { - if item.status != .none { StatusIndicatorAppearance.shared.register(self) } + if item.status != .none || item.isAgentChat { StatusIndicatorAppearance.shared.register(self) } let config = StatusIndicatorAppearance.shared.config if let plan = statusBadgePlan(config: config) { let glyph = statusGlyphLayer ?? { @@ -60,9 +60,28 @@ extension TabCell { /// The side of a mark that sits beside the icon or the title. func statusGlyphSide(iconSide: CGFloat) -> CGFloat { pixel(iconSide * 0.62) } - /// The slot a mark on the left takes before the icon; 0 for any other place. + /// An agent chat draws no chat icon or agent mark while rows show a status icon, as agent chat + /// rows draw none (Leo 2026-10-15); a user's own icon still draws. + var hidesAgentChatIcon: Bool { + guard item.isAgentChat, StatusIndicatorAppearance.shared.config.statusSide != nil else { return false } + switch item.icon { + case .icon(.agentChat), .agentMark: return true + default: return false + } + } + + /// The slot a mark on the left takes before the icon; 0 for any other place, and for an agent + /// chat, whose mark takes the icon's own slot. func statusLeadingSlot(iconSide: CGFloat) -> CGFloat { - statusSide == .left && visibility.showsIcon && !visibility.centersContent ? statusGlyphSide(iconSide: iconSide) + Metrics.space2 : 0 + statusSide == .left && visibility.showsIcon && !visibility.centersContent && !hidesAgentChatIcon + ? statusGlyphSide(iconSide: iconSide) + Metrics.space2 : 0 + } + + /// Where the title starts: after the icon, or where the icon was for an agent chat whose + /// status icon sits on the right (its left side is empty, as the row's is). + func titleStart(iconFrame: CGRect, spacing: CGFloat) -> CGFloat { + hidesAgentChatIcon && StatusIndicatorAppearance.shared.config.statusSide == .right && spinnerLayer == nil + ? iconFrame.minX : iconFrame.maxX + spacing } /// Places the badge mark before the icon (`left`) or at the icon's top trailing corner, where @@ -77,8 +96,8 @@ extension TabCell { case .right? where visibility.showsTitle: return true case .left?: - statusGlyphLayer.frame = CGRect(x: pixel(iconFrame.minX - Metrics.space2 - side), y: pixel(iconFrame.midY - side / 2), - width: side, height: side) + let x = hidesAgentChatIcon ? iconFrame.midX - side / 2 : iconFrame.minX - Metrics.space2 - side + statusGlyphLayer.frame = CGRect(x: pixel(x), y: pixel(iconFrame.midY - side / 2), width: side, height: side) default: // The corner, also for a tab too narrow for its title. statusGlyphLayer.frame = CGRect(x: pixel(iconFrame.maxX - side + Metrics.space1), y: pixel(iconFrame.minY - Metrics.space1), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift index f4ed2651b712..7745e3aff25f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift @@ -35,6 +35,7 @@ extension TabCell: StatusIndicatorConfigClient { func statusIndicatorConfigDidChange(_ config: StatusIndicatorConfig) { if item.status != .none { updateStatusGlyph() } + if item.isAgentChat { updateColors(animated: false); layoutLayers() } // its chat icon shows or hides guard item.isBusy else { return } updateSpinner() updateColors(animated: false) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift index f2da2c3ee270..8e939d7d8fca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift @@ -368,7 +368,7 @@ final class TabCell { // The title always spans to the trailing inset, so it never moves // or resizes when the x appears; the x overlays its end and the // title fades out before it. - let titleX = iconFrame.maxX + m.iconTitleSpacing + let titleX = titleStart(iconFrame: iconFrame, spacing: m.iconTitleSpacing) let span = max(0, bounds.width - m.contentTrailingInset - titleX) let lineHeight = ceil(titleFont.ascender - titleFont.descender + titleFont.leading) titleLayer.opacity = 1 From 119a08a11a757635ec2c80aad195ece8b07e0fec Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:38:01 -0700 Subject: [PATCH 08/11] test: no row or tab draws a kind icon; favicons stay Red: a browser row without a favicon draws a globe, and a terminal tab keeps its kind icon. TabItem.isAgentChat becomes iconIsKind (still set for agent chats only), the flag the fix reads. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextBridge/TabItemMapping.swift | 2 +- .../Sources/CmuxNextTabs/TabCell+Icon.swift | 2 +- .../CmuxNextTabs/TabCell+StatusBadge.swift | 12 ++-- .../TabCell+StatusIndicator.swift | 2 +- .../Sources/CmuxNextTabs/TabItem.swift | 6 +- .../StatusIconSideTests.swift | 69 ++++++++++++------- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift index 11a1575108e7..210251069aca 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift @@ -34,7 +34,7 @@ public struct TabItemMapping { status: status(tab) ) item.isNewTabPage = isNewTabPage - item.isAgentChat = isConversation + item.iconIsKind = isConversation if busy.state.replacesTabIcon { item.indicator = busy.state } item.busyStyle = busy.style if item.status == .needsInput { item.blockedKind = StatusMapping.shared.blockedKind(tab) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift index 72c4333b2505..83a0b72b4690 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+Icon.swift @@ -5,7 +5,7 @@ import CmuxNextIcons extension TabCell { /// The tab icon tinted `tint`, at the cell's icon size and scale. func iconImage(tint: NSColor) -> CGImage? { - if hidesAgentChatIcon { return nil } + if hidesKindIcon { return nil } switch item.icon { case .none: // A pinned tab is icon-only: without an icon of its own it shows a pin. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift index d1b6b2c44c72..3e9a0b9883d8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift @@ -31,7 +31,7 @@ extension TabCell { /// A cell with a status follows the shared config, so a set switch /// restyles it live. func updateStatusGlyph() { - if item.status != .none || item.isAgentChat { StatusIndicatorAppearance.shared.register(self) } + if item.status != .none || item.iconIsKind { StatusIndicatorAppearance.shared.register(self) } let config = StatusIndicatorAppearance.shared.config if let plan = statusBadgePlan(config: config) { let glyph = statusGlyphLayer ?? { @@ -62,8 +62,8 @@ extension TabCell { /// An agent chat draws no chat icon or agent mark while rows show a status icon, as agent chat /// rows draw none (Leo 2026-10-15); a user's own icon still draws. - var hidesAgentChatIcon: Bool { - guard item.isAgentChat, StatusIndicatorAppearance.shared.config.statusSide != nil else { return false } + var hidesKindIcon: Bool { + guard item.iconIsKind, StatusIndicatorAppearance.shared.config.statusSide != nil else { return false } switch item.icon { case .icon(.agentChat), .agentMark: return true default: return false @@ -73,14 +73,14 @@ extension TabCell { /// The slot a mark on the left takes before the icon; 0 for any other place, and for an agent /// chat, whose mark takes the icon's own slot. func statusLeadingSlot(iconSide: CGFloat) -> CGFloat { - statusSide == .left && visibility.showsIcon && !visibility.centersContent && !hidesAgentChatIcon + statusSide == .left && visibility.showsIcon && !visibility.centersContent && !hidesKindIcon ? statusGlyphSide(iconSide: iconSide) + Metrics.space2 : 0 } /// Where the title starts: after the icon, or where the icon was for an agent chat whose /// status icon sits on the right (its left side is empty, as the row's is). func titleStart(iconFrame: CGRect, spacing: CGFloat) -> CGFloat { - hidesAgentChatIcon && StatusIndicatorAppearance.shared.config.statusSide == .right && spinnerLayer == nil + hidesKindIcon && StatusIndicatorAppearance.shared.config.statusSide == .right && spinnerLayer == nil ? iconFrame.minX : iconFrame.maxX + spacing } @@ -96,7 +96,7 @@ extension TabCell { case .right? where visibility.showsTitle: return true case .left?: - let x = hidesAgentChatIcon ? iconFrame.midX - side / 2 : iconFrame.minX - Metrics.space2 - side + let x = hidesKindIcon ? iconFrame.midX - side / 2 : iconFrame.minX - Metrics.space2 - side statusGlyphLayer.frame = CGRect(x: pixel(x), y: pixel(iconFrame.midY - side / 2), width: side, height: side) default: // The corner, also for a tab too narrow for its title. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift index 7745e3aff25f..49a3c4ecdd9a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift @@ -35,7 +35,7 @@ extension TabCell: StatusIndicatorConfigClient { func statusIndicatorConfigDidChange(_ config: StatusIndicatorConfig) { if item.status != .none { updateStatusGlyph() } - if item.isAgentChat { updateColors(animated: false); layoutLayers() } // its chat icon shows or hides + if item.iconIsKind { updateColors(animated: false); layoutLayers() } // its chat icon shows or hides guard item.isBusy else { return } updateSpinner() updateColors(animated: false) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift index be03c3860afc..c9a7095208da 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabItem.swift @@ -40,9 +40,9 @@ public struct TabItem: Identifiable, Hashable, Sendable { /// takes no hover card. A blank browser tab is not one (an incognito or /// app tab's record keeps about:blank while its page shows a site). public var isNewTabPage = false - /// An agent chat: with rows showing a status icon (`StatusIndicatorConfig.statusSide`) it - /// draws no chat icon or agent mark, as agent chat rows draw none (Leo 2026-10-15). - public var isAgentChat = false + /// The icon is the tab's kind icon (or agent mark), not one the user set: with rows showing a + /// status icon (`StatusIndicatorConfig.statusSide`) it is not drawn, as rows draw none (Leo 2026-10-15). + public var iconIsKind = false /// Group this tab belongs to. Ignored for pinned tabs and /// for ids missing from `TabStripModel.groups`. public var groupID: TabGroupID? diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift index 6a48acc8eb1d..ae5a04381e3e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -9,8 +9,8 @@ import Testing /// one status icon (working, needs input, done, error) on the left or the right of the row, and /// tabs the same so rows and tabs match. Both are Debug Settings choices until promoted: the /// `sidebar.rowBadge` Status icon style, and `sidebar.statusIconSide` for its side. Leo 2026-10-15: -/// the Status icon style is the default, agent chat rows draw no chat icon, and a browser row keeps -/// a browser-like icon (its favicon, else a globe) on the left. +/// the Status icon style is the default and no row or tab draws a kind icon (agent chat, terminal or +/// browser); a page's favicon is its own icon, not a kind icon, and stays on the left. @MainActor @Suite(.serialized) struct StatusIconSideTests { private static let busy = WorkspaceID("ws-status-busy") private static let done = WorkspaceID("ws-status-done") @@ -39,13 +39,11 @@ import Testing } @Test(arguments: [SidebarStatusIconSide.left, .right]) - func aBrowserRowKeepsABrowserIconOnTheLeft(_ side: SidebarStatusIconSide) throws { + func noRowDrawsAKindIcon(_ side: SidebarStatusIconSide) throws { try Self.withStatusLook(side) { - let row = try Self.row(Self.sidebar(), Self.page) - #expect(!row.icon.isHidden, "a browser row shows a browser icon") - #expect(row.icon.frame.maxX <= row.title.frame.minX + WorkspaceRowView.labelInset) - if side == .left { - #expect(row.activity.frame.maxX <= row.icon.frame.minX, "the status icon sits before the browser icon") + let sidebar = Self.sidebar() + for id in [Self.busy, Self.chat, Self.page] { + #expect(try Self.row(sidebar, id).icon.isHidden, "\(id) draws a kind icon") } } } @@ -115,29 +113,48 @@ import Testing } @Test(arguments: [SidebarStatusIconSide.left, .right]) - func anAgentChatTabDrawsNoChatIconAndATerminalTabKeepsItsIcon(_ side: SidebarStatusIconSide) throws { + func aTabDrawsNoKindIconAndABrowserTabKeepsItsFavicon(_ side: SidebarStatusIconSide) throws { try Self.withStatusLook(side) { - var chat = TabItem(id: TabID("chat"), title: "fix the build", icon: .icon(.agentChat), status: .needsInput) - chat.isAgentChat = true - let cell = TabCell(item: chat) - cell.frame = CGRect(x: 0, y: 0, width: 220, height: 30) - cell.layoutLayers() - #expect(cell.iconLayer.contents == nil, "no chat icon") - let glyph = try #require(cell.statusGlyphLayer) - let slot = cell.iconLayer.frame - switch side { - case .left: - #expect(abs(glyph.frame.midX - slot.midX) < 0.5, "the status icon \(glyph.frame) takes the icon's slot \(slot)") - case .right: - #expect(cell.titleLayer.frame.minX <= slot.minX + 0.5, "the title \(cell.titleLayer.frame) starts where the icon was \(slot)") + for icon in [TabIcon.icon(.agentChat), .icon(.terminal), .agentMark("claude")] { + var item = TabItem(id: TabID("t"), title: "fix the build", icon: icon, status: .needsInput) + item.iconIsKind = true + let cell = Self.tab(item) + #expect(cell.iconLayer.contents == nil, "\(icon) draws its kind icon") + let glyph = try #require(cell.statusGlyphLayer) + let slot = cell.iconLayer.frame + switch side { + case .left: + #expect(abs(glyph.frame.midX - slot.midX) < 0.5, "the status icon \(glyph.frame) takes the icon's slot \(slot)") + case .right: + #expect(cell.titleLayer.frame.minX <= slot.minX + 0.5, "the title \(cell.titleLayer.frame) starts where the icon was \(slot)") + } + } + var page = TabItem(id: TabID("p"), title: "docs.cmux.com", icon: .image(try Self.favicon()), status: .needsInput) + page.iconIsKind = true + let browser = Self.tab(page) + #expect(browser.iconLayer.contents != nil, "a browser tab keeps its favicon") + if side == .left { + let glyph = try #require(browser.statusGlyphLayer) + #expect(glyph.frame.maxX <= browser.iconLayer.frame.minX, "the status icon sits before the favicon") } - let terminal = TabCell(item: TabItem(id: TabID("term"), title: "zsh", icon: .icon(.terminal), status: .needsInput)) - terminal.frame = CGRect(x: 0, y: 0, width: 220, height: 30) - terminal.layoutLayers() - #expect(terminal.iconLayer.contents != nil, "a terminal tab keeps its icon") } } + private static func tab(_ item: TabItem) -> TabCell { + let cell = TabCell(item: item) + cell.frame = CGRect(x: 0, y: 0, width: 220, height: 30) + cell.layoutLayers() + return cell + } + + private static func favicon() throws -> TabImage { + let context = try #require(CGContext(data: nil, width: 16, height: 16, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(CGColor(red: 0.2, green: 0.5, blue: 0.9, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) + return TabImage(try #require(context.makeImage())) + } + // MARK: Fixtures /// Runs `body` with the Status icon row badge on `side`, then restores today's look. The store is From 3c4d0acb977fb362bed61fb3ff788f30665a6790 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:39:04 -0700 Subject: [PATCH 09/11] feat: no kind icon on any row or tab in the status icon look Leo 2026-10-15: no chat or kind icon on any sidebar row by default, tabs to match. Browser rows drop the globe fallback; a page's favicon still draws. Tabs hide their kind icon or agent mark (not a favicon, a user's icon or a pinned tab's), and the status icon takes the icon's slot on the left. Co-Authored-By: Claude Opus 5.5 --- .../CmuxNextBridge/TabItemMapping.swift | 4 ++-- .../Views/WorkspaceRowView.swift | 4 ++-- .../CmuxNextTabs/TabCell+StatusBadge.swift | 18 +++++++++--------- .../CmuxNextTabs/TabCell+StatusIndicator.swift | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift index 210251069aca..b7da5cfeba7d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/TabItemMapping.swift @@ -34,7 +34,7 @@ public struct TabItemMapping { status: status(tab) ) item.isNewTabPage = isNewTabPage - item.iconIsKind = isConversation + item.iconIsKind = !isNewTabPage // the New Tab page's icon is its only mark if busy.state.replacesTabIcon { item.indicator = busy.state } item.busyStyle = busy.style if item.status == .needsInput { item.blockedKind = StatusMapping.shared.blockedKind(tab) } @@ -48,7 +48,7 @@ public struct TabItemMapping { /// mark, page icon, favicon). Callers that set a derived icon after ``item(_:fallbackTitle:isNewTabPage:)`` /// apply it again last. A busy indicator still covers it while the tab loads. public func applyUserIcon(_ tab: TabModel, to item: inout StripTabItem, asset: (String) -> TabImage? = { _ in nil }) { - if let icon = TabUserIcon.shared.icon(tab.userIcon, asset: asset) { item.icon = icon } + if let icon = TabUserIcon.shared.icon(tab.userIcon, asset: asset) { (item.icon, item.iconIsKind) = (icon, false) } } /// A tab's kind icon from the cmux icon registry. A terminal whose agent session is live, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index 943462aad0ac..01976112f974 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -177,10 +177,10 @@ final class WorkspaceRowView: SidebarRowView { // A row badge style other than `.trailing` draws the unread count and activity in its // badge and leads with the kind icon (`configureBadges`): set those views once, here, so // a status tick does not flip the icon image and the activity indicator twice per configure. - // `.status` draws neither: one status icon on the `sidebar.statusIconSide` side; a browser row keeps a globe. + // `.status` draws neither: one status icon on the `sidebar.statusIconSide` side (a page's favicon still draws). statusSide = content.rowBadge == .status && !isShowingPlaceholder ? content.statusSide : nil let badgesLead = statusSide == nil && content.rowBadge != .trailing && !isShowingPlaceholder - if badgesLead || (statusSide != nil && ws.kind == .browser), shown.icon == nil { + if badgesLead, shown.icon == nil { icon.configure(icon: nil, fallback: ws.kind.iconName, brand: ws.kindBrand ?? ws.agentBrand) } else { icon.configure(icon: shown.icon) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift index 3e9a0b9883d8..81ac3f015381 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift @@ -60,25 +60,25 @@ extension TabCell { /// The side of a mark that sits beside the icon or the title. func statusGlyphSide(iconSide: CGFloat) -> CGFloat { pixel(iconSide * 0.62) } - /// An agent chat draws no chat icon or agent mark while rows show a status icon, as agent chat - /// rows draw none (Leo 2026-10-15); a user's own icon still draws. + /// A tab draws no kind icon or agent mark while rows show a status icon, as rows draw none (Leo + /// 2026-10-15). A favicon, a user's own icon and a pinned tab's icon (its only content) still draw. var hidesKindIcon: Bool { - guard item.iconIsKind, StatusIndicatorAppearance.shared.config.statusSide != nil else { return false } + guard item.iconIsKind, !item.isPinned, StatusIndicatorAppearance.shared.config.statusSide != nil else { return false } switch item.icon { - case .icon(.agentChat), .agentMark: return true - default: return false + case .icon, .agentMark: return true + case .none, .symbol, .image: return false } } - /// The slot a mark on the left takes before the icon; 0 for any other place, and for an agent - /// chat, whose mark takes the icon's own slot. + /// The slot a mark on the left takes before the icon; 0 for any other place, and for a tab + /// without its kind icon, whose mark takes the icon's own slot. func statusLeadingSlot(iconSide: CGFloat) -> CGFloat { statusSide == .left && visibility.showsIcon && !visibility.centersContent && !hidesKindIcon ? statusGlyphSide(iconSide: iconSide) + Metrics.space2 : 0 } - /// Where the title starts: after the icon, or where the icon was for an agent chat whose - /// status icon sits on the right (its left side is empty, as the row's is). + /// Where the title starts: after the icon, or where the icon was for a tab without its kind + /// icon whose status icon sits on the right (its left side is empty, as the row's is). func titleStart(iconFrame: CGRect, spacing: CGFloat) -> CGFloat { hidesKindIcon && StatusIndicatorAppearance.shared.config.statusSide == .right && spinnerLayer == nil ? iconFrame.minX : iconFrame.maxX + spacing diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift index 49a3c4ecdd9a..51aacc2962be 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift @@ -35,7 +35,7 @@ extension TabCell: StatusIndicatorConfigClient { func statusIndicatorConfigDidChange(_ config: StatusIndicatorConfig) { if item.status != .none { updateStatusGlyph() } - if item.iconIsKind { updateColors(animated: false); layoutLayers() } // its chat icon shows or hides + if item.iconIsKind { updateColors(animated: false); layoutLayers() } // its kind icon shows or hides guard item.isBusy else { return } updateSpinner() updateColors(animated: false) From 13ba2498c17ce742282269041a0edaaa4dd1b32f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:43:55 -0700 Subject: [PATCH 10/11] test: allow the half point a pixel-snapped status icon moves Co-Authored-By: Claude Opus 5.5 --- .../CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift index ae5a04381e3e..7d869dffe1db 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -124,7 +124,7 @@ import Testing let slot = cell.iconLayer.frame switch side { case .left: - #expect(abs(glyph.frame.midX - slot.midX) < 0.5, "the status icon \(glyph.frame) takes the icon's slot \(slot)") + #expect(abs(glyph.frame.midX - slot.midX) <= 0.5, "the status icon \(glyph.frame) takes the icon's slot \(slot)") case .right: #expect(cell.titleLayer.frame.minX <= slot.minX + 0.5, "the title \(cell.titleLayer.frame) starts where the icon was \(slot)") } From a7b59319dc0f7bc51bf5086b39c9cff688f548bf Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:50:18 -0700 Subject: [PATCH 11/11] test: read the row before expecting on it Co-Authored-By: Claude Opus 5.5 --- .../CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift index 7d869dffe1db..bf11b5057334 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/StatusIconSideTests.swift @@ -43,7 +43,8 @@ import Testing try Self.withStatusLook(side) { let sidebar = Self.sidebar() for id in [Self.busy, Self.chat, Self.page] { - #expect(try Self.row(sidebar, id).icon.isHidden, "\(id) draws a kind icon") + let row = try Self.row(sidebar, id) + #expect(row.icon.isHidden, "\(id) draws a kind icon") } } }