From 7a46c82b944e260c3d60530adc9ac59125b65e3f Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 05:48:52 -0700 Subject: [PATCH 01/11] test: cover structured agent sidebar previews --- cmuxTests/SidebarAppKitRowCellTests.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index b54c7394f304..545c4becba07 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -13,6 +13,7 @@ struct SidebarAppKitRowCellTests { title: String = "Workspace", customDescription: String? = nil, isPinned: Bool = false, + latestConversationMessage: String? = nil, metadataEntries: [SidebarStatusEntry] = [], metadataBlocks: [SidebarMetadataBlock] = [] ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { @@ -30,7 +31,7 @@ struct SidebarAppKitRowCellTests { remoteStateHelpText: "", showsRemoteReconnectAffordance: false, copyableSidebarSSHError: nil, - latestConversationMessage: nil, + latestConversationMessage: latestConversationMessage, metadataEntries: metadataEntries, metadataBlocks: metadataBlocks, latestLog: nil, @@ -62,6 +63,7 @@ struct SidebarAppKitRowCellTests { canClose: Bool = true, settings: SidebarTabItemSettingsSnapshot? = nil, customDescription: String? = nil, + latestConversationMessage: String? = nil, metadataEntries: [SidebarStatusEntry] = [], metadataBlocks: [SidebarMetadataBlock] = [], shortcutHintText: String? = nil, @@ -75,6 +77,7 @@ struct SidebarAppKitRowCellTests { snapshot: makeSnapshot( customDescription: customDescription, isPinned: isPinned, + latestConversationMessage: latestConversationMessage, metadataEntries: metadataEntries, metadataBlocks: metadataBlocks ), @@ -2058,6 +2061,22 @@ struct SidebarAppKitRowCellTests { #expect(!Self.makeModel(settings: settings).settings.details[keyPath: detailKey]) } } + + @Test + func structuredAgentActivityHidesConversationPreview() { + let defaults = Self.makeDefaults() + defaults.set(true, forKey: IMessageModeSettings.key) + defaults.set(true, forKey: "sidebarShowAgentActivity") + let model = Self.makeModel( + settings: SidebarTabItemSettingsSnapshot(defaults: defaults), + latestConversationMessage: "last agent message" + ) + let cell = Self.configuredCell(model: model) + + #expect(Self.descendants(of: cell) + .compactMap { $0 as? SidebarRowTextView } + .allSatisfy { $0.isHidden || $0.stringValue != "last agent message" }) + } } @Suite From 792805db776094de5255ec576b3a0b2e858e00df Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 05:50:24 -0700 Subject: [PATCH 02/11] sidebar: show agent counts on collapsed groups --- Resources/Localizable.xcstrings | 125 ++++++++++++++++++ Sources/ContentView.swift | 18 ++- .../Cells/SidebarGroupHeaderRowModel.swift | 3 + .../Cells/SidebarGroupHeaderRowView.swift | 61 ++++++++- .../Cells/SidebarWorkspaceRowCellView.swift | 11 +- ...idebarWorkspaceSnapshotRefreshPolicy.swift | 5 +- Sources/SidebarAgentActivitySummary.swift | 67 ++++++++++ Sources/SidebarWorkspaceGroupHeaderView.swift | 25 ++++ .../SidebarWorkspaceGroupRowSnapshot.swift | 3 + Sources/SidebarWorkspaceSnapshotBuilder.swift | 1 + Sources/SidebarWorkspaceSnapshotFactory.swift | 4 + .../VerticalTabsSidebar+WorkspaceGroups.swift | 21 +++ cmuxTests/SidebarAppKitRowCellTests.swift | 58 ++++++-- .../SidebarWorkspaceRowSuspensionTests.swift | 1 + ...rWorkspaceSnapshotAgentActivityTests.swift | 24 +++- ...rWorkspaceSnapshotRefreshPolicyTests.swift | 4 +- ...SidebarWorkspaceTableSuspensionTests.swift | 4 +- .../WorkspaceSidebarObservationTests.swift | 57 ++++++++ 18 files changed, 466 insertions(+), 26 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7d71a134ac02..cf9c4a48f05a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -257164,6 +257164,131 @@ } } }, + "workspaceGroup.agentActivity.a11y": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوكلاء قيد التشغيل: %1$lld. بحاجة إلى إدخال: %2$lld." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokrenuti agenti: %1$lld. Potreban unos: %2$lld." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kørende agenter: %1$lld. Kræver input: %2$lld." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktive Agenten: %1$lld. Eingabe erforderlich: %2$lld." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running agents: %1$lld. Need input: %2$lld." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agentes en ejecución: %1$lld. Necesitan entrada: %2$lld." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Agents en cours : %1$lld. Saisie requise : %2$lld." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Agenti in esecuzione: %1$lld. Richiedono input: %2$lld." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のエージェント: %1$lld。入力待ち: %2$lld。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្នាក់ងារកំពុងដំណើរការ៖ %1$lld។ ត្រូវការបញ្ចូល៖ %2$lld។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중인 에이전트: %1$lld. 입력 필요: %2$lld." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kjørende agenter: %1$lld. Trenger inndata: %2$lld." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Działające agenty: %1$lld. Wymaga danych wejściowych: %2$lld." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agentes em execução: %1$lld. Precisam de entrada: %2$lld." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работающих агентов: %1$lld. Требуется ввод: %2$lld." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอเจนต์ที่กำลังทำงาน: %1$lld ต้องการอินพุต: %2$lld" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışan aracılar: %1$lld. Girdi gereken: %2$lld." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агентів у роботі: %1$lld. Потрібне введення: %2$lld." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中的代理:%1$lld。需要输入:%2$lld。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行中的代理:%1$lld。需要輸入:%2$lld。" + } + } + } + }, "workspaceGroup.autoName.numbered": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5b7b6d67f69b..d46e71d95f81 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11918,12 +11918,16 @@ struct VerticalTabsSidebar: View, Equatable { ) ) }) + let agentActivityCountsByWorkspaceId = workspaceRowInputsById.mapValues { + $0.workspace.agentActivityCounts + } let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( group.id, sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], + agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -11947,6 +11951,7 @@ struct VerticalTabsSidebar: View, Equatable { return sidebarWorkspaceGroupTableConfiguration( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[groupId] ?? [], + agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, renderContext: renderContext ) case .workspace(let workspaceId): @@ -13591,6 +13596,9 @@ struct VerticalTabsSidebar: View, Equatable { ) ) }) + let agentActivityCountsByWorkspaceId = workspaceRowInputsById.mapValues { + $0.workspace.agentActivityCounts + } let _ = anchorCwdRevision let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( @@ -13598,6 +13606,7 @@ struct VerticalTabsSidebar: View, Equatable { sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], + agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -15699,9 +15708,12 @@ struct TabItemView: View, Equatable { let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText - let conversationMessageSubtitle = !settings.hidesAllDetails && settings.iMessageModeEnabled - ? workspaceSnapshot.latestConversationMessage?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - : nil + let conversationMessageSubtitle = SidebarAgentActivitySummary.conversationSubtitle( + showsAgentActivity: showsAgentActivity, + hidesAllDetails: settings.hidesAllDetails, + iMessageModeEnabled: settings.iMessageModeEnabled, + message: workspaceSnapshot.latestConversationMessage + ) let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let subtitleLineLimit = latestNotificationSubtitle == nil ? 2 : settings.notificationMessageLineLimit // Bound notification payloads before shaping so pathological text stays cheap in lazy, Equatable rows. diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift index 916e5d403ebf..3ad20ef33a35 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift @@ -19,6 +19,9 @@ struct SidebarGroupHeaderRowModel: Equatable, Hashable { let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle let memberCount: Int + let showsAgentActivity: Bool + let runningAgentCount: Int + let needsInputAgentCount: Int var anchorUnreadCount: Int var canMarkRead: Bool var canMarkUnread: Bool diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 554db0a33260..7dd20618af4d 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -17,6 +17,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { private let chevronButton = SidebarHeaderGlyphButton() private let iconImageView = NSImageView() private let nameField = NSTextField(labelWithString: "") + private let agentActivityField = NSTextField(labelWithString: "") // Direct-draw badge (shared with workspace rows): NSTextField's // intrinsic insets shift single digits off the circle's optical center. private let unreadBadgeView = SidebarRowUnreadBadgeView() @@ -72,6 +73,8 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { nameField.cell?.truncatesLastVisibleLine = true addSubview(nameField) + addSubview(agentActivityField) + addSubview(unreadBadgeView) plusButton.onClick = { [weak self] in self?.actions?.onTapPlus() } @@ -180,6 +183,43 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { ) nameField.textColor = model.isAnchorActive ? .labelColor : NSColor.labelColor.withAlphaComponent(0.9) + agentActivityField.isHidden = !model.showsAgentActivity + if model.showsAgentActivity { + let font = NSFont.systemFont( + ofSize: GlobalFontMagnification.scaledSize(metrics.nameFontSize, percent: percent), + weight: .semibold + ) + let running = "▶ \(model.runningAgentCount)" + let needsInput = "! \(model.needsInputAgentCount)" + let text = "\(running) \(needsInput)" + let attributed = NSMutableAttributedString( + string: text, + attributes: [.font: font] + ) + attributed.addAttribute( + .foregroundColor, + value: NSColor.systemBlue, + range: NSRange(location: 0, length: running.utf16.count) + ) + attributed.addAttribute( + .foregroundColor, + value: NSColor.systemOrange, + range: NSRange( + location: text.utf16.count - needsInput.utf16.count, + length: needsInput.utf16.count + ) + ) + agentActivityField.attributedStringValue = attributed + let accessibilityText = SidebarAgentActivitySummary.accessibilityText( + counts: .init( + running: model.runningAgentCount, + needsInput: model.needsInputAgentCount + ) + ) + agentActivityField.toolTip = accessibilityText + agentActivityField.setAccessibilityLabel(accessibilityText) + } + let showsBadge = model.anchorUnreadCount > 0 unreadBadgeView.isHidden = !showsBadge if showsBadge { @@ -393,8 +433,12 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { ) } - let nameAvailable = max(0, (plusButton.frame.minX - 4) - x - - (badgeSize.width > 0 ? badgeSize.width + 6 : 0)) + let activitySize = agentActivityField.isHidden + ? .zero + : agentActivityField.attributedStringValue.size() + let trailingDetailsWidth = (activitySize.width > 0 ? ceil(activitySize.width) + 8 : 0) + + (badgeSize.width > 0 ? badgeSize.width + 6 : 0) + let nameAvailable = max(0, (plusButton.frame.minX - 4) - x - trailingDetailsWidth) let nameSize = nameField.attributedStringValue.size() // The field owns ALL remaining width (truncation only when genuinely // out of space); the badge tracks the measured text width instead. @@ -404,8 +448,19 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { width: nameAvailable, height: ceil(nameSize.height) ) + var detailX = x + min(ceil(nameSize.width), nameAvailable) + if !agentActivityField.isHidden { + detailX += 8 + agentActivityField.frame = NSRect( + x: detailX, + y: midY - ceil(activitySize.height) / 2, + width: ceil(activitySize.width), + height: ceil(activitySize.height) + ) + detailX = agentActivityField.frame.maxX + } if !unreadBadgeView.isHidden { - let badgeX = x + min(ceil(nameSize.width), nameAvailable) + 6 + let badgeX = detailX + 6 unreadBadgeView.frame = NSRect( x: badgeX, y: midY - badgeSize.height / 2, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 1773b92f590d..241bd23ae3b0 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -516,11 +516,12 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } } - let conversationSubtitle: String? = { - guard !settings.hidesAllDetails, settings.iMessageModeEnabled else { return nil } - let trimmed = snapshot.latestConversationMessage?.trimmingCharacters(in: .whitespacesAndNewlines) - return (trimmed?.isEmpty == false) ? trimmed : nil - }() + let conversationSubtitle = SidebarAgentActivitySummary.conversationSubtitle( + showsAgentActivity: model.showsAgentActivity, + hidesAllDetails: settings.hidesAllDetails, + iMessageModeEnabled: settings.iMessageModeEnabled, + message: snapshot.latestConversationMessage + ) let effectiveSubtitle = model.latestNotificationText ?? conversationSubtitle let subtitleLineLimit = model.latestNotificationText == nil ? 2 : settings.notificationMessageLineLimit subtitleView.isHidden = effectiveSubtitle == nil diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 50defc0e2c56..2706274d178b 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -16,6 +16,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { let checklistTotalCount: Int let checklistFirstUncheckedText: String? let activeCodingAgentCount: Int + let agentActivityCounts: SidebarAgentActivitySummary.Counts } var contextMenuImmediateFields: ContextMenuImmediateFields { @@ -33,7 +34,8 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { checklistCompletedCount: checklistCompletedCount, checklistTotalCount: checklistTotalCount, checklistFirstUncheckedText: checklistFirstUncheckedText, - activeCodingAgentCount: activeCodingAgentCount + activeCodingAgentCount: activeCodingAgentCount, + agentActivityCounts: agentActivityCounts ) } @@ -58,6 +60,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { // The loading spinner is a leading row glyph like mediaActivity, so // it also updates immediately while the context menu is open. activeCodingAgentCount: snapshot.activeCodingAgentCount, + agentActivityCounts: snapshot.agentActivityCounts, compactGitBranchSummaryText: compactGitBranchSummaryText, compactDirectoryCandidates: compactDirectoryCandidates, compactBranchDirectoryCandidates: compactBranchDirectoryCandidates, diff --git a/Sources/SidebarAgentActivitySummary.swift b/Sources/SidebarAgentActivitySummary.swift index ccafebaafd94..e903199fb3aa 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -1,6 +1,18 @@ import Foundation enum SidebarAgentActivitySummary { + struct Counts: Equatable, Hashable, Sendable { + var running = 0 + var needsInput = 0 + + static func + (lhs: Self, rhs: Self) -> Self { + Self( + running: lhs.running + rhs.running, + needsInput: lhs.needsInput + rhs.needsInput + ) + } + } + static func visibleActiveCodingAgentCount( showsAgentActivity: Bool, statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] @@ -16,4 +28,59 @@ enum SidebarAgentActivitySummary { partial + panelStates.values.reduce(0) { $1 == .running ? $0 + 1 : $0 } } } + + static func visibleCounts( + showsAgentActivity: Bool, + countsByWorkspace: @autoclosure () -> [Counts] + ) -> Counts { + guard showsAgentActivity else { return Counts() } + return countsByWorkspace().reduce(Counts(), +) + } + + static func counts( + statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] + ) -> Counts { + statesByPanelId.values.reduce(Counts()) { partial, panelStates in + let states = panelStates + .filter { !AgentHibernationLifecycleStatusKeys.isManualKey($0.key) } + .map(\.value) + var result = partial + if states.contains(.needsInput) { + result.needsInput += 1 + } else if states.contains(.running) { + result.running += 1 + } + return result + } + } + + static func visibleCounts( + showsAgentActivity: Bool, + statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] + ) -> Counts { + guard showsAgentActivity else { return Counts() } + return counts(statesByPanelId: statesByPanelId()) + } + + static func accessibilityText(counts: Counts) -> String { + String.localizedStringWithFormat( + String( + localized: "workspaceGroup.agentActivity.a11y", + defaultValue: "Running agents: %1$lld. Need input: %2$lld." + ), + Int64(counts.running), + Int64(counts.needsInput) + ) + } + + static func conversationSubtitle( + showsAgentActivity: Bool, + hidesAllDetails: Bool, + iMessageModeEnabled: Bool, + message: String? + ) -> String? { + guard !showsAgentActivity, !hidesAllDetails, iMessageModeEnabled else { return nil } + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 3fcdf4b16d6d..d69cc21f8474 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -22,6 +22,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { lhs.isMultiSelected == rhs.isMultiSelected && lhs.multiSelectionBackgroundStyle == rhs.multiSelectionBackgroundStyle && lhs.memberCount == rhs.memberCount && + lhs.showsAgentActivity == rhs.showsAgentActivity && + lhs.runningAgentCount == rhs.runningAgentCount && + lhs.needsInputAgentCount == rhs.needsInputAgentCount && lhs.anchorUnreadCount == rhs.anchorUnreadCount && lhs.canMarkRead == rhs.canMarkRead && lhs.canMarkUnread == rhs.canMarkUnread && @@ -55,6 +58,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle let memberCount: Int + let showsAgentActivity: Bool + let runningAgentCount: Int + let needsInputAgentCount: Int let anchorUnreadCount: Int let canMarkRead: Bool let canMarkUnread: Bool @@ -128,6 +134,12 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { String(localized: "workspaceGroup.pinned.tooltip", defaultValue: "Pinned group") } + private var agentActivityAccessibilityText: String { + SidebarAgentActivitySummary.accessibilityText( + counts: .init(running: runningAgentCount, needsInput: needsInputAgentCount) + ) + } + private var multiSelectionBackgroundColor: Color { guard let color = multiSelectionBackgroundStyle.color else { return .clear @@ -185,6 +197,19 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { .foregroundStyle(isAnchorActive ? Color.primary : Color.primary.opacity(0.9)) .lineLimit(1) .truncationMode(.tail) + if showsAgentActivity { + HStack(spacing: 6) { + Text("▶ \(runningAgentCount)") + .foregroundStyle(Color.blue) + Text("! \(needsInputAgentCount)") + .foregroundStyle(Color.orange) + } + .cmuxFont(size: metrics.nameFontSize, weight: .semibold) + .fixedSize() + .safeHelp(agentActivityAccessibilityText) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(agentActivityAccessibilityText)) + } if anchorUnreadCount > 0 { Text("\(anchorUnreadCount)") .cmuxFont(size: metrics.unreadFontSize, weight: .semibold) diff --git a/Sources/SidebarWorkspaceGroupRowSnapshot.swift b/Sources/SidebarWorkspaceGroupRowSnapshot.swift index e5affaa53ed1..18ba00246618 100644 --- a/Sources/SidebarWorkspaceGroupRowSnapshot.swift +++ b/Sources/SidebarWorkspaceGroupRowSnapshot.swift @@ -19,6 +19,9 @@ struct SidebarWorkspaceGroupRowSnapshot { let isMultiSelected: Bool let multiSelectionBackgroundStyle: SidebarWorkspaceRowBackgroundStyle let memberCount: Int + let showsAgentActivity: Bool + let runningAgentCount: Int + let needsInputAgentCount: Int let anchorUnreadCount: Int let canMarkRead: Bool let canMarkUnread: Bool diff --git a/Sources/SidebarWorkspaceSnapshotBuilder.swift b/Sources/SidebarWorkspaceSnapshotBuilder.swift index 5da5a3ae4f9c..5fd9aaac3e77 100644 --- a/Sources/SidebarWorkspaceSnapshotBuilder.swift +++ b/Sources/SidebarWorkspaceSnapshotBuilder.swift @@ -48,6 +48,7 @@ struct SidebarWorkspaceSnapshotBuilder { let latestLog: SidebarLogEntry? let progress: SidebarProgressState? let activeCodingAgentCount: Int + let agentActivityCounts: SidebarAgentActivitySummary.Counts let compactGitBranchSummaryText: String? let compactDirectoryCandidates: [String] let compactBranchDirectoryCandidates: [String] diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 835a6c098674..65cdf6a27de9 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -101,6 +101,10 @@ struct SidebarWorkspaceSnapshotFactory { showsAgentActivity: showsAgentActivity, statesByPanelId: workspace.agentLifecycleStatesByPanelId ), + agentActivityCounts: SidebarAgentActivitySummary.visibleCounts( + showsAgentActivity: showsAgentActivity, + statesByPanelId: workspace.agentLifecycleStatesByPanelId + ), compactGitBranchSummaryText: compactGitBranchSummaryText, compactDirectoryCandidates: compactDirectoryCandidates, compactBranchDirectoryCandidates: compactBranchDirectoryCandidates, diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index ed3c2d22e84b..e68769d98bf3 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -9,6 +9,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupTableConfiguration( group: WorkspaceGroup, memberWorkspaceIds: [UUID], + agentActivityCountsByWorkspaceId: [UUID: SidebarAgentActivitySummary.Counts], renderContext: WorkspaceListRenderContext ) -> SidebarWorkspaceTableRowConfiguration { let settings = renderContext.tabItemSettings @@ -32,6 +33,11 @@ extension VerticalTabsSidebar { ) let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement + let showsAgentActivity = group.isCollapsed && renderContext.showsAgentActivity + let agentActivity = SidebarAgentActivitySummary.visibleCounts( + showsAgentActivity: showsAgentActivity, + countsByWorkspace: memberWorkspaceIds.compactMap { agentActivityCountsByWorkspaceId[$0] } + ) // The AppKit controller applies the current unread snapshot after row // construction, keeping this root projection outside Observation. let unreadSnapshot = SidebarUnreadSnapshot() @@ -84,6 +90,9 @@ extension VerticalTabsSidebar { isMultiSelected: isMultiSelected, multiSelectionBackgroundStyle: multiSelectionBackgroundStyle, memberCount: memberWorkspaceIds.count, + showsAgentActivity: showsAgentActivity, + runningAgentCount: agentActivity.running, + needsInputAgentCount: agentActivity.needsInput, anchorUnreadCount: anchorUnreadCount, canMarkRead: canMarkAnchorRead, canMarkUnread: canMarkAnchorUnread, @@ -252,6 +261,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupRowSnapshot( group: WorkspaceGroup, memberWorkspaceIds: [UUID], + agentActivityCountsByWorkspaceId: [UUID: SidebarAgentActivitySummary.Counts], renderContext: WorkspaceListRenderContext, unreadSnapshot: SidebarUnreadSnapshot, notificationIndex: SidebarWorkspaceNotificationIndex, @@ -279,6 +289,11 @@ extension VerticalTabsSidebar { ) let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement + let showsAgentActivity = group.isCollapsed && renderContext.showsAgentActivity + let agentActivity = SidebarAgentActivitySummary.visibleCounts( + showsAgentActivity: showsAgentActivity, + countsByWorkspace: memberWorkspaceIds.compactMap { agentActivityCountsByWorkspaceId[$0] } + ) let anchorUnreadCount: Int = { if group.isCollapsed { return memberWorkspaceIds.reduce(0) { partial, workspaceId in @@ -333,6 +348,9 @@ extension VerticalTabsSidebar { isMultiSelected: isMultiSelected, multiSelectionBackgroundStyle: multiSelectionBackgroundStyle, memberCount: memberWorkspaceIds.count, + showsAgentActivity: showsAgentActivity, + runningAgentCount: agentActivity.running, + needsInputAgentCount: agentActivity.needsInput, anchorUnreadCount: anchorUnreadCount, canMarkRead: canMarkAnchorRead, canMarkUnread: canMarkAnchorUnread, @@ -383,6 +401,9 @@ extension VerticalTabsSidebar { isMultiSelected: snapshot.isMultiSelected, multiSelectionBackgroundStyle: snapshot.multiSelectionBackgroundStyle, memberCount: snapshot.memberCount, + showsAgentActivity: snapshot.showsAgentActivity, + runningAgentCount: snapshot.runningAgentCount, + needsInputAgentCount: snapshot.needsInputAgentCount, anchorUnreadCount: snapshot.anchorUnreadCount, canMarkRead: snapshot.canMarkRead, canMarkUnread: snapshot.canMarkUnread, diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 545c4becba07..5529efaba647 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -37,6 +37,7 @@ struct SidebarAppKitRowCellTests { latestLog: nil, progress: nil, activeCodingAgentCount: 0, + agentActivityCounts: .init(), compactGitBranchSummaryText: nil, compactDirectoryCandidates: [], compactBranchDirectoryCandidates: [], @@ -2082,24 +2083,28 @@ struct SidebarAppKitRowCellTests { @Suite @MainActor struct SidebarPinnedIndicatorColorTests { - @Test - func pinnedGroupUsesWorkspacePinColor() throws { - let workspaceCell = SidebarAppKitRowCellTests.configuredCell( - model: SidebarAppKitRowCellTests.makeModel(isPinned: true) - ) - let groupCell = SidebarGroupHeaderTableCellView() - groupCell.configurePresentation(model: SidebarGroupHeaderRowModel( + private func makeGroupModel( + isCollapsed: Bool = false, + isPinned: Bool = false, + showsAgentActivity: Bool = false, + runningAgentCount: Int = 0, + needsInputAgentCount: Int = 0 + ) -> SidebarGroupHeaderRowModel { + SidebarGroupHeaderRowModel( groupId: UUID(), anchorWorkspaceId: UUID(), name: "Group", iconSymbol: "folder", tintHex: nil, - isCollapsed: false, - isPinned: true, + isCollapsed: isCollapsed, + isPinned: isPinned, isAnchorActive: false, isMultiSelected: false, multiSelectionBackgroundStyle: .clear, memberCount: 1, + showsAgentActivity: showsAgentActivity, + runningAgentCount: runningAgentCount, + needsInputAgentCount: needsInputAgentCount, anchorUnreadCount: 0, canMarkRead: false, canMarkUnread: false, @@ -2117,7 +2122,16 @@ struct SidebarPinnedIndicatorColorTests { isBeingDragged: false, topDropIndicatorVisible: false, bottomDropIndicatorVisible: false - )) + ) + } + + @Test + func pinnedGroupUsesWorkspacePinColor() throws { + let workspaceCell = SidebarAppKitRowCellTests.configuredCell( + model: SidebarAppKitRowCellTests.makeModel(isPinned: true) + ) + let groupCell = SidebarGroupHeaderTableCellView() + groupCell.configurePresentation(model: makeGroupModel(isPinned: true)) let workspacePin = try #require( SidebarAppKitRowCellTests.descendants(of: workspaceCell) @@ -2132,4 +2146,28 @@ struct SidebarPinnedIndicatorColorTests { #expect(groupPin.contentTintColor == workspacePin.contentTintColor) } + + @Test + func collapsedGroupShowsRunningAndNeedsInputCounts() throws { + let cell = SidebarGroupHeaderTableCellView() + cell.configurePresentation(model: makeGroupModel( + isCollapsed: true, + showsAgentActivity: true, + runningAgentCount: 4, + needsInputAgentCount: 2 + )) + + let activity = try #require( + SidebarAppKitRowCellTests.descendants(of: cell) + .compactMap { $0 as? NSTextField } + .first { $0.stringValue.contains("▶ 4") } + ) + + #expect(!activity.isHidden) + #expect(activity.stringValue.contains("! 2")) + #expect(activity.accessibilityLabel() == SidebarAgentActivitySummary.accessibilityText( + counts: .init(running: 4, needsInput: 2) + )) + } + } diff --git a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift index ea86170e9d8e..bfd5453e6cfa 100644 --- a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift +++ b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift @@ -33,6 +33,7 @@ struct SidebarWorkspaceRowSuspensionTests { latestLog: nil, progress: nil, activeCodingAgentCount: 0, + agentActivityCounts: .init(), compactGitBranchSummaryText: nil, compactDirectoryCandidates: [], compactBranchDirectoryCandidates: [], diff --git a/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift b/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift index 0ac8347638c6..993e443296dc 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift @@ -28,11 +28,13 @@ extension SidebarWorkspaceSnapshotRefreshPolicyTests { @Test func contextMenuAgentActivityChangeUpdatesDisplayedSpinnerImmediately() { let current = Self.snapshot( latestConversationMessage: "old message", - activeCodingAgentCount: 0 + activeCodingAgentCount: 0, + agentActivityCounts: .init(needsInput: 1) ) let next = Self.snapshot( latestConversationMessage: "new message", - activeCodingAgentCount: 1 + activeCodingAgentCount: 1, + agentActivityCounts: .init(running: 1) ) let decision = SidebarWorkspaceSnapshotRefreshPolicy().decision( @@ -43,11 +45,29 @@ extension SidebarWorkspaceSnapshotRefreshPolicyTests { ) #expect(decision.workspaceSnapshotStorage?.activeCodingAgentCount == 1) + #expect(decision.workspaceSnapshotStorage?.agentActivityCounts == .init(running: 1)) #expect(decision.workspaceSnapshotStorage?.latestConversationMessage == "old message") #expect(decision.pendingWorkspaceSnapshot == next) #expect(decision.hasDeferredWorkspaceObservationInvalidation) } + @Test func contextMenuNeedsInputToIdleUpdatesStructuredActivityWithoutSpinnerChange() { + let current = Self.snapshot(agentActivityCounts: .init(needsInput: 1)) + let next = Self.snapshot(agentActivityCounts: .init()) + + let decision = SidebarWorkspaceSnapshotRefreshPolicy().decision( + current: current, + next: next, + force: false, + contextMenuVisible: true + ) + + #expect(decision.workspaceSnapshotStorage?.activeCodingAgentCount == 0) + #expect(decision.workspaceSnapshotStorage?.agentActivityCounts == .init()) + #expect(decision.pendingWorkspaceSnapshot == nil) + #expect(!decision.hasDeferredWorkspaceObservationInvalidation) + } + @Test func presentationKeyChangesWhenAgentActivityVisibilityChanges() { let hidden = Self.presentationKey(showsAgentActivity: false) let visible = Self.presentationKey(showsAgentActivity: true) diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 80d1b667b328..89f44f67d022 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -188,7 +188,8 @@ import Testing listeningPorts: [Int] = [], finderDirectoryPath: String? = nil, mediaActivity: BrowserMediaActivity = BrowserMediaActivity(), - activeCodingAgentCount: Int = 0 + activeCodingAgentCount: Int = 0, + agentActivityCounts: SidebarAgentActivitySummary.Counts = .init() ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { SidebarWorkspaceSnapshotBuilder.Snapshot( presentationKey: presentationKey ?? Self.presentationKey(), @@ -207,6 +208,7 @@ import Testing latestLog: nil, progress: nil, activeCodingAgentCount: activeCodingAgentCount, + agentActivityCounts: agentActivityCounts, compactGitBranchSummaryText: nil, compactDirectoryCandidates: [], compactBranchDirectoryCandidates: [], diff --git a/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift b/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift index 03fdbc35d4cd..b9e8736162f9 100644 --- a/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift +++ b/cmuxTests/SidebarWorkspaceTableSuspensionTests.swift @@ -610,7 +610,9 @@ struct SidebarWorkspaceTableSuspensionTests { tintHex: nil, isCollapsed: false, isPinned: false, isAnchorActive: false, isMultiSelected: false, multiSelectionBackgroundStyle: .clear, - memberCount: 1, anchorUnreadCount: 0, canMarkRead: false, canMarkUnread: true, + memberCount: 1, showsAgentActivity: false, + runningAgentCount: 0, needsInputAgentCount: 0, + anchorUnreadCount: 0, canMarkRead: false, canMarkUnread: true, hasLatestNotifications: false, canMarkAllRead: false, canMarkAllUnread: true, shortcutHintText: nil, shortcutHintXOffset: 0, shortcutHintYOffset: 0, fontScale: 1, globalFontMagnificationPercent: 100, cwdContextMenuItems: [], diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 89aba1ca6369..91ca1e60daf2 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -396,6 +396,63 @@ struct WorkspaceSidebarObservationTests { #expect(count == 2) } + @Test func groupAgentCountsUseOneHighestPriorityStatePerPanel() { + let firstPanelId = UUID() + let secondPanelId = UUID() + let thirdPanelId = UUID() + + let counts = SidebarAgentActivitySummary.visibleCounts( + showsAgentActivity: true, + countsByWorkspace: [ + SidebarAgentActivitySummary.counts(statesByPanelId: [ + firstPanelId: [ + "claude_code": .running, + "cmux.feed.attention:claude_code": .needsInput, + ], + secondPanelId: ["codex": .running], + ]), + SidebarAgentActivitySummary.counts(statesByPanelId: [ + thirdPanelId: [ + "manual:build": .running, + "kimi": .idle, + ], + ]), + ] + ) + + #expect(counts == .init(running: 1, needsInput: 1)) + } + + @Test func hiddenGroupAgentCountsDoNotReadLifecycleStates() { + var didReadStates = false + + let counts = SidebarAgentActivitySummary.visibleCounts( + showsAgentActivity: false, + countsByWorkspace: { + didReadStates = true + return [.init(running: 1)] + }() + ) + + #expect(counts == .init()) + #expect(!didReadStates) + } + + @Test func agentActivityReplacesConversationSubtitle() { + #expect(SidebarAgentActivitySummary.conversationSubtitle( + showsAgentActivity: true, + hidesAllDetails: false, + iMessageModeEnabled: true, + message: "last agent message" + ) == nil) + #expect(SidebarAgentActivitySummary.conversationSubtitle( + showsAgentActivity: false, + hidesAllDetails: false, + iMessageModeEnabled: true, + message: " last agent message " + ) == "last agent message") + } + @Test func visibleActiveCodingAgentCountReturnsZeroWhenSettingIsDisabled() { let panelId = UUID() let statesByPanelId = [ From f42d73c526cb2a855075fd69b7b36bc0bae7384d Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 06:30:21 -0700 Subject: [PATCH 03/11] sidebar: harden grouped agent aggregation --- .github/workflows/ci.yml | 1 + .../SidebarAgentActivityAggregator.swift | 32 +++++ .../SidebarAgentActivityCounts.swift | 31 +++++ .../SidebarAgentPanelActivity.swift | 11 ++ .../SidebarAgentActivityAggregatorTests.swift | 25 ++++ Resources/Localizable.xcstrings | 114 +++--------------- Sources/ContentView.swift | 14 +-- .../Cells/SidebarGroupHeaderRowView.swift | 7 +- .../Cells/SidebarWorkspaceRowCellView.swift | 2 +- ...idebarWorkspaceSnapshotRefreshPolicy.swift | 3 +- Sources/SidebarAgentActivitySummary.swift | 79 ++++++------ Sources/SidebarWorkspaceGroupHeaderView.swift | 6 +- Sources/SidebarWorkspaceSnapshotBuilder.swift | 2 +- Sources/SidebarWorkspaceSnapshotFactory.swift | 4 +- .../VerticalTabsSidebar+WorkspaceGroups.swift | 16 ++- cmuxTests/SidebarAppKitRowCellTests.swift | 2 +- ...rWorkspaceSnapshotAgentActivityTests.swift | 2 +- ...rWorkspaceSnapshotRefreshPolicyTests.swift | 2 +- .../WorkspaceSidebarObservationTests.swift | 24 ++-- 19 files changed, 204 insertions(+), 173 deletions(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityAggregator.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityCounts.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentPanelActivity.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarAgentActivityAggregatorTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40273a54ec91..9494c907e21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1462,6 +1462,7 @@ jobs: CmuxNotifications CmuxSettings CmuxSettingsUI + CmuxSidebar CmuxTerminal CmuxTerminalCore CmuxUpdater diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityAggregator.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityAggregator.swift new file mode 100644 index 000000000000..1b2e4aa27f38 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityAggregator.swift @@ -0,0 +1,32 @@ +/// Aggregates immutable agent-activity values for sidebar presentation. +public struct SidebarAgentActivityAggregator: Sendable { + /// Creates an agent-activity aggregator. + public init() {} + + /// Counts the highest-priority activity state already selected for each panel. + /// + /// - Parameter panelActivities: One activity value per panel. + /// - Returns: The number of running and needs-input panels. + public func counts(panelActivities: S) -> SidebarAgentActivityCounts + where S.Element == SidebarAgentPanelActivity { + panelActivities.reduce(into: SidebarAgentActivityCounts()) { counts, activity in + switch activity { + case .running: + counts.running += 1 + case .needsInput: + counts.needsInput += 1 + case .inactive: + break + } + } + } + + /// Combines workspace counts into a larger sidebar scope. + /// + /// - Parameter counts: Counts from the workspaces in the scope. + /// - Returns: The combined counts. + public func total(counts: S) -> SidebarAgentActivityCounts + where S.Element == SidebarAgentActivityCounts { + counts.reduce(SidebarAgentActivityCounts(), +) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityCounts.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityCounts.swift new file mode 100644 index 000000000000..e6d6778d42de --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentActivityCounts.swift @@ -0,0 +1,31 @@ +/// Counts active agents by the state shown in the sidebar. +public struct SidebarAgentActivityCounts: Equatable, Hashable, Sendable { + /// The number of agents currently running. + public var running: Int + + /// The number of agents waiting for user input. + public var needsInput: Int + + /// Creates sidebar agent counts. + /// + /// - Parameters: + /// - running: The number of running agents. + /// - needsInput: The number of agents waiting for input. + public init(running: Int = 0, needsInput: Int = 0) { + self.running = running + self.needsInput = needsInput + } + + /// Adds counts from two sidebar scopes. + /// + /// - Parameters: + /// - lhs: Counts from the first scope. + /// - rhs: Counts from the second scope. + /// - Returns: The combined counts. + public static func + (lhs: Self, rhs: Self) -> Self { + Self( + running: lhs.running + rhs.running, + needsInput: lhs.needsInput + rhs.needsInput + ) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentPanelActivity.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentPanelActivity.swift new file mode 100644 index 000000000000..a2ba561141ff --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/AgentActivity/SidebarAgentPanelActivity.swift @@ -0,0 +1,11 @@ +/// The highest-priority active-agent state for one sidebar panel. +public enum SidebarAgentPanelActivity: Sendable { + /// At least one agent in the panel is running. + case running + + /// At least one agent in the panel is waiting for user input. + case needsInput + + /// The panel has no active agent state to count. + case inactive +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarAgentActivityAggregatorTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarAgentActivityAggregatorTests.swift new file mode 100644 index 000000000000..f3f614b23a3e --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarAgentActivityAggregatorTests.swift @@ -0,0 +1,25 @@ +import Testing + +@testable import CmuxSidebar + +@Suite("Sidebar agent activity aggregator") +struct SidebarAgentActivityAggregatorTests { + @Test func countsOneStatePerPanel() { + let counts = SidebarAgentActivityAggregator().counts(panelActivities: [ + .running, + .needsInput, + .inactive, + ]) + + #expect(counts == .init(running: 1, needsInput: 1)) + } + + @Test func combinesWorkspaceCounts() { + let counts = SidebarAgentActivityAggregator().total(counts: [ + SidebarAgentActivityCounts(running: 2), + SidebarAgentActivityCounts(running: 1, needsInput: 3), + ]) + + #expect(counts == .init(running: 3, needsInput: 3)) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cf9c4a48f05a..8108d86e1b6a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -257167,124 +257167,50 @@ "workspaceGroup.agentActivity.a11y": { "extractionState": "manual", "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "الوكلاء قيد التشغيل: %1$lld. بحاجة إلى إدخال: %2$lld." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Pokrenuti agenti: %1$lld. Potreban unos: %2$lld." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Kørende agenter: %1$lld. Kræver input: %2$lld." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Aktive Agenten: %1$lld. Eingabe erforderlich: %2$lld." - } - }, "en": { "stringUnit": { "state": "translated", "value": "Running agents: %1$lld. Need input: %2$lld." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agentes en ejecución: %1$lld. Necesitan entrada: %2$lld." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Agents en cours : %1$lld. Saisie requise : %2$lld." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Agenti in esecuzione: %1$lld. Richiedono input: %2$lld." - } - }, "ja": { "stringUnit": { "state": "translated", "value": "実行中のエージェント: %1$lld。入力待ち: %2$lld。" } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ភ្នាក់ងារកំពុងដំណើរការ៖ %1$lld។ ត្រូវការបញ្ចូល៖ %2$lld។" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "실행 중인 에이전트: %1$lld. 입력 필요: %2$lld." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Kjørende agenter: %1$lld. Trenger inndata: %2$lld." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Działające agenty: %1$lld. Wymaga danych wejściowych: %2$lld." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Agentes em execução: %1$lld. Precisam de entrada: %2$lld." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Работающих агентов: %1$lld. Требуется ввод: %2$lld." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เอเจนต์ที่กำลังทำงาน: %1$lld ต้องการอินพุต: %2$lld" - } - }, - "tr": { + } + } + }, + "workspaceGroup.agentActivity.needsInput": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Çalışan aracılar: %1$lld. Girdi gereken: %2$lld." + "value": "! %lld" } }, - "uk": { + "ja": { "stringUnit": { "state": "translated", - "value": "Агентів у роботі: %1$lld. Потрібне введення: %2$lld." + "value": "! %lld" } - }, - "zh-Hans": { + } + } + }, + "workspaceGroup.agentActivity.running": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "运行中的代理:%1$lld。需要输入:%2$lld。" + "value": "▶ %lld" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "執行中的代理:%1$lld。需要輸入:%2$lld。" + "value": "▶ %lld" } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d46e71d95f81..e5608d88ef3b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11918,16 +11918,13 @@ struct VerticalTabsSidebar: View, Equatable { ) ) }) - let agentActivityCountsByWorkspaceId = workspaceRowInputsById.mapValues { - $0.workspace.agentActivityCounts - } let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( group.id, sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], - agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -11951,7 +11948,7 @@ struct VerticalTabsSidebar: View, Equatable { return sidebarWorkspaceGroupTableConfiguration( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[groupId] ?? [], - agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext ) case .workspace(let workspaceId): @@ -13596,9 +13593,6 @@ struct VerticalTabsSidebar: View, Equatable { ) ) }) - let agentActivityCountsByWorkspaceId = workspaceRowInputsById.mapValues { - $0.workspace.agentActivityCounts - } let _ = anchorCwdRevision let groupRowSnapshotsById = Dictionary(uniqueKeysWithValues: renderContext.workspaceGroups.map { group in ( @@ -13606,7 +13600,7 @@ struct VerticalTabsSidebar: View, Equatable { sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], - agentActivityCountsByWorkspaceId: agentActivityCountsByWorkspaceId, + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -15708,7 +15702,7 @@ struct TabItemView: View, Equatable { let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText - let conversationMessageSubtitle = SidebarAgentActivitySummary.conversationSubtitle( + let conversationMessageSubtitle = SidebarAgentActivitySummary().conversationSubtitle( showsAgentActivity: showsAgentActivity, hidesAllDetails: settings.hidesAllDetails, iMessageModeEnabled: settings.iMessageModeEnabled, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 7dd20618af4d..c9e3089b16a7 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -185,12 +185,13 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { agentActivityField.isHidden = !model.showsAgentActivity if model.showsAgentActivity { + let summary = SidebarAgentActivitySummary() let font = NSFont.systemFont( ofSize: GlobalFontMagnification.scaledSize(metrics.nameFontSize, percent: percent), weight: .semibold ) - let running = "▶ \(model.runningAgentCount)" - let needsInput = "! \(model.needsInputAgentCount)" + let running = summary.runningText(count: model.runningAgentCount) + let needsInput = summary.needsInputText(count: model.needsInputAgentCount) let text = "\(running) \(needsInput)" let attributed = NSMutableAttributedString( string: text, @@ -210,7 +211,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { ) ) agentActivityField.attributedStringValue = attributed - let accessibilityText = SidebarAgentActivitySummary.accessibilityText( + let accessibilityText = summary.accessibilityText( counts: .init( running: model.runningAgentCount, needsInput: model.needsInputAgentCount diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 241bd23ae3b0..61a5a9e37bad 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -516,7 +516,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } } - let conversationSubtitle = SidebarAgentActivitySummary.conversationSubtitle( + let conversationSubtitle = SidebarAgentActivitySummary().conversationSubtitle( showsAgentActivity: model.showsAgentActivity, hidesAllDetails: settings.hidesAllDetails, iMessageModeEnabled: settings.iMessageModeEnabled, diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 2706274d178b..c58ddda282e7 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -1,3 +1,4 @@ +import CmuxSidebar import CmuxWorkspaces extension SidebarWorkspaceSnapshotBuilder.Snapshot { @@ -16,7 +17,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { let checklistTotalCount: Int let checklistFirstUncheckedText: String? let activeCodingAgentCount: Int - let agentActivityCounts: SidebarAgentActivitySummary.Counts + let agentActivityCounts: SidebarAgentActivityCounts } var contextMenuImmediateFields: ContextMenuImmediateFields { diff --git a/Sources/SidebarAgentActivitySummary.swift b/Sources/SidebarAgentActivitySummary.swift index e903199fb3aa..4af7022b6f41 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -1,19 +1,10 @@ +import CmuxSidebar import Foundation -enum SidebarAgentActivitySummary { - struct Counts: Equatable, Hashable, Sendable { - var running = 0 - var needsInput = 0 +struct SidebarAgentActivitySummary { + private let aggregator = SidebarAgentActivityAggregator() - static func + (lhs: Self, rhs: Self) -> Self { - Self( - running: lhs.running + rhs.running, - needsInput: lhs.needsInput + rhs.needsInput - ) - } - } - - static func visibleActiveCodingAgentCount( + func visibleActiveCodingAgentCount( showsAgentActivity: Bool, statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] ) -> Int { @@ -21,7 +12,7 @@ enum SidebarAgentActivitySummary { return activeCodingAgentCount(statesByPanelId: statesByPanelId()) } - static func activeCodingAgentCount( + func activeCodingAgentCount( statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] ) -> Int { statesByPanelId.values.reduce(0) { partial, panelStates in @@ -29,40 +20,40 @@ enum SidebarAgentActivitySummary { } } - static func visibleCounts( + func visibleCounts( showsAgentActivity: Bool, - countsByWorkspace: @autoclosure () -> [Counts] - ) -> Counts { - guard showsAgentActivity else { return Counts() } - return countsByWorkspace().reduce(Counts(), +) + countsByWorkspace: @autoclosure () -> [SidebarAgentActivityCounts] + ) -> SidebarAgentActivityCounts { + guard showsAgentActivity else { return SidebarAgentActivityCounts() } + return aggregator.total(counts: countsByWorkspace()) } - static func counts( + func counts( statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] - ) -> Counts { - statesByPanelId.values.reduce(Counts()) { partial, panelStates in - let states = panelStates - .filter { !AgentHibernationLifecycleStatusKeys.isManualKey($0.key) } - .map(\.value) - var result = partial - if states.contains(.needsInput) { - result.needsInput += 1 - } else if states.contains(.running) { - result.running += 1 + ) -> SidebarAgentActivityCounts { + aggregator.counts(panelActivities: statesByPanelId.values.lazy.map { panelStates in + var activity = SidebarAgentPanelActivity.inactive + for (key, state) in panelStates where !AgentHibernationLifecycleStatusKeys.isManualKey(key) { + if state == .needsInput { + return .needsInput + } + if state == .running { + activity = .running + } } - return result - } + return activity + }) } - static func visibleCounts( + func visibleCounts( showsAgentActivity: Bool, statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] - ) -> Counts { - guard showsAgentActivity else { return Counts() } + ) -> SidebarAgentActivityCounts { + guard showsAgentActivity else { return SidebarAgentActivityCounts() } return counts(statesByPanelId: statesByPanelId()) } - static func accessibilityText(counts: Counts) -> String { + func accessibilityText(counts: SidebarAgentActivityCounts) -> String { String.localizedStringWithFormat( String( localized: "workspaceGroup.agentActivity.a11y", @@ -73,7 +64,21 @@ enum SidebarAgentActivitySummary { ) } - static func conversationSubtitle( + func runningText(count: Int) -> String { + String.localizedStringWithFormat( + String(localized: "workspaceGroup.agentActivity.running", defaultValue: "▶ %lld"), + Int64(count) + ) + } + + func needsInputText(count: Int) -> String { + String.localizedStringWithFormat( + String(localized: "workspaceGroup.agentActivity.needsInput", defaultValue: "! %lld"), + Int64(count) + ) + } + + func conversationSubtitle( showsAgentActivity: Bool, hidesAllDetails: Bool, iMessageModeEnabled: Bool, diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index d69cc21f8474..6ef6ba6f1304 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -135,7 +135,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { } private var agentActivityAccessibilityText: String { - SidebarAgentActivitySummary.accessibilityText( + SidebarAgentActivitySummary().accessibilityText( counts: .init(running: runningAgentCount, needsInput: needsInputAgentCount) ) } @@ -199,9 +199,9 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { .truncationMode(.tail) if showsAgentActivity { HStack(spacing: 6) { - Text("▶ \(runningAgentCount)") + Text(SidebarAgentActivitySummary().runningText(count: runningAgentCount)) .foregroundStyle(Color.blue) - Text("! \(needsInputAgentCount)") + Text(SidebarAgentActivitySummary().needsInputText(count: needsInputAgentCount)) .foregroundStyle(Color.orange) } .cmuxFont(size: metrics.nameFontSize, weight: .semibold) diff --git a/Sources/SidebarWorkspaceSnapshotBuilder.swift b/Sources/SidebarWorkspaceSnapshotBuilder.swift index 5fd9aaac3e77..761be6340add 100644 --- a/Sources/SidebarWorkspaceSnapshotBuilder.swift +++ b/Sources/SidebarWorkspaceSnapshotBuilder.swift @@ -48,7 +48,7 @@ struct SidebarWorkspaceSnapshotBuilder { let latestLog: SidebarLogEntry? let progress: SidebarProgressState? let activeCodingAgentCount: Int - let agentActivityCounts: SidebarAgentActivitySummary.Counts + let agentActivityCounts: SidebarAgentActivityCounts let compactGitBranchSummaryText: String? let compactDirectoryCandidates: [String] let compactBranchDirectoryCandidates: [String] diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 65cdf6a27de9..559b23195b40 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -97,11 +97,11 @@ struct SidebarWorkspaceSnapshotFactory { : [], latestLog: detailVisibility.showsLog ? workspace.logEntries.last : nil, progress: detailVisibility.showsProgress ? workspace.progress : nil, - activeCodingAgentCount: SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + activeCodingAgentCount: SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: showsAgentActivity, statesByPanelId: workspace.agentLifecycleStatesByPanelId ), - agentActivityCounts: SidebarAgentActivitySummary.visibleCounts( + agentActivityCounts: SidebarAgentActivitySummary().visibleCounts( showsAgentActivity: showsAgentActivity, statesByPanelId: workspace.agentLifecycleStatesByPanelId ), diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index e68769d98bf3..8c3f40b729a0 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -9,7 +9,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupTableConfiguration( group: WorkspaceGroup, memberWorkspaceIds: [UUID], - agentActivityCountsByWorkspaceId: [UUID: SidebarAgentActivitySummary.Counts], + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], renderContext: WorkspaceListRenderContext ) -> SidebarWorkspaceTableRowConfiguration { let settings = renderContext.tabItemSettings @@ -34,9 +34,11 @@ extension VerticalTabsSidebar { let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement let showsAgentActivity = group.isCollapsed && renderContext.showsAgentActivity - let agentActivity = SidebarAgentActivitySummary.visibleCounts( + let agentActivity = SidebarAgentActivitySummary().visibleCounts( showsAgentActivity: showsAgentActivity, - countsByWorkspace: memberWorkspaceIds.compactMap { agentActivityCountsByWorkspaceId[$0] } + countsByWorkspace: memberWorkspaceIds.compactMap { + workspaceRowsById[$0]?.workspace.agentActivityCounts + } ) // The AppKit controller applies the current unread snapshot after row // construction, keeping this root projection outside Observation. @@ -261,7 +263,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupRowSnapshot( group: WorkspaceGroup, memberWorkspaceIds: [UUID], - agentActivityCountsByWorkspaceId: [UUID: SidebarAgentActivitySummary.Counts], + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], renderContext: WorkspaceListRenderContext, unreadSnapshot: SidebarUnreadSnapshot, notificationIndex: SidebarWorkspaceNotificationIndex, @@ -290,9 +292,11 @@ extension VerticalTabsSidebar { let cwdContextMenuItems = resolvedConfig?.contextMenuItems ?? [] let newWorkspacePlacement = resolvedConfig?.newWorkspacePlacement let showsAgentActivity = group.isCollapsed && renderContext.showsAgentActivity - let agentActivity = SidebarAgentActivitySummary.visibleCounts( + let agentActivity = SidebarAgentActivitySummary().visibleCounts( showsAgentActivity: showsAgentActivity, - countsByWorkspace: memberWorkspaceIds.compactMap { agentActivityCountsByWorkspaceId[$0] } + countsByWorkspace: memberWorkspaceIds.compactMap { + workspaceRowsById[$0]?.workspace.agentActivityCounts + } ) let anchorUnreadCount: Int = { if group.isCollapsed { diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 5529efaba647..ba82550f6da6 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2165,7 +2165,7 @@ struct SidebarPinnedIndicatorColorTests { #expect(!activity.isHidden) #expect(activity.stringValue.contains("! 2")) - #expect(activity.accessibilityLabel() == SidebarAgentActivitySummary.accessibilityText( + #expect(activity.accessibilityLabel() == SidebarAgentActivitySummary().accessibilityText( counts: .init(running: 4, needsInput: 2) )) } diff --git a/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift b/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift index 993e443296dc..7d8f94c868cd 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotAgentActivityTests.swift @@ -89,7 +89,7 @@ extension SidebarWorkspaceSnapshotRefreshPolicyTests { ] } - let count = SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + let count = SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: false, statesByPanelId: agentLifecycleStates() ) diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index 89f44f67d022..a9fa71e1488f 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -189,7 +189,7 @@ import Testing finderDirectoryPath: String? = nil, mediaActivity: BrowserMediaActivity = BrowserMediaActivity(), activeCodingAgentCount: Int = 0, - agentActivityCounts: SidebarAgentActivitySummary.Counts = .init() + agentActivityCounts: SidebarAgentActivityCounts = .init() ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { SidebarWorkspaceSnapshotBuilder.Snapshot( presentationKey: presentationKey ?? Self.presentationKey(), diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 91ca1e60daf2..a286622a4eed 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -328,7 +328,7 @@ struct WorkspaceSidebarObservationTests { let panelId = try #require(workspace.focusedPanelId) workspace.setAgentLifecycle(key: "manual", panelId: panelId, lifecycle: .running) #expect( - SidebarAgentActivitySummary.activeCodingAgentCount( + SidebarAgentActivitySummary().activeCodingAgentCount( statesByPanelId: workspace.agentLifecycleStatesByPanelId ) == 1 ) @@ -338,7 +338,7 @@ struct WorkspaceSidebarObservationTests { // specific panel (the cross-surface off bug). #expect(workspace.clearAgentLifecycle(key: "manual", panelId: nil)) #expect( - SidebarAgentActivitySummary.activeCodingAgentCount( + SidebarAgentActivitySummary().activeCodingAgentCount( statesByPanelId: workspace.agentLifecycleStatesByPanelId ) == 0 ) @@ -355,7 +355,7 @@ struct WorkspaceSidebarObservationTests { #expect(!workspace.hasRunningAgentLifecycle(key: "manual")) #expect(workspace.hasRunningAgentLifecycle(key: "codex")) #expect( - SidebarAgentActivitySummary.activeCodingAgentCount( + SidebarAgentActivitySummary().activeCodingAgentCount( statesByPanelId: workspace.agentLifecycleStatesByPanelId ) == 1 ) @@ -379,7 +379,7 @@ struct WorkspaceSidebarObservationTests { let firstPanelId = UUID() let secondPanelId = UUID() - let count = SidebarAgentActivitySummary.activeCodingAgentCount( + let count = SidebarAgentActivitySummary().activeCodingAgentCount( statesByPanelId: [ firstPanelId: [ "codex": .running, @@ -401,17 +401,17 @@ struct WorkspaceSidebarObservationTests { let secondPanelId = UUID() let thirdPanelId = UUID() - let counts = SidebarAgentActivitySummary.visibleCounts( + let counts = SidebarAgentActivitySummary().visibleCounts( showsAgentActivity: true, countsByWorkspace: [ - SidebarAgentActivitySummary.counts(statesByPanelId: [ + SidebarAgentActivitySummary().counts(statesByPanelId: [ firstPanelId: [ "claude_code": .running, "cmux.feed.attention:claude_code": .needsInput, ], secondPanelId: ["codex": .running], ]), - SidebarAgentActivitySummary.counts(statesByPanelId: [ + SidebarAgentActivitySummary().counts(statesByPanelId: [ thirdPanelId: [ "manual:build": .running, "kimi": .idle, @@ -426,7 +426,7 @@ struct WorkspaceSidebarObservationTests { @Test func hiddenGroupAgentCountsDoNotReadLifecycleStates() { var didReadStates = false - let counts = SidebarAgentActivitySummary.visibleCounts( + let counts = SidebarAgentActivitySummary().visibleCounts( showsAgentActivity: false, countsByWorkspace: { didReadStates = true @@ -439,13 +439,13 @@ struct WorkspaceSidebarObservationTests { } @Test func agentActivityReplacesConversationSubtitle() { - #expect(SidebarAgentActivitySummary.conversationSubtitle( + #expect(SidebarAgentActivitySummary().conversationSubtitle( showsAgentActivity: true, hidesAllDetails: false, iMessageModeEnabled: true, message: "last agent message" ) == nil) - #expect(SidebarAgentActivitySummary.conversationSubtitle( + #expect(SidebarAgentActivitySummary().conversationSubtitle( showsAgentActivity: false, hidesAllDetails: false, iMessageModeEnabled: true, @@ -463,13 +463,13 @@ struct WorkspaceSidebarObservationTests { ] #expect( - SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: false, statesByPanelId: statesByPanelId ) == 0 ) #expect( - SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: true, statesByPanelId: statesByPanelId ) == 2 From 7355b3450338ab06696c425efdb07fedfeaf6b92 Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 07:20:06 -0700 Subject: [PATCH 04/11] sidebar: fix Xcode 26 warning --- Sources/Sidebar/SidebarWorkspaceDescriptionText.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift b/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift index a1020e42561f..870bc0dff2bc 100644 --- a/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift +++ b/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift @@ -62,7 +62,7 @@ struct SidebarWorkspaceDescriptionText: View { ) #endif } - .onChange(of: markdown) { newValue in + .onChange(of: markdown) { _, newValue in #if DEBUG let newlineCount = newValue.reduce(into: 0) { count, character in if character == "\n" { count += 1 } From b182bf1a27d0cc66a0ecdfb64086cd04bd8a2960 Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 10:43:47 -0700 Subject: [PATCH 05/11] test: cover agent notification preview suppression --- cmuxTests/SidebarAppKitRowCellTests.swift | 22 ++++++++++++++++++- .../WorkspaceSidebarObservationTests.swift | 11 ++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index ba82550f6da6..5d5153bd06df 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -65,6 +65,7 @@ struct SidebarAppKitRowCellTests { settings: SidebarTabItemSettingsSnapshot? = nil, customDescription: String? = nil, latestConversationMessage: String? = nil, + latestNotificationText: String? = nil, metadataEntries: [SidebarStatusEntry] = [], metadataBlocks: [SidebarMetadataBlock] = [], shortcutHintText: String? = nil, @@ -89,7 +90,7 @@ struct SidebarAppKitRowCellTests { canCloseWorkspace: canClose, accessibilityWorkspaceCount: 1, unreadCount: 0, - latestNotificationText: nil, + latestNotificationText: latestNotificationText, showsAgentActivity: resolvedSettings.details.showAgentActivity, rowSpacing: 8, isBeingDragged: false, @@ -2078,6 +2079,25 @@ struct SidebarAppKitRowCellTests { .compactMap { $0 as? SidebarRowTextView } .allSatisfy { $0.isHidden || $0.stringValue != "last agent message" }) } + + @Test + func structuredAgentActivityHidesNotificationPreviewButKeepsDescription() { + let defaults = Self.makeDefaults() + defaults.set(true, forKey: "sidebarShowAgentActivity") + let model = Self.makeModel( + settings: SidebarTabItemSettingsSnapshot(defaults: defaults), + customDescription: "Explicit workspace description", + latestNotificationText: "Kimi Code task complete" + ) + let cell = Self.configuredCell(model: model) + let visibleText = Self.descendants(of: cell) + .compactMap { $0 as? SidebarRowTextView } + .filter { !$0.isHidden } + .map(\.stringValue) + + #expect(visibleText.contains("Explicit workspace description")) + #expect(!visibleText.contains("Kimi Code task complete")) + } } @Suite diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index a286622a4eed..0d77f6c7ca12 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -453,6 +453,17 @@ struct WorkspaceSidebarObservationTests { ) == "last agent message") } + @Test func agentActivityReplacesNotificationSubtitle() { + #expect(SidebarAgentActivitySummary().notificationSubtitle( + showsAgentActivity: true, + message: "Kimi Code task complete" + ) == nil) + #expect(SidebarAgentActivitySummary().notificationSubtitle( + showsAgentActivity: false, + message: "Kimi Code task complete" + ) == "Kimi Code task complete") + } + @Test func visibleActiveCodingAgentCountReturnsZeroWhenSettingIsDisabled() { let panelId = UUID() let statesByPanelId = [ From 713768479063069884efb472299e054501b157ef Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 12 Aug 2026 10:45:26 -0700 Subject: [PATCH 06/11] sidebar: hide agent notification previews --- Sources/ContentView.swift | 10 +++++++--- .../Cells/SidebarWorkspaceRowCellView.swift | 13 +++++++++---- Sources/SidebarAgentActivitySummary.swift | 7 +++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e5608d88ef3b..d0c53390ca89 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15666,7 +15666,7 @@ struct TabItemView: View, Equatable { } } } - .padding(.top, latestNotificationText == nil ? 1 : 2) + .padding(.top, latestNotificationText == nil || showsAgentActivity ? 1 : 2) .safeHelp(workspaceSnapshot.remoteStateHelpText) } } @@ -15701,8 +15701,12 @@ struct TabItemView: View, Equatable { let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText - let conversationMessageSubtitle = SidebarAgentActivitySummary().conversationSubtitle( + let agentActivitySummary = SidebarAgentActivitySummary() + let latestNotificationSubtitle = agentActivitySummary.notificationSubtitle( + showsAgentActivity: showsAgentActivity, + message: latestNotificationText + ) + let conversationMessageSubtitle = agentActivitySummary.conversationSubtitle( showsAgentActivity: showsAgentActivity, hidesAllDetails: settings.hidesAllDetails, iMessageModeEnabled: settings.iMessageModeEnabled, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 61a5a9e37bad..814ab24cd871 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -516,14 +516,19 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } } - let conversationSubtitle = SidebarAgentActivitySummary().conversationSubtitle( + let agentActivitySummary = SidebarAgentActivitySummary() + let notificationSubtitle = agentActivitySummary.notificationSubtitle( + showsAgentActivity: model.showsAgentActivity, + message: model.latestNotificationText + ) + let conversationSubtitle = agentActivitySummary.conversationSubtitle( showsAgentActivity: model.showsAgentActivity, hidesAllDetails: settings.hidesAllDetails, iMessageModeEnabled: settings.iMessageModeEnabled, message: snapshot.latestConversationMessage ) - let effectiveSubtitle = model.latestNotificationText ?? conversationSubtitle - let subtitleLineLimit = model.latestNotificationText == nil ? 2 : settings.notificationMessageLineLimit + let effectiveSubtitle = notificationSubtitle ?? conversationSubtitle + let subtitleLineLimit = notificationSubtitle == nil ? 2 : settings.notificationMessageLineLimit subtitleView.isHidden = effectiveSubtitle == nil if let effectiveSubtitle { subtitleView.maximumNumberOfLines = subtitleLineLimit @@ -1225,7 +1230,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } if !remoteTargetView.isHidden { - y += model.latestNotificationText == nil ? 1 : 2 + y += model.latestNotificationText == nil || model.showsAgentActivity ? 1 : 2 y += spacing let statusSize = remoteStatusView.isHidden ? .zero : remoteStatusView.sidebarNaturalCellSize let reconnectSize = remoteReconnectButton.isHidden ? .zero : remoteReconnectButton.intrinsicContentSize diff --git a/Sources/SidebarAgentActivitySummary.swift b/Sources/SidebarAgentActivitySummary.swift index 4af7022b6f41..e63f6a3fcc1e 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -88,4 +88,11 @@ struct SidebarAgentActivitySummary { let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } + + func notificationSubtitle( + showsAgentActivity: Bool, + message: String? + ) -> String? { + showsAgentActivity ? nil : message + } } From a42e57ee230f726670159911aaf0eb02b04a57d2 Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 14 Aug 2026 18:41:11 -0700 Subject: [PATCH 07/11] test: distinguish notifications from idle reminders --- cmuxTests/ClaudeBackgroundWorkNotifyTests.swift | 9 ++++++--- cmuxTests/SidebarAppKitRowCellTests.swift | 6 ++++-- cmuxTests/WorkspaceSidebarObservationTests.swift | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 45c494461d96..5bf106597987 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -272,8 +272,11 @@ struct ClaudeBackgroundWorkNotifyTests { let snapshot = context.state.snapshot() #expect(notifyLine(snapshot, containing: "c=idle-reminder;p=0") != nil, "idle_prompt after an idle stop must tag pending=0; saw \(snapshot)") - // With no pending work this is a real waiting state, so the pill flips. - #expect(statusLine(snapshot, value: "Needs input") != nil, - "Idle idle_prompt must still set the Needs input pill; saw \(snapshot)") + // Claude emits idle_prompt after an idle turn even when it did not ask + // the user anything. Keep the reminder, but do not claim input is needed. + #expect(statusLine(snapshot, value: "Needs input") == nil, + "Idle idle_prompt must not set a Needs input pill; saw \(snapshot)") + #expect(lifecycleLine(snapshot, value: "needsInput") == nil, + "Idle idle_prompt must not publish a needs-input lifecycle; saw \(snapshot)") } } diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 5d5153bd06df..62ff4cabfa64 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2081,12 +2081,13 @@ struct SidebarAppKitRowCellTests { } @Test - func structuredAgentActivityHidesNotificationPreviewButKeepsDescription() { + func structuredAgentActivityKeepsNotificationPreviewButHidesConversationSnippet() { let defaults = Self.makeDefaults() defaults.set(true, forKey: "sidebarShowAgentActivity") let model = Self.makeModel( settings: SidebarTabItemSettingsSnapshot(defaults: defaults), customDescription: "Explicit workspace description", + latestConversationMessage: "last agent message", latestNotificationText: "Kimi Code task complete" ) let cell = Self.configuredCell(model: model) @@ -2096,7 +2097,8 @@ struct SidebarAppKitRowCellTests { .map(\.stringValue) #expect(visibleText.contains("Explicit workspace description")) - #expect(!visibleText.contains("Kimi Code task complete")) + #expect(visibleText.contains("Kimi Code task complete")) + #expect(!visibleText.contains("last agent message")) } } diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 0d77f6c7ca12..c8c098d84b99 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -453,11 +453,11 @@ struct WorkspaceSidebarObservationTests { ) == "last agent message") } - @Test func agentActivityReplacesNotificationSubtitle() { + @Test func agentActivityKeepsNotificationSubtitle() { #expect(SidebarAgentActivitySummary().notificationSubtitle( showsAgentActivity: true, message: "Kimi Code task complete" - ) == nil) + ) == "Kimi Code task complete") #expect(SidebarAgentActivitySummary().notificationSubtitle( showsAgentActivity: false, message: "Kimi Code task complete" From 925292da94f604dc09d5fb7533b2ac27f7e4e106 Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 14 Aug 2026 18:42:00 -0700 Subject: [PATCH 08/11] fix: keep notification truth separate from idle state --- CLI/cmux.swift | 11 +++++------ Sources/ContentView.swift | 2 +- .../Cells/SidebarWorkspaceRowCellView.swift | 2 +- Sources/SidebarAgentActivitySummary.swift | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 04f7ad48a98e..2bf9f50ccf22 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25271,12 +25271,11 @@ struct CMUXCLI { } } - // An idle reminder while background work is still pending is not a - // real "waiting for input" state: the pane is still running (the Stop - // hook set it to Running) and the app suppresses this banner. Skip the - // "Needs input" pill/lifecycle so the idle nag can't undo the Running - // status; the app still gates the (tagged) notification itself. - let suppressNeedsInputState = (notifyCategory == .idleReminder && notifyPending) + // Claude's timed idle_prompt is a reminder, not evidence that Claude + // asked a question. Keep the notification, but never turn it into a + // "Needs input" pill/lifecycle. Pending work remains Running too. + let suppressNeedsInputState = notificationType == "idle_prompt" + || (notifyCategory == .idleReminder && notifyPending) // `.other` means "ungated, always deliver" — identical to an untagged // payload, so don't put it on the wire: the app parser accepts only diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d0c53390ca89..a484d1818b40 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15666,7 +15666,7 @@ struct TabItemView: View, Equatable { } } } - .padding(.top, latestNotificationText == nil || showsAgentActivity ? 1 : 2) + .padding(.top, latestNotificationText == nil ? 1 : 2) .safeHelp(workspaceSnapshot.remoteStateHelpText) } } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 814ab24cd871..61c38fc2ab60 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -1230,7 +1230,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } if !remoteTargetView.isHidden { - y += model.latestNotificationText == nil || model.showsAgentActivity ? 1 : 2 + y += model.latestNotificationText == nil ? 1 : 2 y += spacing let statusSize = remoteStatusView.isHidden ? .zero : remoteStatusView.sidebarNaturalCellSize let reconnectSize = remoteReconnectButton.isHidden ? .zero : remoteReconnectButton.intrinsicContentSize diff --git a/Sources/SidebarAgentActivitySummary.swift b/Sources/SidebarAgentActivitySummary.swift index e63f6a3fcc1e..0f83ef27df17 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -90,9 +90,9 @@ struct SidebarAgentActivitySummary { } func notificationSubtitle( - showsAgentActivity: Bool, + showsAgentActivity _: Bool, message: String? ) -> String? { - showsAgentActivity ? nil : message + message } } From b36069bbabc0297b05133d18531f2ddef99a99c4 Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 14 Aug 2026 21:19:16 -0700 Subject: [PATCH 09/11] test: assert truthful idle and notification states --- cmuxTests/ClaudeBackgroundWorkNotifyTests.swift | 2 ++ cmuxTests/SidebarAppKitRowCellTests.swift | 1 + 2 files changed, 3 insertions(+) diff --git a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 5bf106597987..8083bf0f0fa3 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -278,5 +278,7 @@ struct ClaudeBackgroundWorkNotifyTests { "Idle idle_prompt must not set a Needs input pill; saw \(snapshot)") #expect(lifecycleLine(snapshot, value: "needsInput") == nil, "Idle idle_prompt must not publish a needs-input lifecycle; saw \(snapshot)") + #expect(statusLine(snapshot, value: "Idle") != nil) + #expect(lifecycleLine(snapshot, value: "idle") != nil) } } diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 62ff4cabfa64..483300529f45 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2083,6 +2083,7 @@ struct SidebarAppKitRowCellTests { @Test func structuredAgentActivityKeepsNotificationPreviewButHidesConversationSnippet() { let defaults = Self.makeDefaults() + defaults.set(true, forKey: IMessageModeSettings.key) defaults.set(true, forKey: "sidebarShowAgentActivity") let model = Self.makeModel( settings: SidebarTabItemSettingsSnapshot(defaults: defaults), From 29cf858dbf37d09a11bd459fa5af2a17e332614e Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 14 Aug 2026 21:28:51 -0700 Subject: [PATCH 10/11] test: require x marker for agent errors --- .../CodexTerminalErrorNotificationTests.swift | 2 +- .../WorkspaceRemoteConnectionTests.swift | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cmuxTests/CodexTerminalErrorNotificationTests.swift b/cmuxTests/CodexTerminalErrorNotificationTests.swift index 8251def9b00c..2752fe9d1dd9 100644 --- a/cmuxTests/CodexTerminalErrorNotificationTests.swift +++ b/cmuxTests/CodexTerminalErrorNotificationTests.swift @@ -70,7 +70,7 @@ struct CodexTerminalErrorNotificationTests { #expect( server.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") }, diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 8d315a8774d4..0a037042a067 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -3963,7 +3963,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex rate limit") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4037,7 +4037,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4115,7 +4115,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex network error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4254,7 +4254,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4315,7 +4315,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4376,7 +4376,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4437,7 +4437,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4504,7 +4504,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4656,7 +4656,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4730,7 +4730,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -4952,7 +4952,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -5032,7 +5032,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex network error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -5316,7 +5316,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") @@ -5433,7 +5433,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue( state.commands.contains { command in command.contains("set_status codex Codex network error") && - command.contains("--icon=exclamationmark.triangle.fill") && + command.contains("--icon=xmark.circle.fill") && command.contains("--color=#FF453A") && command.contains("--priority=100") && command.contains("--tab=\(workspaceId)") From 3a71d4506bc315993d4ef282640ec9da4acf6932 Mon Sep 17 00:00:00 2001 From: krandder Date: Sat, 15 Aug 2026 01:46:43 -0700 Subject: [PATCH 11/11] fix: mark terminal agent errors with x --- CLI/cmux.swift | 58 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2bf9f50ccf22..76d73baed69e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25750,6 +25750,19 @@ struct CMUXCLI { } } + private func setAgentErrorStatus( + client: SocketClient, + key: String, + value: String, + workspaceId: String, + surfaceId: String? + ) { + _ = try? sendV1Command( + "set_status \(key) \(value) --icon=xmark.circle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", + client: client + ) + } + private func runAgentHibernation( commandArgs: [String], client: SocketClient, @@ -27669,9 +27682,12 @@ struct CMUXCLI { let payload = "Codex|\(sanitizeNotificationField(summary.subtitle))|\(sanitizeNotificationField(summary.body))" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) } - _ = try? sendV1Command( - "set_status codex \(summary.statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client + setAgentErrorStatus( + client: client, + key: "codex", + value: summary.statusValue, + workspaceId: workspaceId, + surfaceId: surfaceId ) } @@ -31808,9 +31824,12 @@ export default CMUXSessionRestore; String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), def.displayName ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client + setAgentErrorStatus( + client: client, + key: def.statusKey, + value: statusValue, + workspaceId: workspaceId, + surfaceId: surfaceId ) case nil: break @@ -32326,9 +32345,12 @@ export default CMUXSessionRestore; workspaceId: workspaceId, surfaceId: surfaceId ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(codexFailure.statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client + setAgentErrorStatus( + client: client, + key: def.statusKey, + value: codexFailure.statusValue, + workspaceId: workspaceId, + surfaceId: surfaceId ) } else if antigravityFailure != nil { setAgentLifecycle( @@ -32342,9 +32364,12 @@ export default CMUXSessionRestore; String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), def.displayName ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client + setAgentErrorStatus( + client: client, + key: def.statusKey, + value: statusValue, + workspaceId: workspaceId, + surfaceId: surfaceId ) } else if antigravityHasActiveBackgroundWork { setAgentLifecycle( @@ -32753,9 +32778,12 @@ export default CMUXSessionRestore; String(localized: "agent.generic.notification.status.error", defaultValue: "%@ error"), def.displayName ) - _ = try? sendV1Command( - "set_status \(def.statusKey) \(statusValue) --icon=exclamationmark.triangle.fill --color=#FF453A --priority=100 --tab=\(workspaceId)\(socketPanelOption(surfaceId))", - client: client + setAgentErrorStatus( + client: client, + key: def.statusKey, + value: statusValue, + workspaceId: workspaceId, + surfaceId: surfaceId ) case .idle?: if !hasNewerRunningSession(workspaceId: workspaceId, surfaceId: surfaceId) {