diff --git a/Sources/Workspace+SidebarStatusVisibility.swift b/Sources/Workspace+SidebarStatusVisibility.swift index 957ee02c4324..2172d3f5754b 100644 --- a/Sources/Workspace+SidebarStatusVisibility.swift +++ b/Sources/Workspace+SidebarStatusVisibility.swift @@ -2,6 +2,8 @@ import CmuxSidebar import Foundation extension Workspace { + private static let feedAttentionStatusKeyPrefix = "cmux.feed.attention:" + func sidebarStatusEntriesVisibleForDisplay() -> [SidebarStatusEntry] { let visibleStructuredStatusKeys = visibleStructuredAgentStatusKeysByPanel() return statusEntries.values.filter { entry in @@ -13,6 +15,9 @@ extension Workspace { _ entry: SidebarStatusEntry, visibleStructuredStatusKeys: Set ) -> Bool { + if Self.feedAttentionAgentStatusKey(for: entry.key) != nil { + return visibleStructuredStatusKeys.contains(entry.key) + } guard AgentHibernationLifecycleStatusKeys.allowedStatusKeys.contains(entry.key) else { return true } @@ -30,6 +35,23 @@ extension Workspace { } statusKeysByPanelId[panelId, default: []].insert(statusKey) } + + // Feed keeps its blocking-decision status in a separate namespace so + // it cannot overwrite the agent's own lifecycle/status slot. For the + // sidebar, both keys still describe the same agent on this panel and + // must compete for one displayed row. + for (panelId, lifecycleStates) in agentLifecycleStatesByPanelId + where panels[panelId] != nil { + for (key, lifecycle) in lifecycleStates { + guard lifecycle == .needsInput, + Self.feedAttentionAgentStatusKey(for: key) != nil, + statusEntries[key] != nil else { + continue + } + statusKeysByPanelId[panelId, default: []].insert(key) + } + } + var visibleStatusKeys = Set() for statusKeys in statusKeysByPanelId.values { let winningEntry = statusKeys.compactMap { statusEntries[$0] }.max { @@ -52,6 +74,12 @@ extension Workspace { return visibleStatusKeys } + private static func feedAttentionAgentStatusKey(for statusKey: String) -> String? { + guard statusKey.hasPrefix(feedAttentionStatusKeyPrefix) else { return nil } + let agentStatusKey = String(statusKey.dropFirst(feedAttentionStatusKeyPrefix.count)) + return agentStatusKey.isEmpty ? nil : agentStatusKey + } + private func isSidebarStatusEntryLessCurrent( _ lhs: SidebarStatusEntry, than rhs: SidebarStatusEntry diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 86daef43d3d1..3373732b3f4c 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -77,6 +77,82 @@ struct WorkspaceSidebarObservationTests { ) } + @Test func feedNeedsInputOverlayReplacesRunningStatusForSamePanel() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let attentionKey = FeedCoordinator.attentionStatusKey(forSource: "codex") + + workspace.statusEntries["codex"] = SidebarStatusEntry( + key: "codex", + value: "Running", + timestamp: Date(timeIntervalSince1970: 10) + ) + workspace.recordAgentPID( + key: "codex.codex-session", + pid: 12_345, + panelId: panelId, + refreshPorts: false + ) + workspace.setAgentLifecycle( + key: attentionKey, + panelId: panelId, + lifecycle: .needsInput + ) + workspace.statusEntries[attentionKey] = SidebarStatusEntry( + key: attentionKey, + value: FeedCoordinator.needsInputStatusValue, + icon: "bell.fill", + timestamp: Date(timeIntervalSince1970: 20) + ) + + let displayedKeys = Set(workspace.sidebarStatusEntriesInDisplayOrder().map(\.key)) + + #expect( + displayedKeys == Set([attentionKey]), + "A Feed Needs input overlay and the agent's Running status must not render as two rows for one panel." + ) + } + + @Test func feedNeedsInputOverlayDoesNotHideRunningAgentOnAnotherPanel() throws { + let workspace = Workspace() + let runningPanelId = try #require(workspace.focusedPanelId) + let waitingPanel = try #require(workspace.newTerminalSurfaceInFocusedPane(focus: false)) + let attentionKey = FeedCoordinator.attentionStatusKey(forSource: "codex") + + workspace.statusEntries["codex"] = SidebarStatusEntry( + key: "codex", + value: "Running", + timestamp: Date(timeIntervalSince1970: 10) + ) + workspace.recordAgentPID( + key: "codex.running-session", + pid: 12_345, + panelId: runningPanelId, + refreshPorts: false + ) + workspace.recordAgentPID( + key: "codex.waiting-session", + pid: 12_346, + panelId: waitingPanel.id, + refreshPorts: false + ) + workspace.setAgentLifecycle( + key: attentionKey, + panelId: waitingPanel.id, + lifecycle: .needsInput + ) + workspace.statusEntries[attentionKey] = SidebarStatusEntry( + key: attentionKey, + value: FeedCoordinator.needsInputStatusValue, + timestamp: Date(timeIntervalSince1970: 20) + ) + + #expect( + Set(workspace.sidebarStatusEntriesInDisplayOrder().map(\.key)) == Set(["codex", attentionKey]), + "An attention overlay belongs to its panel, not every panel running the same agent." + ) + } + @Test func terminalAgentContextDoesNotObserveAgentRuntimeMaps() throws { let workspace = Workspace() let panelId = try #require(workspace.focusedPanelId)