Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,28 +45,48 @@ enum SidebarLaneStatus {
}
}

/// `row` with the strongest of its own activity and `lanes`' states.
/// `row` with the strongest of its own activity and `lanes`' states. A chat that asks for the
/// user comes first, as in the Priority view (needs you, then failed): a failure beside it never
/// turns the row's one yellow dot into the blue one (Leo 2026-10-21, "why ISN'T THIS JUST A
/// YELLOW DOT!?"; the row draws no "Needs you" word to fall back on).
static func merge(_ lanes: [AgentActivityLane], into row: inout SidebarWorkspace) {
for lane in lanes {
let state = indicator(lane.state)
if state.isWorking { row.agentWorking = true }
let current = StatusReport(id: "row", source: .explicit, state: row.activity)
guard StatusStack.precedes(StatusReport(id: "lane:\(lane.id)", source: .agent, state: state), current) else { continue }
let precedes = switch (asks(state), asks(row.activity)) {
case (true, false): true
case (false, true): false
default: StatusStack.precedes(StatusReport(id: "lane:\(lane.id)", source: .agent, state: state), current)
}
guard precedes else { continue }
row.activity = state
row.activityStyle = nil
row.detail.activitySince = lane.since
}
}

/// Whether `state` waits on the user (a question, a permission, a sign-in).
private static func asks(_ state: StatusIndicatorState) -> Bool {
if case .waiting = state { true } else { false }
}

/// The Priority view's chip for the most urgent of `lanes` (`AgentPriorityRanking`, with the
/// same read marks and Red CI), nil when each one works or is idle: the working mark already says working.
/// `failingChecks` is the Red CI answer, by default the session tree feed's.
/// same read marks and Red CI), nil when each one works, is idle or finished: the working mark
/// already says working, and a row never says Done (Leo 2026-10-21; its unread dot does). A
/// finished chat drops out before the ranking, so it never hides a failed one, and its failing
/// checks still read as Red CI. `failingChecks` is the Red CI answer, by default the session tree feed's.
static func chip(_ lanes: [AgentActivityLane], marks: AgentPriorityMarks.State,
failingChecks: ((AgentActivityLane) -> Bool)? = nil) -> SidebarPriorityEntry.State? {
lanes.map { AgentPriorityRanking.state($0, read: marks.read[$0.id] == AgentPriorityMarks.stamp($0),
failingChecks: failingChecks?($0) ?? SidebarSessionTreeFeed.shared.checksFailing($0)) }
.filter { if case .working = $0 { false } else { $0 != .idle } }
.min { AgentPriorityRanking.tier($0) < AgentPriorityRanking.tier($1) }
lanes.compactMap { lane -> SidebarPriorityEntry.State? in
let failing = failingChecks?(lane) ?? SidebarSessionTreeFeed.shared.checksFailing(lane)
switch AgentPriorityRanking.state(lane, read: marks.read[lane.id] == AgentPriorityMarks.stamp(lane), failingChecks: failing) {
case .done: return failing ? .ciFailing : nil
case .working, .idle: return nil
case let state: return state
}
}
.min { AgentPriorityRanking.tier($0) < AgentPriorityRanking.tier($1) }
}

