diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1cd76d26d492..f62e44407ec6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14086,6 +14086,7 @@ struct TabItemView: View, Equatable { guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } + .sidebarAgentRuntimeObservation(id: tab.id, model: tab.sidebarAgentRuntimeObservation) { refreshWorkspaceSnapshot() } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) @@ -14306,7 +14307,6 @@ struct TabItemView: View, Equatable { tabManager.clearCustomDescription(tabId: tab.id) } } - } if !remoteContextMenuWorkspaceIds.isEmpty { diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 874816dfd44c..57861e4711b2 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -10,6 +10,26 @@ extension Workspace { private static let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT" private static let truthyStartupEnvironmentValues: Set = ["1", "true", "yes", "on", "enabled"] + var agentPIDs: [String: pid_t] { + get { sidebarAgentRuntimeObservation.agentPIDs } + set { sidebarAgentRuntimeObservation.setAgentPIDs(newValue) } + } + + var agentPIDPanelIdsByKey: [String: UUID] { + get { sidebarAgentRuntimeObservation.agentPIDPanelIdsByKey } + set { sidebarAgentRuntimeObservation.setAgentPIDPanelIdsByKey(newValue) } + } + + var agentPIDKeysByPanelId: [UUID: Set] { + get { sidebarAgentRuntimeObservation.agentPIDKeysByPanelId } + set { sidebarAgentRuntimeObservation.setAgentPIDKeysByPanelId(newValue) } + } + + var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] { + get { sidebarAgentRuntimeObservation.agentLifecycleStatesByPanelId } + set { sidebarAgentRuntimeObservation.setAgentLifecycleStatesByPanelId(newValue) } + } + func agentRuntimeState(forPanelId panelId: UUID) -> DetachedAgentRuntimeState? { let pidKeys = agentPIDKeysByPanelId[panelId] ?? [] diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 548c19136bbd..d8113fc2b0c8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2509,12 +2509,8 @@ final class Workspace: Identifiable, ObservableObject { get { surfaceRegistry.panelShellActivityStates } set { surfaceRegistry.panelShellActivityStates = newValue } } - /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. - /// Used for stale-session detection: if the PID is dead, the status entry is cleared. - var agentPIDs: [String: pid_t] = [:] - var agentPIDPanelIdsByKey: [String: UUID] = [:] - var agentPIDKeysByPanelId: [UUID: Set] = [:] - var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] + /// Agent runtime maps that affect sidebar status visibility. + let sidebarAgentRuntimeObservation = WorkspaceSidebarAgentRuntimeObservationModel() var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] diff --git a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift new file mode 100644 index 000000000000..00f4b889a880 --- /dev/null +++ b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift @@ -0,0 +1,64 @@ +import Darwin +import Foundation +import Observation + +/// Owns agent runtime maps that affect whether structured sidebar statuses are visible. +@MainActor +@Observable +final class WorkspaceSidebarAgentRuntimeObservationModel { + @ObservationIgnored + private(set) var agentPIDs: [String: pid_t] = [:] + @ObservationIgnored + private(set) var agentPIDPanelIdsByKey: [String: UUID] = [:] + @ObservationIgnored + private(set) var agentPIDKeysByPanelId: [UUID: Set] = [:] + @ObservationIgnored + private(set) var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] + @ObservationIgnored + private(set) var changeGeneration: UInt64 = 0 + + @ObservationIgnored + private var changeObservers: [UUID: AsyncStream.Continuation] = [:] + + /// Emits whenever any runtime map changes. + func changes() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let id = UUID() + changeObservers[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.changeObservers[id] = nil } + } + } + } + + func setAgentPIDs(_ newValue: [String: pid_t]) { + guard agentPIDs != newValue else { return } + agentPIDs = newValue + notifyChanged() + } + + func setAgentPIDPanelIdsByKey(_ newValue: [String: UUID]) { + guard agentPIDPanelIdsByKey != newValue else { return } + agentPIDPanelIdsByKey = newValue + notifyChanged() + } + + func setAgentPIDKeysByPanelId(_ newValue: [UUID: Set]) { + guard agentPIDKeysByPanelId != newValue else { return } + agentPIDKeysByPanelId = newValue + notifyChanged() + } + + func setAgentLifecycleStatesByPanelId(_ newValue: [UUID: [String: AgentHibernationLifecycleState]]) { + guard agentLifecycleStatesByPanelId != newValue else { return } + agentLifecycleStatesByPanelId = newValue + notifyChanged() + } + + private func notifyChanged() { + changeGeneration &+= 1 + for continuation in changeObservers.values { + continuation.yield(()) + } + } +} diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index 3db0a5eff28d..8b2dd957402b 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -2,6 +2,7 @@ import Combine import CmuxCore import Foundation import CmuxSidebar +import SwiftUI private struct SidebarPanelObservationState: Equatable { let panelIds: [UUID] @@ -11,6 +12,21 @@ private struct SidebarPanelObservationState: Equatable { } } +extension View { + func sidebarAgentRuntimeObservation( + id: UUID, + model: WorkspaceSidebarAgentRuntimeObservationModel, + onChange: @MainActor @escaping () -> Void + ) -> some View { + task(id: id) { @MainActor in + for await _ in model.changes() { + if Task.isCancelled { break } + onChange() + } + } + } +} + private struct SidebarImmediateObservationState: Equatable { let title: String let customDescription: String? @@ -99,7 +115,6 @@ extension Workspace { $remoteConnectionDetail, $activeRemoteTerminalSessionCount ) - return Publishers.CombineLatest4( workspaceFields, metadataFields, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index aa6de984a1a5..3092b57165b4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1117,6 +1117,7 @@ EE30D6000000000000000002 /* WorkspaceRemoteSessionHostAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE30D6000000000000000001 /* WorkspaceRemoteSessionHostAdapter.swift */; }; 44FF9EFEEF762BE6CF158E42 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */; }; 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */; }; + 6799A0016799A0016799A001 /* WorkspaceSidebarAgentRuntimeObservationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */; }; 5659B0015659B0015659B001 /* WorkspaceSidebarLogEntryLimitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659B0025659B0025659B002 /* WorkspaceSidebarLogEntryLimitProvider.swift */; }; 5659A0015659A0015659A001 /* WorkspaceSidebarObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */; }; 5659A0035659A0035659A003 /* WorkspaceSidebarObservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5659A0045659A0045659A004 /* WorkspaceSidebarObservationTests.swift */; }; @@ -2251,6 +2252,7 @@ EE30D6000000000000000001 /* WorkspaceRemoteSessionHostAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSessionHostAdapter.swift; sourceTree = ""; }; 92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift; sourceTree = ""; }; 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = ""; }; + 6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarAgentRuntimeObservationModel.swift; sourceTree = ""; }; 5659B0025659B0025659B002 /* WorkspaceSidebarLogEntryLimitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarLogEntryLimitProvider.swift; sourceTree = ""; }; 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservation.swift; sourceTree = ""; }; 5659A0045659A0045659A004 /* WorkspaceSidebarObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservationTests.swift; sourceTree = ""; }; @@ -2711,6 +2713,7 @@ A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, A5001416 /* Workspace.swift */, + 6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */, 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */, 5659B0025659B0025659B002 /* WorkspaceSidebarLogEntryLimitProvider.swift */, D7AB3605C10DEF0000000004 /* WorkspaceCloseTabsBatching.swift */, @@ -4613,6 +4616,7 @@ EE30D6000000000000000002 /* WorkspaceRemoteSessionHostAdapter.swift in Sources */, 44FF9EFEEF762BE6CF158E42 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, + 6799A0016799A0016799A001 /* WorkspaceSidebarAgentRuntimeObservationModel.swift in Sources */, 5659B0015659B0015659B001 /* WorkspaceSidebarLogEntryLimitProvider.swift in Sources */, 5659A0015659A0015659A001 /* WorkspaceSidebarObservation.swift in Sources */, D7AB34400000000000000001 /* WorkspaceSurfaceConfig.swift in Sources */, diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 5ab5eef384a3..791bda6b9417 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -1,5 +1,7 @@ +import Combine import Foundation -import XCTest +import Observation +import Testing import CmuxSidebar @@ -10,8 +12,8 @@ import CmuxSidebar #endif @MainActor -final class WorkspaceSidebarObservationTests: XCTestCase { - func testSidebarObservationPublisherEmitsForLateStatusSubscriber() { +struct WorkspaceSidebarObservationTests { + @Test func sidebarObservationPublisherEmitsForLateStatusSubscriber() { let workspace = Workspace() workspace.statusEntries["test_probe"] = SidebarStatusEntry( key: "test_probe", @@ -27,14 +29,80 @@ final class WorkspaceSidebarObservationTests: XCTestCase { } defer { cancellable.cancel() } - XCTAssertGreaterThan( - publishCount, - 0, + #expect( + publishCount > 0, "A sidebar row that subscribes after status metadata already exists must still refresh from the current workspace state." ) } - func testSidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { + @Test func agentRuntimeObservationChangesWhenAgentPIDMakesExistingStatusVisible() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + workspace.statusEntries["codex"] = SidebarStatusEntry( + key: "codex", + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + #expect( + !workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, + "Structured agent statuses stay hidden until a live agent runtime owns the status key." + ) + + let generationBeforeRecord = workspace.sidebarAgentRuntimeObservation.changeGeneration + var workspaceWillChangeCount = 0 + let objectWillChangeCancellable = workspace.objectWillChange.sink { + workspaceWillChangeCount += 1 + } + defer { objectWillChangeCancellable.cancel() } + + workspace.recordAgentPID( + key: "codex.session-b", + pid: 12_345, + panelId: panelId, + refreshPorts: false + ) + + #expect( + workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, + "Recording the agent PID makes the existing Running status visible." + ) + #expect( + workspace.sidebarAgentRuntimeObservation.changeGeneration > generationBeforeRecord, + "Agent PID ownership changes must notify the sidebar row runtime observation stream." + ) + #expect( + workspaceWillChangeCount == 0, + "Agent PID ownership is sidebar presentation state and must not broadly invalidate Workspace observers." + ) + } + + @Test func terminalAgentContextDoesNotObserveAgentRuntimeMaps() throws { + let workspace = Workspace() + let panelId = try #require(workspace.focusedPanelId) + let panel = try #require(workspace.panels[panelId]) + let changeFlag = ObservationChangeFlag() + + withObservationTracking { + _ = WorkspaceContentView.terminalAgentContext(panel: panel, workspace: workspace) + } onChange: { + changeFlag.mark() + } + + workspace.recordAgentPID( + key: "codex.session-c", + pid: 12_346, + panelId: panelId, + refreshPorts: false + ) + + #expect( + changeFlag.fired == false, + "Terminal content must not subscribe to sidebar-only agent runtime map churn." + ) + } + + @Test func sidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { let workspace = Workspace() workspace.title = "Restored Workspace" @@ -44,14 +112,13 @@ final class WorkspaceSidebarObservationTests: XCTestCase { } defer { cancellable.cancel() } - XCTAssertGreaterThan( - publishCount, - 0, + #expect( + publishCount > 0, "A sidebar row that subscribes after immediate workspace fields already exist must still refresh from the current workspace state." ) } - func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { + @Test func sidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { let workspace = Workspace() var publishCount = 0 @@ -64,10 +131,18 @@ final class WorkspaceSidebarObservationTests: XCTestCase { workspace.remoteHeartbeatCount = 1 workspace.remoteLastHeartbeatAt = Date() - XCTAssertEqual( - publishCount, - 0, + #expect( + publishCount == 0, "Expected non-visible remote heartbeat updates to avoid invalidating sidebar rows" ) } } + +// Mutable flag captured by Observation's Sendable onChange closure in this test. +private final class ObservationChangeFlag: @unchecked Sendable { + private(set) var fired = false + + func mark() { + fired = true + } +}