diff --git a/Sources/ControlSidebarPanelOwner.swift b/Sources/ControlSidebarPanelOwner.swift index e495e8e13b9c..aced7d2fe3ab 100644 --- a/Sources/ControlSidebarPanelOwner.swift +++ b/Sources/ControlSidebarPanelOwner.swift @@ -34,7 +34,14 @@ enum ControlSidebarPanelOwner { func statusEntry(key: String, panelId: UUID?) -> SidebarStatusEntry? { switch self { - case .workspace(let workspace): workspace.statusEntries[key] + case .workspace(let workspace): + // A pane compares against its own last report, so its first report + // is recorded even when another pane already wrote the same text. + if let panelId, workspace.panels[panelId] != nil { + workspace.agentStatusEntry(key: key, panelId: panelId) + } else { + workspace.statusEntries[key] + } case .dock(let dock): panelId.flatMap { dock.agentRuntimeStatusEntry(key: key, panelId: $0) } } @@ -42,7 +49,7 @@ enum ControlSidebarPanelOwner { func setStatusEntry(_ entry: SidebarStatusEntry, key: String, panelId: UUID?) { switch self { - case .workspace(let workspace): workspace.statusEntries[key] = entry + case .workspace(let workspace): workspace.setStatusEntry(entry, key: key, panelId: panelId) case .dock(let dock): guard let panelId else { return } dock.setAgentRuntimeStatusEntry(entry, key: key, panelId: panelId) @@ -52,7 +59,7 @@ enum ControlSidebarPanelOwner { func clearStatusEntry(key: String, panelId: UUID?) { switch self { case .workspace(let workspace): - workspace.statusEntries.removeValue(forKey: key) + workspace.clearStatusEntry(key: key, panelId: panelId) case .dock(let dock): guard let panelId else { return } dock.clearAgentRuntimeStatusEntry(key: key, panelId: panelId) diff --git a/Sources/Workspace+AgentLifecycle.swift b/Sources/Workspace+AgentLifecycle.swift index 0ac1ab7f697b..6eb167237e18 100644 --- a/Sources/Workspace+AgentLifecycle.swift +++ b/Sources/Workspace+AgentLifecycle.swift @@ -510,6 +510,7 @@ extension Workspace { for panelId in panelIds { guard agentLifecycleStatesByPanelId[panelId]?[key] != nil else { continue } agentLifecycleStatesByPanelId[panelId]?.removeValue(forKey: key) + removePanelStatusEntry(key: key, panelId: panelId) if agentLifecycleStatesByPanelId[panelId]?.isEmpty == true { agentLifecycleStatesByPanelId.removeValue(forKey: panelId) } @@ -530,6 +531,9 @@ extension Workspace { func clearAgentLifecycleStates(panelId: UUID) { guard let removed = agentLifecycleStatesByPanelId.removeValue(forKey: panelId) else { return } + for key in removed.keys { + removePanelStatusEntry(key: key, panelId: panelId) + } let manualStates = removed.filter { AgentHibernationLifecycleStatusKeys.isManualKey($0.key) } if !manualStates.isEmpty { let host: UUID? = if panels[panelId] != nil { diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 519369cb3f2b..4d110aa2fa5e 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -86,12 +86,12 @@ extension Workspace { agentPIDIdentitiesForPanel[key] = agentPIDProcessIdentitiesByKey[key] } let statusKey = agentStatusKey(forAgentPIDKey: key) - if let statusEntry = statusEntries[statusKey] { + if let statusEntry = agentStatusEntry(key: statusKey, panelId: panelId) ?? statusEntries[statusKey] { statusEntriesForPanel[statusKey] = statusEntry } } for (statusKey, lifecycle) in lifecycleStates where lifecycle == .needsInput { - if let statusEntry = statusEntries[statusKey] { + if let statusEntry = agentStatusEntry(key: statusKey, panelId: panelId) ?? statusEntries[statusKey] { statusEntriesForPanel[statusKey] = statusEntry } } @@ -340,7 +340,7 @@ extension Workspace { } if let statusKeyToClear, !hasAgentRuntime(forStatusKey: statusKeyToClear), - statusEntries.removeValue(forKey: statusKeyToClear) != nil { + removeStatusEntry(forKey: statusKeyToClear) { didChange = true } if didChange, refreshPorts { @@ -402,7 +402,7 @@ extension Workspace { for (statusKey, capturedStatusEntry) in runtimeState.statusEntries where !hasAgentRuntime(forStatusKey: statusKey) && statusEntries[statusKey] == capturedStatusEntry { - statusEntries.removeValue(forKey: statusKey) + removeStatusEntry(forKey: statusKey) didChange = true } if didChange { @@ -414,7 +414,7 @@ extension Workspace { func adoptDetachedAgentRuntimeState(_ runtimeState: DetachedAgentRuntimeState?) { guard let runtimeState else { return } for (statusKey, statusEntry) in runtimeState.statusEntries { - statusEntries[statusKey] = statusEntry + setStatusEntry(statusEntry, key: statusKey, panelId: runtimeState.panelId) } var didAdoptAgentPID = false for (key, pid) in runtimeState.agentPIDs { @@ -541,6 +541,7 @@ extension Workspace { manualUnreadPanelIds.remove(panelId) manualUnreadMarkedAt.removeValue(forKey: panelId) panelShellActivityStates.removeValue(forKey: panelId) + agentStatusEntriesByPanelId.removeValue(forKey: panelId) restoredPanelTitleBoundariesByPanelId.removeValue(forKey: panelId) clearAgentLifecycleStates(panelId: panelId) surfaceTTYNames.removeValue(forKey: panelId) diff --git a/Sources/Workspace+SidebarStatusVisibility.swift b/Sources/Workspace+SidebarStatusVisibility.swift index 81e3dbedaaf4..40cd2e4ce5a8 100644 --- a/Sources/Workspace+SidebarStatusVisibility.swift +++ b/Sources/Workspace+SidebarStatusVisibility.swift @@ -2,10 +2,99 @@ import CmuxSidebar import Foundation extension Workspace { + /// The workspace row is an aggregate of every pane: each visible key shows + /// its most urgent pane's entry (see `mostUrgentPanelStatusEntry`), not + /// whichever pane wrote last or the focused one. func sidebarStatusEntriesVisibleForDisplay() -> [SidebarStatusEntry] { let visibleStructuredStatusKeys = visibleStructuredAgentStatusKeysByPanel() - return statusEntries.values.filter { entry in - shouldDisplaySidebarStatusEntry(entry, visibleStructuredStatusKeys: visibleStructuredStatusKeys) + return statusEntries.values.compactMap { entry in + guard shouldDisplaySidebarStatusEntry(entry, visibleStructuredStatusKeys: visibleStructuredStatusKeys) else { + return nil + } + return mostUrgentPanelStatusEntry(forKey: entry.key) ?? entry + } + } + + /// Keys with at least one live pane waiting on the person; the row sorts + /// them ahead of other entries of the same priority. + func sidebarStatusKeysNeedingInput() -> Set { + var keys = Set() + for (panelId, lifecycleStates) in agentLifecycleStatesByPanelId where panels[panelId] != nil { + for (key, lifecycle) in lifecycleStates where lifecycle == .needsInput { + keys.insert(key) + } + } + return keys + } + + /// A pane's own last-reported entry for `key`, while the workspace still + /// shows that key at all. + func agentStatusEntry(key: String, panelId: UUID) -> SidebarStatusEntry? { + guard statusEntries[key] != nil else { return nil } + return agentStatusEntriesByPanelId[panelId]?[key] + } + + func setStatusEntry(_ entry: SidebarStatusEntry, key: String, panelId: UUID?) { + statusEntries[key] = entry + if let panelId, panels[panelId] != nil { + agentStatusEntriesByPanelId[panelId, default: [:]][key] = entry + } + } + + func clearStatusEntry(key: String, panelId: UUID?) { + removeStatusEntry(forKey: key) + } + + /// Removes the key and every pane's copy of it, so a later report for the + /// key never revives an older pane's text. + @discardableResult + func removeStatusEntry(forKey key: String) -> Bool { + for panelId in Array(agentStatusEntriesByPanelId.keys) { + removePanelStatusEntry(key: key, panelId: panelId) + } + return statusEntries.removeValue(forKey: key) != nil + } + + /// Drops one pane's copy, e.g. when its agent lifecycle for the key ends; + /// the row then falls back to the workspace entry for that pane. + func removePanelStatusEntry(key: String, panelId: UUID) { + agentStatusEntriesByPanelId[panelId]?.removeValue(forKey: key) + if agentStatusEntriesByPanelId[panelId]?.isEmpty == true { + agentStatusEntriesByPanelId.removeValue(forKey: panelId) + } + } + + /// Several panes can report the same agent key (two Claude panes share + /// `claude_code`). The pane that most needs the person wins: needs input, + /// then running, then unknown, then idle; the newest report breaks ties. + /// Only live panes that still own the agent (a lifecycle state or an agent + /// PID for the key) count, so an ended pane's last report never lingers. + /// Nil when no pane-scoped report exists; callers fall back to the + /// workspace entry. + func mostUrgentPanelStatusEntry(forKey key: String) -> SidebarStatusEntry? { + var winner: (rank: Int, entry: SidebarStatusEntry)? + for (panelId, entries) in agentStatusEntriesByPanelId where panels[panelId] != nil { + guard let entry = entries[key], panelOwnsAgentStatus(key: key, panelId: panelId) else { continue } + let rank = Self.sidebarStatusUrgencyRank(agentLifecycleStatesByPanelId[panelId]?[key]) + if let current = winner, (current.rank, current.entry.timestamp) >= (rank, entry.timestamp) { + continue + } + winner = (rank, entry) + } + return winner?.entry + } + + private func panelOwnsAgentStatus(key: String, panelId: UUID) -> Bool { + if agentLifecycleStatesByPanelId[panelId]?[key] != nil { return true } + return agentPIDKeysByPanelId[panelId]?.contains { agentStatusKey(forAgentPIDKey: $0) == key } ?? false + } + + private static func sidebarStatusUrgencyRank(_ lifecycle: AgentHibernationLifecycleState?) -> Int { + switch lifecycle { + case .needsInput: 3 + case .running: 2 + case .unknown, nil: 1 + case .idle: 0 } } @@ -82,9 +171,7 @@ extension Workspace { let localAgentKeys = Set(agentPIDs.keys) for statusKey in AgentHibernationLifecycleStatusKeys.allowedStatusKeys where !localAgentKeys.contains(statusKey) { - if statusEntries[statusKey] != nil { - statusEntries.removeValue(forKey: statusKey) - } + removeStatusEntry(forKey: statusKey) _ = clearAgentLifecycle(key: statusKey) } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 104aa17a8e01..7bfa367f9a1b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -355,6 +355,7 @@ extension Workspace { // processes (e.g. claude_code "Running"). Don't restore them across app // restarts because the processes that set them are gone. statusEntries.removeAll() + agentStatusEntriesByPanelId.removeAll() clearAllAgentPIDs(refreshPorts: false) clearAllAgentLifecycleStates() agentListeningPorts.removeAll() @@ -3023,6 +3024,11 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos get { sidebarMetadata.statusEntries } set { sidebarMetadata.statusEntries = newValue } } + /// Each pane's own copy of the agent status it last reported, keyed by + /// panel then status key. `statusEntries` keeps one entry per key (last + /// writer wins), so two panes running the same agent would otherwise hide + /// each other; the row resolves the most urgent pane from these instead. + var agentStatusEntriesByPanelId: [UUID: [String: SidebarStatusEntry]] = [:] var metadataBlocks: [String: SidebarMetadataBlock] { get { sidebarMetadata.metadataBlocks } set { sidebarMetadata.metadataBlocks = newValue } @@ -6454,6 +6460,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos func resetSidebarContext(reason: String = "unspecified") { statusEntries.removeAll() + agentStatusEntriesByPanelId.removeAll() clearAllAgentPIDs(refreshPorts: false) clearAllAgentLifecycleStates() agentListeningPorts.removeAll() @@ -6668,8 +6675,11 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { - sidebarStatusEntriesVisibleForDisplay().sorted { lhs, rhs in + let keysNeedingInput = sidebarStatusKeysNeedingInput() + return sidebarStatusEntriesVisibleForDisplay().sorted { lhs, rhs in if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + let lhsNeedsInput = keysNeedingInput.contains(lhs.key) + if lhsNeedsInput != keysNeedingInput.contains(rhs.key) { return lhsNeedsInput } if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } return lhs.key < rhs.key } diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 86daef43d3d1..db6be683a82a 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -417,6 +417,65 @@ struct WorkspaceSidebarObservationTests { ) == 2 ) } + + /// Two Claude panes share the `claude_code` key (and its one PID, held by + /// the pane that started last). The row is a workspace aggregate: the pane + /// waiting on the person wins over a pane that reported Running later. + @Test func sharedAgentStatusKeyShowsTheMostUrgentPane() throws { + let workspace = Workspace() + let waitingPanelId = try #require(workspace.focusedPanelId) + let runningPanelId = try #require( + workspace.newTerminalSplit(from: waitingPanelId, orientation: .horizontal, focus: false)?.id + ) + let owner = ControlSidebarPanelOwner.workspace(workspace) + workspace.recordAgentPID(key: "claude_code", pid: 12_402, panelId: runningPanelId, refreshPorts: false) + + owner.setStatusEntry( + SidebarStatusEntry(key: "claude_code", value: "Needs input", timestamp: Date(timeIntervalSince1970: 1_000)), + key: "claude_code", + panelId: waitingPanelId + ) + workspace.setAgentLifecycle(key: "claude_code", panelId: waitingPanelId, lifecycle: .needsInput) + owner.setStatusEntry( + SidebarStatusEntry(key: "claude_code", value: "Running", timestamp: Date(timeIntervalSince1970: 2_000)), + key: "claude_code", + panelId: runningPanelId + ) + workspace.setAgentLifecycle(key: "claude_code", panelId: runningPanelId, lifecycle: .running) + + #expect( + workspace.sidebarStatusEntriesInDisplayOrder().first { $0.key == "claude_code" }?.value == "Needs input", + "A later Running report from another pane must not hide the pane waiting on the person." + ) + + _ = workspace.clearAgentLifecycle(key: "claude_code", panelId: waitingPanelId) + #expect( + workspace.sidebarStatusEntriesInDisplayOrder().first { $0.key == "claude_code" }?.value == "Running", + "Once the waiting pane's lifecycle ends, its old Needs input text must not linger." + ) + } + + /// Across different agents of the same priority, one waiting on the person + /// sorts first even when another reported more recently. + @Test func needsInputStatusSortsAheadOfNewerEntries() throws { + let workspace = Workspace() + let codexPanelId = try #require(workspace.focusedPanelId) + let claudePanelId = try #require( + workspace.newTerminalSplit(from: codexPanelId, orientation: .horizontal, focus: false)?.id + ) + workspace.recordAgentPID(key: "codex.a", pid: 12_403, panelId: codexPanelId, refreshPorts: false) + workspace.recordAgentPID(key: "claude_code.a", pid: 12_404, panelId: claudePanelId, refreshPorts: false) + workspace.statusEntries["claude_code"] = SidebarStatusEntry( + key: "claude_code", value: "Needs input", timestamp: Date(timeIntervalSince1970: 1_000) + ) + workspace.statusEntries["codex"] = SidebarStatusEntry( + key: "codex", value: "Running", timestamp: Date(timeIntervalSince1970: 2_000) + ) + workspace.setAgentLifecycle(key: "claude_code", panelId: claudePanelId, lifecycle: .needsInput) + workspace.setAgentLifecycle(key: "codex", panelId: codexPanelId, lifecycle: .running) + + #expect(workspace.sidebarStatusEntriesInDisplayOrder().map(\.key) == ["claude_code", "codex"]) + } } // Mutable flag captured by Observation's Sendable onChange closure in this test.