/// The first line of the step of the working lane that started last, nil when none works or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ nonisolated extension SidebarLayoutOptions {
if let sessionTree { content.keycap = keycaps[ws.id] }
content.pullRequests = sessionTree?.pullRequests[ws.id] ?? pullRequests[ws.id]
if !showsTimestamps { (content.time, content.badgeTime) = (nil, nil) }
// What the agent needs outranks the row's age and one-line status on the name's line; the
// row view words it (localized on the main actor, so the layout stays nonisolated).
if !minimal, let chip = ws.agentChip { (content.chip, content.time) = (chip, nil) }
// What the agent needs, for the row's accessibility label only: a row draws no status words
// (Leo 2026-10-21), its badge's dot says it. Worded on the main actor by the row view.
if !minimal, let chip = WorkspaceRowContent.rowChip(ws.agentChip) { content.chip = chip }
// A row that already has a second line says there what its agent does now; a one-line row
// keeps its height, so rows do not grow and shrink as agents start and stop.
if !minimal, content.detail != nil, let step = ws.agentStep { (content.now, content.detail) = (step, step) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,17 @@ nonisolated struct WorkspaceRowBadges: Equatable {
self.items = detailed ? pending : Self.simple(pending).map { [$0] } ?? []
}

/// The one state a row shows without detailed icons: Ready (`success`, the blue dot) when any
/// item waits for the user, else the work in progress (its progress kept), else nothing.
/// The one state a row shows without detailed icons: needs you (`waiting`, one yellow dot
/// whatever it waits for) in place of the blue dot, else Ready (`success`, the blue dot) for an
/// error or something unread, else the work in progress (its progress kept), else nothing.
/// Never both dots (Leo 2026-10-21, on a blue dot beside "Needs you": "why ISN'T THIS JUST A
/// YELLOW DOT!?").
static func simple(_ items: [StatusIndicatorState]) -> StatusIndicatorState? {
if items.contains(where: { if case .waiting = $0 { true } else { false } }) { return .waiting }
let ready = items.contains { state in
switch state {
case .waiting, .error, .success: true
case .idle, .busy, .paused, .working: false
case .error, .success: true
case .idle, .busy, .paused, .working, .waiting: false
}
}
return ready ? .success : items.first { $0.isWorking || $0.isLoading }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ public nonisolated enum SidebarTunables {
help: "A workspace row's status mark also follows the acpmux lane of each agent chat in it on this Mac: waiting on you or failed wins, then working, then the row's own status.",
default: true, code: "SidebarTunables.laneStatus")

/// A row names the state of its most urgent agent chat with the Priority view's words (needs
/// you, done, failed, lost) at the right of its name (`SidebarLaneStatus`). On by default.
/// A row's accessibility label names the state of its most urgent agent chat with the Priority
/// view's words (needs you, failed, lost; never Done, `WorkspaceRowContent.rowChip`;
/// `SidebarLaneStatus`). The row draws no status words: its badge's dot says it (Leo 2026-10-21).
/// On by default.
public static let rowStatus = Tunable<Bool>.toggle(
"sidebar.rowStatus", .sidebar, "Rows name their agent's state",
help: "A workspace row says needs you, done, failed or lost at the right of its name, in the Priority view's words, for the most urgent agent chat in it on this Mac.",
"sidebar.rowStatus", .sidebar, "Row labels name their agent's state",
help: "A workspace row's accessibility label says needs you, failed or lost, in the Priority view's words, for the most urgent agent chat in it on this Mac. The row itself draws no status words; its badge's dot says it.",
default: true, code: "SidebarTunables.rowStatus")

/// The workspace hover card says what its agents did since the person last looked at it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable {
public var keycap: Int?
/// The session tree: the row's pull requests, a glyph and a count in their status color.
public var pullRequests: SidebarPullRequestBadge?
/// The Priority view's chip (`SidebarWorkspace.agentChip`): the row view draws its word as the
/// name line's trailing text in the chip's color, in place of the age or one-line status.
/// The Priority view's chip (`SidebarWorkspace.agentChip`) when it asks for something
/// (`rowChip`), for the row's accessibility label. Never drawn: a row shows no status words
/// (Leo 2026-10-21); needs you is the badge's yellow dot.
public var chip: SidebarPriorityEntry.State?
/// What the working agent does now (`SidebarWorkspace.agentStep`), in place of the second
/// line's text: the row view draws it as "Now: …". Set only on a row that has a second line.
Expand Down Expand Up @@ -166,6 +167,16 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable {
return formatter.string(from: TimeInterval(days) * 86_400) ?? ""
}

/// The chip a row may draw: what asks for the user (needs you, failed, lost, Red CI). A finished
/// chat says nothing in words (Leo 2026-10-21, "WHO BROUGHT BACK THE DONE AND THE TIMERS"): its
/// unread dot is the cue. Working and idle never reach a row (`SidebarLaneStatus.chip`).
static func rowChip(_ state: SidebarPriorityEntry.State?) -> SidebarPriorityEntry.State? {
switch state {
case .needsYou?, .failed?, .lost?, .ciFailing?: state
case .done?, .working?, .idle?, nil: nil
}
}

/// How long a row has worked, in whole minutes ("4m", "1h 3m"); nil under a minute, so the
/// row's text changes once a minute at most.
static func workingElapsed(since date: Date, now: Date) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension WorkspaceRowView {
func accessibilityText(_ ws: SidebarWorkspace, content: WorkspaceRowContent) -> String {
var parts = [ws.title]
if let s = content.now.map(Strings.rowNow) ?? content.detail { parts.append(s) }
// The chip's word takes the one-line status's place, as drawn.
// The chip's word (never drawn: no status words on a row), else the one-line status the row draws.
if let s = content.chip.map({ SidebarPriorityRowView.chipText($0, now: Date()) }) ?? content.trailingStatus { parts.append(s) }
if let s = content.status?.searchText, !s.isEmpty { parts.append(s) }
if let s = content.badge { parts.append(s) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ extension WorkspaceRowView {
performWithTheme {
title.textColor = Palette.textPrimary
subtitle.setColor(Palette.textSecondary); [timeLabel, metaLabel, elapsedLabel].forEach { $0.setColor(Palette.textTertiary) }
if let chip { timeLabel.setColor(SidebarPriorityRowView.chipColor(chip)) }
tabCount.setColor(Palette.textTertiary)
mutedMark.setTint(Palette.textTertiary)
prBadge.setColor(Palette.textSecondary)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ final class WorkspaceRowView: SidebarRowView {
/// The last configuration, so a Debug Settings switch of `sidebar.agentMark` redraws the row.
internal(set) var lastConfiguration: (SidebarWorkspace, SidebarRow, SidebarRowBadgeStyle?)?
var activityState = StatusIndicatorState.idle
/// The Priority chip on the name's line, which colors that line's trailing text.
var chip: SidebarPriorityEntry.State? { didSet { if chip != oldValue { needsDisplay = true } } }
let badge = UnreadBadgeView(), rowBadge = RowBadgeView() // the count, and its replacement (WorkspaceRowView+Badges)
var rowBadgeStyle = SidebarRowBadgeStyle.corner
/// The agents acting in the workspace (`sidebar.presenceDots`, cx-gg84).
Expand Down Expand Up @@ -262,16 +260,15 @@ final class WorkspaceRowView: SidebarRowView {
subtitle.setString(shown.now.map(Strings.rowNow) ?? row.detail ?? "")
hasSubtitle = row.detail != nil
// While the agent works, its running time takes the age's place (WorkspaceRowView+Elapsed).
let trailingText = shown.chip.map { SidebarPriorityRowView.chipText($0, now: Date()) }
?? (shown.workingSince != nil ? shown.trailingStatus : shown.trailingText)
// No status words on a row (Leo 2026-10-21): its badge's dot says it; the label reads the chip.
let trailingText = (shown.workingSince != nil ? shown.trailingStatus : shown.trailingText)
(isExpanded, timeText) = (shown.expanded, trailingText)
timeLabel.setString(trailingText ?? "")
if workingSince != shown.workingSince { elapsedTicker.cancel() }
workingSince = shown.workingSince
metaLabel.setString(shown.meta ?? "")
activity.configure(badgesLead ? .idle : shown.activity, style: badgesLead ? nil : ws.activityStyle)
activityState = shown.activity
chip = shown.chip
agentMarkVariant = content.agentMark
let markImage = agentMarkVariant == .off || isShowingPlaceholder ? nil
: ws.agentBrand.flatMap { AgentBrandCatalog.templateImage(brand: $0, size: agentMarkSize) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import AppKit
import CmuxNextAgentActivity
@testable import CmuxNextAgentLanes
@testable import CmuxNextDesign
@testable import CmuxNextSidebar
import Foundation
import Testing

/// Leo 2026-10-21, on rows that said "Done" beside a blue dot again: "WHO BROUGHT BACK THE DONE AND
/// THE TIMERS" (#23390 brought the Done back). A workspace row never says Done: a finished, unread
/// chat keeps only its quiet unread dot. What asks for the user (needs you, failed) still reads.
@MainActor @Suite(.serialized)
struct SidebarRowNoDoneTests {
private func workspace(_ chip: SidebarPriorityEntry.State?) -> SidebarWorkspace {
var ws = SidebarWorkspace(id: WorkspaceID("ws"), title: "Fix the build", lastActivity: Date())
ws.agentChip = chip
ws.unread = .dot
return ws
}

/// The row the list draws for `ws`: its content through the layout (where the chip is read),
/// configured and laid out in a window.
private func row(_ ws: SidebarWorkspace) -> (WorkspaceRowView, NSWindow) {
let content = SidebarLayoutOptions().rowContent(ws, machine: nil)
let row = SidebarRow(key: .workspace(ws.id), y: 0, height: 28, section: .pinned, group: nil, siblingIndex: 0,
parentIndex: nil, isLastInGroup: false, isCollapsed: false, childCount: 0, groupColor: nil,
content: content)
let view = WorkspaceRowView(key: row.key)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 28), styleMask: .borderless,
backing: .buffered, defer: true)
// `close()` would release a window the test still holds (a segfault on the fleet).
window.isReleasedWhenClosed = false
window.contentView?.addSubview(view)
view.frame = NSRect(x: 0, y: 0, width: 320, height: 28)
view.configure(ws, row: row, badgeStyle: .corner)
view.layoutSubtreeIfNeeded()
return (view, window)
}

/// Every line of text the row shows, at any depth.
private func shownText(_ view: NSView) -> [String] {
view.subviews.flatMap { sub -> [String] in
guard !sub.isHidden else { return [] }
let own = (sub as? any ShownText).map { [$0.stringValue] } ?? []
return own + shownText(sub)
}.filter { !$0.isEmpty }
}

@Test func aFinishedChatsRowNeverSaysDone() {
let (view, window) = row(workspace(.done))
let done = SidebarPriorityRowView.chipText(.done, now: Date())
#expect(!shownText(view).contains(done), "the row draws \(shownText(view))")
#expect(view.accessibilityLabel()?.contains(done) != true)
// The quiet unread dot is the finished chat's only cue.
#expect(view.accessibilityLabel()?.contains(Strings.unreadDot) == true)
window.close()
}

@Test func onlyWhatAsksForTheUserReachesARow() {
#expect(WorkspaceRowContent.rowChip(.done) == nil)
#expect(WorkspaceRowContent.rowChip(.working(since: Date())) == nil)
#expect(WorkspaceRowContent.rowChip(.idle) == nil)
for chip: SidebarPriorityEntry.State in [.needsYou, .failed, .lost, .ciFailing] {
#expect(WorkspaceRowContent.rowChip(chip) == chip)
}
}

/// No status words at all (Leo 2026-10-21, on a blue dot beside "Needs you": "why ISN'T THIS
/// JUST A YELLOW DOT!?"): the badge's dot says it; only the accessibility label words it.
@Test func aRowShowsNoStatusWords() {
for chip: SidebarPriorityEntry.State in [.needsYou, .failed, .lost, .ciFailing, .done] {
let (view, window) = row(workspace(chip))
let word = SidebarPriorityRowView.chipText(chip, now: Date())
#expect(!shownText(view).contains(word), "\(chip): the row draws \(shownText(view))")
window.close()
}
let (view, window) = row(workspace(.needsYou))
#expect(view.accessibilityLabel()?.contains(SidebarPriorityRowView.chipText(.needsYou, now: Date())) == true)
window.close()
}

/// A chat asking for the user beside a failed or lost one, or on a row whose own status failed:
/// the row draws the one yellow dot, never the blue one. The failure used to outrank the
/// question, and with no "Needs you" word left the row read Ready (codex review of #23767).
@Test func needsYouBesideAFailureIsTheYellowDot() throws {
for own: StatusIndicatorState in [.idle, .error] {
for order in [["failed", "input"], ["input", "lost"]] {
var ws = workspace(.needsYou)
ws.activity = own
let lanes = try order.enumerated().map { index, state in
try #require(AgentActivityLane.of(summary: ["sessionId": "s\(index)", "name": "s\(index)", "lane": ["state": state]]))
}
SidebarLaneStatus.merge(lanes, into: &ws)
let (view, window) = row(ws)
#expect(view.rowBadge.states == [.waiting], "own \(own), lanes \(order): \(view.rowBadge.states)")
window.close()
}
}
}
}
Loading