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/CLI/cmux.swift b/CLI/cmux.swift index 04f7ad48a98e..76d73baed69e 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 @@ -25751,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, @@ -27670,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 ) } @@ -31809,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 @@ -32327,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( @@ -32343,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( @@ -32754,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) { 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 7d71a134ac02..8108d86e1b6a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -257164,6 +257164,57 @@ } } }, + "workspaceGroup.agentActivity.a11y": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running agents: %1$lld. Need input: %2$lld." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のエージェント: %1$lld。入力待ち: %2$lld。" + } + } + } + }, + "workspaceGroup.agentActivity.needsInput": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "! %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "! %lld" + } + } + } + }, + "workspaceGroup.agentActivity.running": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "▶ %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "▶ %lld" + } + } + } + }, "workspaceGroup.autoName.numbered": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5b7b6d67f69b..a484d1818b40 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11924,6 +11924,7 @@ struct VerticalTabsSidebar: View, Equatable { sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -11947,6 +11948,7 @@ struct VerticalTabsSidebar: View, Equatable { return sidebarWorkspaceGroupTableConfiguration( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[groupId] ?? [], + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext ) case .workspace(let workspaceId): @@ -13598,6 +13600,7 @@ struct VerticalTabsSidebar: View, Equatable { sidebarWorkspaceGroupRowSnapshot( group: group, memberWorkspaceIds: renderContext.memberWorkspaceIdsByGroupId[group.id] ?? [], + workspaceRowsById: workspaceRowInputsById, renderContext: renderContext, unreadSnapshot: unreadSnapshot, notificationIndex: notificationIndex, @@ -15698,10 +15701,17 @@ 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 = !settings.hidesAllDetails && settings.iMessageModeEnabled - ? workspaceSnapshot.latestConversationMessage?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - : nil + let agentActivitySummary = SidebarAgentActivitySummary() + let latestNotificationSubtitle = agentActivitySummary.notificationSubtitle( + showsAgentActivity: showsAgentActivity, + message: latestNotificationText + ) + let conversationMessageSubtitle = agentActivitySummary.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..c9e3089b16a7 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,44 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { ) nameField.textColor = model.isAnchorActive ? .labelColor : NSColor.labelColor.withAlphaComponent(0.9) + 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 = summary.runningText(count: model.runningAgentCount) + let needsInput = summary.needsInputText(count: 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 = summary.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 +434,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 +449,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..61c38fc2ab60 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -516,13 +516,19 @@ 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 effectiveSubtitle = model.latestNotificationText ?? conversationSubtitle - let subtitleLineLimit = model.latestNotificationText == nil ? 2 : settings.notificationMessageLineLimit + 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 = notificationSubtitle ?? conversationSubtitle + let subtitleLineLimit = notificationSubtitle == nil ? 2 : settings.notificationMessageLineLimit subtitleView.isHidden = effectiveSubtitle == nil if let effectiveSubtitle { subtitleView.maximumNumberOfLines = subtitleLineLimit 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 } diff --git a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift index 50defc0e2c56..c58ddda282e7 100644 --- a/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift +++ b/Sources/Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift @@ -1,3 +1,4 @@ +import CmuxSidebar import CmuxWorkspaces extension SidebarWorkspaceSnapshotBuilder.Snapshot { @@ -16,6 +17,7 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { let checklistTotalCount: Int let checklistFirstUncheckedText: String? let activeCodingAgentCount: Int + let agentActivityCounts: SidebarAgentActivityCounts } var contextMenuImmediateFields: ContextMenuImmediateFields { @@ -33,7 +35,8 @@ extension SidebarWorkspaceSnapshotBuilder.Snapshot { checklistCompletedCount: checklistCompletedCount, checklistTotalCount: checklistTotalCount, checklistFirstUncheckedText: checklistFirstUncheckedText, - activeCodingAgentCount: activeCodingAgentCount + activeCodingAgentCount: activeCodingAgentCount, + agentActivityCounts: agentActivityCounts ) } @@ -58,6 +61,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..0f83ef27df17 100644 --- a/Sources/SidebarAgentActivitySummary.swift +++ b/Sources/SidebarAgentActivitySummary.swift @@ -1,7 +1,10 @@ +import CmuxSidebar import Foundation -enum SidebarAgentActivitySummary { - static func visibleActiveCodingAgentCount( +struct SidebarAgentActivitySummary { + private let aggregator = SidebarAgentActivityAggregator() + + func visibleActiveCodingAgentCount( showsAgentActivity: Bool, statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] ) -> Int { @@ -9,11 +12,87 @@ enum SidebarAgentActivitySummary { return activeCodingAgentCount(statesByPanelId: statesByPanelId()) } - static func activeCodingAgentCount( + func activeCodingAgentCount( statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] ) -> Int { statesByPanelId.values.reduce(0) { partial, panelStates in partial + panelStates.values.reduce(0) { $1 == .running ? $0 + 1 : $0 } } } + + func visibleCounts( + showsAgentActivity: Bool, + countsByWorkspace: @autoclosure () -> [SidebarAgentActivityCounts] + ) -> SidebarAgentActivityCounts { + guard showsAgentActivity else { return SidebarAgentActivityCounts() } + return aggregator.total(counts: countsByWorkspace()) + } + + func counts( + statesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] + ) -> 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 activity + }) + } + + func visibleCounts( + showsAgentActivity: Bool, + statesByPanelId: @autoclosure () -> [UUID: [String: AgentHibernationLifecycleState]] + ) -> SidebarAgentActivityCounts { + guard showsAgentActivity else { return SidebarAgentActivityCounts() } + return counts(statesByPanelId: statesByPanelId()) + } + + func accessibilityText(counts: SidebarAgentActivityCounts) -> String { + String.localizedStringWithFormat( + String( + localized: "workspaceGroup.agentActivity.a11y", + defaultValue: "Running agents: %1$lld. Need input: %2$lld." + ), + Int64(counts.running), + Int64(counts.needsInput) + ) + } + + 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, + message: String? + ) -> String? { + guard !showsAgentActivity, !hidesAllDetails, iMessageModeEnabled else { return nil } + let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + func notificationSubtitle( + showsAgentActivity _: Bool, + message: String? + ) -> String? { + message + } } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index 3fcdf4b16d6d..6ef6ba6f1304 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(SidebarAgentActivitySummary().runningText(count: runningAgentCount)) + .foregroundStyle(Color.blue) + Text(SidebarAgentActivitySummary().needsInputText(count: 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..761be6340add 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: SidebarAgentActivityCounts let compactGitBranchSummaryText: String? let compactDirectoryCandidates: [String] let compactBranchDirectoryCandidates: [String] diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 835a6c098674..559b23195b40 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -97,7 +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( showsAgentActivity: showsAgentActivity, statesByPanelId: workspace.agentLifecycleStatesByPanelId ), diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index ed3c2d22e84b..8c3f40b729a0 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -9,6 +9,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupTableConfiguration( group: WorkspaceGroup, memberWorkspaceIds: [UUID], + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], renderContext: WorkspaceListRenderContext ) -> SidebarWorkspaceTableRowConfiguration { let settings = renderContext.tabItemSettings @@ -32,6 +33,13 @@ 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 { + workspaceRowsById[$0]?.workspace.agentActivityCounts + } + ) // The AppKit controller applies the current unread snapshot after row // construction, keeping this root projection outside Observation. let unreadSnapshot = SidebarUnreadSnapshot() @@ -84,6 +92,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 +263,7 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupRowSnapshot( group: WorkspaceGroup, memberWorkspaceIds: [UUID], + workspaceRowsById: [UUID: SidebarWorkspaceRowInput], renderContext: WorkspaceListRenderContext, unreadSnapshot: SidebarUnreadSnapshot, notificationIndex: SidebarWorkspaceNotificationIndex, @@ -279,6 +291,13 @@ 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 { + workspaceRowsById[$0]?.workspace.agentActivityCounts + } + ) let anchorUnreadCount: Int = { if group.isCollapsed { return memberWorkspaceIds.reduce(0) { partial, workspaceId in @@ -333,6 +352,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 +405,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/ClaudeBackgroundWorkNotifyTests.swift b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift index 45c494461d96..8083bf0f0fa3 100644 --- a/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift +++ b/cmuxTests/ClaudeBackgroundWorkNotifyTests.swift @@ -272,8 +272,13 @@ 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)") + #expect(statusLine(snapshot, value: "Idle") != nil) + #expect(lifecycleLine(snapshot, value: "idle") != nil) } } 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/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index b54c7394f304..483300529f45 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,12 +31,13 @@ struct SidebarAppKitRowCellTests { remoteStateHelpText: "", showsRemoteReconnectAffordance: false, copyableSidebarSSHError: nil, - latestConversationMessage: nil, + latestConversationMessage: latestConversationMessage, metadataEntries: metadataEntries, metadataBlocks: metadataBlocks, latestLog: nil, progress: nil, activeCodingAgentCount: 0, + agentActivityCounts: .init(), compactGitBranchSummaryText: nil, compactDirectoryCandidates: [], compactBranchDirectoryCandidates: [], @@ -62,6 +64,8 @@ struct SidebarAppKitRowCellTests { canClose: Bool = true, settings: SidebarTabItemSettingsSnapshot? = nil, customDescription: String? = nil, + latestConversationMessage: String? = nil, + latestNotificationText: String? = nil, metadataEntries: [SidebarStatusEntry] = [], metadataBlocks: [SidebarMetadataBlock] = [], shortcutHintText: String? = nil, @@ -75,6 +79,7 @@ struct SidebarAppKitRowCellTests { snapshot: makeSnapshot( customDescription: customDescription, isPinned: isPinned, + latestConversationMessage: latestConversationMessage, metadataEntries: metadataEntries, metadataBlocks: metadataBlocks ), @@ -85,7 +90,7 @@ struct SidebarAppKitRowCellTests { canCloseWorkspace: canClose, accessibilityWorkspaceCount: 1, unreadCount: 0, - latestNotificationText: nil, + latestNotificationText: latestNotificationText, showsAgentActivity: resolvedSettings.details.showAgentActivity, rowSpacing: 8, isBeingDragged: false, @@ -2058,29 +2063,71 @@ 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" }) + } + + @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), + customDescription: "Explicit workspace description", + latestConversationMessage: "last agent message", + 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")) + #expect(!visibleText.contains("last agent message")) + } } @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, @@ -2098,7 +2145,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) @@ -2113,4 +2169,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..7d8f94c868cd 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) @@ -69,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 80d1b667b328..a9fa71e1488f 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: SidebarAgentActivityCounts = .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/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)") diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 89aba1ca6369..c8c098d84b99 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, @@ -396,6 +396,74 @@ 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 agentActivityKeepsNotificationSubtitle() { + #expect(SidebarAgentActivitySummary().notificationSubtitle( + showsAgentActivity: true, + message: "Kimi Code task complete" + ) == "Kimi Code task complete") + #expect(SidebarAgentActivitySummary().notificationSubtitle( + showsAgentActivity: false, + message: "Kimi Code task complete" + ) == "Kimi Code task complete") + } + @Test func visibleActiveCodingAgentCountReturnsZeroWhenSettingIsDisabled() { let panelId = UUID() let statesByPanelId = [ @@ -406,13 +474,13 @@ struct WorkspaceSidebarObservationTests { ] #expect( - SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: false, statesByPanelId: statesByPanelId ) == 0 ) #expect( - SidebarAgentActivitySummary.visibleActiveCodingAgentCount( + SidebarAgentActivitySummary().visibleActiveCodingAgentCount( showsAgentActivity: true, statesByPanelId: statesByPanelId ) == 2