diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift index 78151b17f8fe..49601b59166d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift @@ -49,10 +49,6 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { /// Compact rows under a badge style (`SidebarRowBadgeStyle`): the dim time since the last /// change on the right, unless the second line shows the last activity already. public var badgeTime: String? - /// While the agent works: when it began (`SidebarWorkspaceDetail.activitySince`). The row - /// shows how long ("4m") beside the working dot when its name keeps its full width - /// (`WorkspaceRowView+Elapsed`); the hover card shows it to the second. - public var workingSince: Date? /// The status block under the name (classic's): the reported entries /// with `agentStatus` on, the log line with `log` on and the progress /// label with `progress` on. Nil when none of those is reported, so a @@ -128,7 +124,6 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { ) trailingStatus = trailing badgeTime = set.secondLine.contains(.lastActivity) ? nil : ws.lastActivity.map { Self.ageText($0, now: now) } - if case .working = activity { workingSince = ws.detail.activitySince } } /// An expanded row (`sidebar.density`, cx-13w3; T3 Code / Codex @@ -153,7 +148,6 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { expanded = true time = ws.lastActivity.map { Self.ageText($0, now: now) } self.meta = meta.isEmpty ? nil : meta.joined(separator: Self.separator) - if case .working = activity { workingSince = ws.detail.activitySince } } /// The age of a change, short: a time today, else days (`3d`), weeks @@ -179,18 +173,6 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { } } - /// 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? { - let minutes = (now.timeIntervalSince(date) / 60).rounded(.down) - guard minutes >= 1 else { return nil } - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - formatter.maximumUnitCount = 2 - formatter.allowedUnits = [.day, .hour, .minute] - return formatter.string(from: minutes * 60) - } - /// The part of `reported` the shown elements draw; nil when empty. static func status(_ reported: SidebarWorkspaceStatus?, _ set: WorkspaceRowElements) -> SidebarWorkspaceStatus? { guard let reported else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift index 61c0ef2e2c26..5a8732c7211a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift @@ -19,7 +19,7 @@ extension WorkspaceRowView { return } // The icon, the cleared unread badge and the idle activity are set in `configure`. - if timeText == nil, shown.workingSince == nil, let time = shown.badgeTime { + if timeText == nil, let time = shown.badgeTime { timeText = time timeLabel.setString(time) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift deleted file mode 100644 index 57bc91b3ad29..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift +++ /dev/null @@ -1,40 +0,0 @@ -import AppKit -import CmuxNextDesign -import Foundation - -/// A working row's running time (Leo 2026-10-20: "4m" when the row is wide): dim, left of the -/// working dot, in place of the age of the last change, and only while the name still fits whole -/// beside it, so a narrow sidebar keeps the row as it was. A one-line status there keeps its place. -/// The hover card shows the time to the second. -extension WorkspaceRowView { - /// Lays out the running time left of `trailing` and returns the new trailing edge; while it - /// shows, arms a tick for the next whole minute. - func layoutElapsed(trailing: CGFloat, textX: CGFloat, cy: CGFloat, height: CGFloat) -> CGFloat { - let now = Date() - let since = timeText == nil && !isShowingPlaceholder ? workingSince : nil - let text = since.flatMap { WorkspaceRowContent.workingElapsed(since: $0, now: now) } ?? "" - elapsedLabel.setString(text) - let w = ceil(elapsedLabel.attributedStringValue.size().width + 2 * Self.labelInset) - let fits = !text.isEmpty && textX + Self.labelInset + title.intrinsicContentSize.width + Metrics.space2 + w <= trailing - elapsedLabel.isHidden = !fits - // The first minute shows nothing yet; wake when it ends. A resize or a new state lays out anyway. - if let since, window != nil, !isHiddenOrHasHiddenAncestor, fits || text.isEmpty { - let intoMinute = now.timeIntervalSince(since).truncatingRemainder(dividingBy: 60) - elapsedTicker.scheduleIfIdle(after: .seconds(60 - max(0, intoMinute))) { @MainActor [weak self] in - self?.needsLayout = true - } - } else { - elapsedTicker.cancel() - } - guard fits else { return trailing } - elapsedLabel.frame = NSRect(x: trailing - w, y: cy - height / 2, width: w, height: height) - return trailing - w - Metrics.space1 - } - - /// A row taken from the pool stops its tick and lays out again, which re-arms it. A row that - /// returns to its own workspace keeps its content (`keepsContentOnReturn`), so the start stays. - func stopElapsed() { - elapsedTicker.cancel() - needsLayout = true - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift index fc9e6d3666bc..d4f577780783 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift @@ -61,7 +61,7 @@ extension WorkspaceRowView { performWithTheme { // An idle workspace nobody named reads quiet until it is selected, hovered or busy. title.textColor = isQuiet && !isSelected && !isSecondarySelected && !isHovered ? Palette.textSecondary : Palette.textPrimary - subtitle.setColor(Palette.textSecondary); [timeLabel, metaLabel, elapsedLabel].forEach { $0.setColor(Palette.textTertiary) } + subtitle.setColor(Palette.textSecondary); [timeLabel, metaLabel].forEach { $0.setColor(Palette.textTertiary) } tabCount.setColor(Palette.textTertiary) mutedMark.setTint(Palette.textTertiary) if let machine { machineMark.setTint(machine.color) } @@ -210,7 +210,6 @@ extension WorkspaceRowView { machineMark.frame = NSRect(x: textX + Self.labelInset, y: cy - ind / 2, width: ind, height: ind) textX = machineMark.frame.maxX + Metrics.space1 } - trailing = layoutElapsed(trailing: trailing, textX: textX, cy: cy, height: sh) let textW = max(0, trailing - textX) let titleArrival = titleArrivalPending || titleArrivalActive title.isHidden = renaming || (isShowingPlaceholder && !titleArrival) @@ -232,7 +231,7 @@ extension WorkspaceRowView { updateCalmTitleTip() subtitle.wantsLayer = true subtitle.setTrailingFade(occlusion: overlayStart.map { max(0, subtitle.frame.maxX - ($0 - Metrics.space1)) } ?? 0, fade: fade, animated: fades) - for mark in [activity, rowBadge, agentMark, mutedMark, machineMark, audioButton, presenceDots, timeLabel, elapsedLabel] as [NSView] { + for mark in [activity, rowBadge, agentMark, mutedMark, machineMark, audioButton, presenceDots, timeLabel] as [NSView] { fadeOpacity(mark, to: overlayStart.map { mark.frame.maxX > $0 - Metrics.space1 } == true ? 0 : 1, fades: fades) } layoutStatus(x: textX, width: max(0, b.width - Metrics.space3 - textX), head: b.maxY, bottom: full.maxY) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index f9dc65e61712..b6975af60620 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -3,7 +3,6 @@ import Observation import CmuxAgentBrands import CmuxNextDesign import CmuxNextIcons -import CmuxNextWakeups import QuartzCore final class WorkspaceRowView: SidebarRowView { @@ -14,10 +13,6 @@ final class WorkspaceRowView: SidebarRowView { let subtitle = SidebarRowView.label(font: SidebarStyle.subtitleFont) /// Expanded rows (`sidebar.density`, cx-13w3; WorkspaceRowView+Expanded): the age and the meta line. let timeLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont), metaLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont) - /// How long the agent has worked ("4m"), beside the working dot when the name has room (WorkspaceRowView+Elapsed). - let elapsedLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont) - var workingSince: Date? - let elapsedTicker = DemandTimer(owner: "sidebar.row.elapsed") let activity = StatusIndicatorView() /// The running agent's brand mark (`SidebarAgentMarkVariant`); hidden when off. internal let agentMark = DecorativeImageView() // the row's label says the agent's state @@ -132,7 +127,7 @@ final class WorkspaceRowView: SidebarRowView { mutedMark.setAccessibilityElement(false) machineMark.imageScaling = .scaleProportionallyDown machineMark.isHidden = true - [icon, title, subtitle, timeLabel, metaLabel, elapsedLabel, activity, agentMark, mutedMark, machineMark, audioButton, badge, rowBadge, presenceDots, closeButton, moreButton, pinButton, disclosureButton, tabCount, prBadge, placeholderBar, statusBlock, + [icon, title, subtitle, timeLabel, metaLabel, activity, agentMark, mutedMark, machineMark, audioButton, badge, rowBadge, presenceDots, closeButton, moreButton, pinButton, disclosureButton, tabCount, prBadge, placeholderBar, statusBlock, treeLine, pullRequests, keycap].forEach(addSubview) setUpSessionTree() disclosureButton.isHidden = true @@ -192,7 +187,6 @@ final class WorkspaceRowView: SidebarRowView { onToggleTabs = nil onToggleAudio = nil title.stopMarquee() - stopElapsed() title.layer?.removeAnimation(forKey: "workspace-title-motion") lastTitle = nil titleArrivalGeneration &+= 1 @@ -268,13 +262,10 @@ final class WorkspaceRowView: SidebarRowView { subtitle.setFont(SidebarStyle.subtitleFont) 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). - // 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) + // No status words and no running time on a row (Leo 2026-10-21): its badge's dot says it; the label reads the chip. + let trailingText = 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 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowNoDoneTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowNoDoneTests.swift index 675604e0b2c4..9d441791417a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowNoDoneTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarRowNoDoneTests.swift @@ -6,9 +6,11 @@ import CmuxNextAgentActivity 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. +/// Leo 2026-10-21, on rows that said "Done" beside a blue dot and "10m" beside the working mark +/// again: "WHO BROUGHT BACK THE DONE AND THE TIMERS" (#23390 brought the Done back, #22826 the +/// timer). A workspace row never says Done and never shows how long its agent has worked: a +/// finished, unread chat keeps only its quiet unread dot, a working one its spinner. What asks for +/// the user (needs you, failed) still reads. @MainActor @Suite(.serialized) struct SidebarRowNoDoneTests { private func workspace(_ chip: SidebarPriorityEntry.State?) -> SidebarWorkspace { @@ -65,6 +67,17 @@ struct SidebarRowNoDoneTests { } } + @Test func aWorkingRowShowsNoRunningTime() { + var ws = SidebarWorkspace(id: WorkspaceID("ws"), title: "Fix the build", lastActivity: Date(), agentWorking: true, + activity: .working(progress: nil)) + ws.detail.activitySince = Date().addingTimeInterval(-10 * 60) + let (view, window) = row(ws) + // "10m", "1h 3m", "2d": a number and a unit, the running time's shape. + let timers = shownText(view).filter { $0.range(of: #"^\d+\s?(s|m|h|d|min|hr)\b"#, options: .regularExpression) != nil } + #expect(timers.isEmpty, "the row draws \(shownText(view))") + window.close() + } + /// 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() { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift deleted file mode 100644 index 2fcab20bb86a..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import AppKit -import CmuxNextDesign -@testable import CmuxNextSidebar -import Foundation -import Testing - -/// A working row shows how long the agent has worked ("4m", Leo 2026-10-20) beside its dot when -/// the name keeps its full width; a narrow row stays as it was. -@MainActor @Suite(.serialized) -struct WorkspaceRowElapsedTests { - private func working(_ title: String, since: Date?) -> SidebarWorkspace { - // An agent's last update gives the row an age, which the running time replaces. - var ws = SidebarWorkspace(id: WorkspaceID("ws"), title: title, lastActivity: Date(), agentWorking: true, - activity: .working(progress: nil)) - ws.detail.activitySince = since - return ws - } - - /// The row as the list shows it, `width` wide, in a window so its minute tick may arm. - private func row(_ ws: SidebarWorkspace, width: CGFloat) -> (WorkspaceRowView, NSWindow) { - let content = WorkspaceRowContent(ws, preferences: .defaults) - 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: width, 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: width, height: 28) - view.configure(ws, row: row, badgeStyle: .corner) - view.layoutSubtreeIfNeeded() - return (view, window) - } - - @Test func theRunningTimeReadsInWholeMinutes() { - let now = Date() - #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-59), now: now) == nil) - #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-4 * 60 - 30), now: now) == "4m") - #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-63 * 60), now: now) == "1h 3m") - } - - @Test func onlyAWorkingRowCarriesItsStart() { - let since = Date().addingTimeInterval(-300) - #expect(WorkspaceRowContent(working("Fix the build", since: since), preferences: .defaults).workingSince == since) - var idle = working("Fix the build", since: since) - (idle.agentWorking, idle.activity) = (false, .idle) - #expect(WorkspaceRowContent(idle, preferences: .defaults).workingSince == nil) - } - - @Test func aWideRowShowsTheTimeBesideTheDot() { - let (view, window) = row(working("Fix the build", since: Date().addingTimeInterval(-4 * 60 - 30)), width: 320) - #expect(!view.elapsedLabel.isHidden) - #expect(view.elapsedLabel.stringValue == "4m") - // Between the whole name and the trailing marks. - #expect(view.title.frame.maxX <= view.elapsedLabel.frame.minX) - #expect(view.title.frame.width >= view.title.intrinsicContentSize.width) - #expect(view.elapsedLabel.frame.maxX <= view.rowBadge.frame.minX) - #expect(view.timeLabel.isHidden) - window.close() - } - - /// A row parked in the pool and taken back for its own workspace keeps its content, and its time. - @Test func aRowBackFromThePoolKeepsItsTime() { - let ws = working("Fix the build", since: Date().addingTimeInterval(-4 * 60 - 30)) - let (view, window) = row(ws, width: 320) - view.prepareForReuse(key: .workspace(ws.id)) - let content = WorkspaceRowContent(ws, preferences: .defaults) - view.configure(ws, 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), badgeStyle: .corner) - view.layoutSubtreeIfNeeded() - #expect(!view.elapsedLabel.isHidden) - #expect(view.elapsedLabel.stringValue == "4m") - window.close() - } - - @Test func aNarrowRowKeepsItsName() { - let (view, window) = row(working("Redesign the source control pane's commit flow", since: Date().addingTimeInterval(-600)), - width: 200) - #expect(view.elapsedLabel.isHidden) - window.close() - } - - @Test func underAMinuteShowsNothing() { - let (view, window) = row(working("Fix the build", since: Date().addingTimeInterval(-10)), width: 320) - #expect(view.elapsedLabel.isHidden) - #expect(view.timeLabel.isHidden) - window.close() - } - - /// A quiet row keeps the age of its last change. - @Test func anIdleRowKeepsItsAge() { - var idle = working("Fix the build", since: nil) - (idle.agentWorking, idle.activity) = (false, .idle) - let (view, window) = row(idle, width: 320) - #expect(view.elapsedLabel.isHidden) - #expect(!view.timeLabel.isHidden) - window.close() - } -}