Skip to content
Open
28 changes: 28 additions & 0 deletions Sources/Workspace+SidebarStatusVisibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +15,9 @@ extension Workspace {
_ entry: SidebarStatusEntry,
visibleStructuredStatusKeys: Set<String>
) -> Bool {
if Self.feedAttentionAgentStatusKey(for: entry.key) != nil {
return visibleStructuredStatusKeys.contains(entry.key)
}
guard AgentHibernationLifecycleStatusKeys.allowedStatusKeys.contains(entry.key) else {
return true
}
Expand All @@ -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<String>()
for statusKeys in statusKeysByPanelId.values {
let winningEntry = statusKeys.compactMap { statusEntries[$0] }.max {
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions cmuxTests/WorkspaceSidebarObservationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading