Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions Sources/ControlSidebarPanelOwner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,22 @@ 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) }
}
}

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)
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Workspace+AgentLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions Sources/Workspace+PanelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 92 additions & 5 deletions Sources/Workspace+SidebarStatusVisibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
var keys = Set<String>()
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
}
}

Expand Down Expand Up @@ -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)
}
}
Expand Down
12 changes: 11 additions & 1 deletion Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
59 changes: 59 additions & 0 deletions cmuxTests/WorkspaceSidebarObservationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading