From bc1cb8cbaf687d3f60f261d9e79a01a2afdaa156 Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 22:16:55 -0700 Subject: [PATCH 1/9] Add regression for sidebar agent status visibility refresh --- .../WorkspaceSidebarObservationTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index 5ab5eef384a3..ecada07cdcc9 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -34,6 +34,45 @@ final class WorkspaceSidebarObservationTests: XCTestCase { ) } + func testSidebarObservationPublisherEmitsWhenAgentPIDMakesExistingStatusVisible() throws { + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + workspace.statusEntries["codex"] = SidebarStatusEntry( + key: "codex", + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + XCTAssertFalse( + workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, + "Structured agent statuses stay hidden until a live agent runtime owns the status key." + ) + + var publishCount = 0 + let cancellable = workspace.sidebarObservationPublisher.sink { + publishCount += 1 + } + defer { cancellable.cancel() } + publishCount = 0 + + workspace.recordAgentPID( + key: "codex.session-b", + pid: 12_345, + panelId: panelId, + refreshPorts: false + ) + + XCTAssertTrue( + workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, + "Recording the agent PID makes the existing Running status visible." + ) + XCTAssertGreaterThan( + publishCount, + 0, + "A sidebar row must refresh when agent PID ownership changes status visibility." + ) + } + func testSidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { let workspace = Workspace() workspace.title = "Restored Workspace" From cf204648cc52e44045fb7ebd8236c9edd981f99e Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 22:18:03 -0700 Subject: [PATCH 2/9] Refresh sidebar on agent runtime status changes --- Sources/Workspace+PanelLifecycle.swift | 10 ++++++++++ Sources/Workspace.swift | 10 ++++++++++ Sources/WorkspaceSidebarObservation.swift | 12 ++++++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 874816dfd44c..27b59b6bc8ee 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -97,6 +97,8 @@ extension Workspace { @discardableResult func recordAgentPID(key: String, pid: pid_t, panelId: UUID?, refreshPorts: Bool = true) -> Bool { + let previousPID = agentPIDs[key] + let previousPanelId = agentPIDPanelIdsByKey[key] var didClearOtherStructuredAgentRuntime = false if let panelId { didClearOtherStructuredAgentRuntime = clearOtherStructuredAgentRuntimes(onPanel: panelId, keeping: key) @@ -110,6 +112,11 @@ extension Workspace { if refreshPorts { refreshTrackedAgentPorts() } + if didClearOtherStructuredAgentRuntime || + previousPID != pid || + previousPanelId != panelId { + sidebarAgentRuntimeObservationToken &+= 1 + } return didClearOtherStructuredAgentRuntime } @@ -240,6 +247,9 @@ extension Workspace { if didChange, refreshPorts { refreshTrackedAgentPorts() } + if didChange { + sidebarAgentRuntimeObservationToken &+= 1 + } return didChange } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 548c19136bbd..340db3159566 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2515,6 +2515,7 @@ final class Workspace: Identifiable, ObservableObject { var agentPIDPanelIdsByKey: [String: UUID] = [:] var agentPIDKeysByPanelId: [UUID: Set] = [:] var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] + @Published var sidebarAgentRuntimeObservationToken: UInt64 = 0 var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] @@ -4558,8 +4559,12 @@ final class Workspace: Identifiable, ObservableObject { ) { let targetPanelId = panelId ?? focusedPanelId guard let targetPanelId, panels[targetPanelId] != nil else { return } + let previousLifecycle = agentLifecycleStatesByPanelId[targetPanelId]?[key] agentLifecycleStatesByPanelId[targetPanelId, default: [:]][key] = lifecycle recordAgentLifecycleChange(panelId: targetPanelId) + if previousLifecycle != lifecycle { + sidebarAgentRuntimeObservationToken &+= 1 + } } @discardableResult @@ -4575,12 +4580,16 @@ final class Workspace: Identifiable, ObservableObject { didClear = true recordAgentLifecycleChange(panelId: panelId) } + if didClear { + sidebarAgentRuntimeObservationToken &+= 1 + } return didClear } func clearAgentLifecycleStates(panelId: UUID) { guard agentLifecycleStatesByPanelId.removeValue(forKey: panelId) != nil else { return } recordAgentLifecycleChange(panelId: panelId) + sidebarAgentRuntimeObservationToken &+= 1 } func clearAllAgentLifecycleStates() { @@ -4590,6 +4599,7 @@ final class Workspace: Identifiable, ObservableObject { for panelId in panelIds { recordAgentLifecycleChange(panelId: panelId) } + sidebarAgentRuntimeObservationToken &+= 1 } private func recordAgentLifecycleChange(panelId: UUID) { diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index 3db0a5eff28d..35d5b8b17f62 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -39,6 +39,7 @@ private struct SidebarObservationState: Equatable { let remoteConnectionDetail: String? let activeRemoteTerminalSessionCount: Int let listeningPorts: [Int] + let agentRuntimeObservationToken: UInt64 let browserMediaActivity: BrowserMediaActivity } @@ -99,6 +100,10 @@ extension Workspace { $remoteConnectionDetail, $activeRemoteTerminalSessionCount ) + let presentationInvalidationFields = Publishers.CombineLatest( + $listeningPorts, + $sidebarAgentRuntimeObservationToken + ) return Publishers.CombineLatest4( workspaceFields, @@ -106,13 +111,15 @@ extension Workspace { gitFields, remoteFields ) - .combineLatest($listeningPorts) - .compactMap { [weak self] groupedFields, listeningPorts -> SidebarObservationState? in + .combineLatest(presentationInvalidationFields) + .compactMap { [weak self] groupedFields, presentationInvalidationFields -> SidebarObservationState? in guard let self else { return nil } let workspaceFields = groupedFields.0 let metadataFields = groupedFields.1 let gitFields = groupedFields.2 let remoteFields = groupedFields.3 + let listeningPorts = presentationInvalidationFields.0 + let agentRuntimeObservationToken = presentationInvalidationFields.1 return SidebarObservationState( currentDirectory: workspaceFields.0, extensionSidebarProjectRootPath: workspaceFields.1, @@ -131,6 +138,7 @@ extension Workspace { remoteConnectionDetail: remoteFields.2, activeRemoteTerminalSessionCount: remoteFields.3, listeningPorts: listeningPorts, + agentRuntimeObservationToken: agentRuntimeObservationToken, browserMediaActivity: self.browserMediaActivity ) } From 542d8746113d153898f8c0afc443b2a05c36bd54 Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 22:25:34 -0700 Subject: [PATCH 3/9] Observe agent runtime state in sidebar rows --- Sources/Workspace+PanelLifecycle.swift | 10 -------- Sources/Workspace.swift | 18 +++----------- Sources/WorkspaceSidebarObservation.swift | 30 ++++++++++++++++++++--- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 27b59b6bc8ee..874816dfd44c 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -97,8 +97,6 @@ extension Workspace { @discardableResult func recordAgentPID(key: String, pid: pid_t, panelId: UUID?, refreshPorts: Bool = true) -> Bool { - let previousPID = agentPIDs[key] - let previousPanelId = agentPIDPanelIdsByKey[key] var didClearOtherStructuredAgentRuntime = false if let panelId { didClearOtherStructuredAgentRuntime = clearOtherStructuredAgentRuntimes(onPanel: panelId, keeping: key) @@ -112,11 +110,6 @@ extension Workspace { if refreshPorts { refreshTrackedAgentPorts() } - if didClearOtherStructuredAgentRuntime || - previousPID != pid || - previousPanelId != panelId { - sidebarAgentRuntimeObservationToken &+= 1 - } return didClearOtherStructuredAgentRuntime } @@ -247,9 +240,6 @@ extension Workspace { if didChange, refreshPorts { refreshTrackedAgentPorts() } - if didChange { - sidebarAgentRuntimeObservationToken &+= 1 - } return didChange } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 340db3159566..42c73ac477a7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2511,11 +2511,10 @@ final class Workspace: Identifiable, ObservableObject { } /// 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]] = [:] - @Published var sidebarAgentRuntimeObservationToken: UInt64 = 0 + @Published var agentPIDs: [String: pid_t] = [:] + @Published var agentPIDPanelIdsByKey: [String: UUID] = [:] + @Published var agentPIDKeysByPanelId: [UUID: Set] = [:] + @Published var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] @@ -4559,12 +4558,8 @@ final class Workspace: Identifiable, ObservableObject { ) { let targetPanelId = panelId ?? focusedPanelId guard let targetPanelId, panels[targetPanelId] != nil else { return } - let previousLifecycle = agentLifecycleStatesByPanelId[targetPanelId]?[key] agentLifecycleStatesByPanelId[targetPanelId, default: [:]][key] = lifecycle recordAgentLifecycleChange(panelId: targetPanelId) - if previousLifecycle != lifecycle { - sidebarAgentRuntimeObservationToken &+= 1 - } } @discardableResult @@ -4580,16 +4575,12 @@ final class Workspace: Identifiable, ObservableObject { didClear = true recordAgentLifecycleChange(panelId: panelId) } - if didClear { - sidebarAgentRuntimeObservationToken &+= 1 - } return didClear } func clearAgentLifecycleStates(panelId: UUID) { guard agentLifecycleStatesByPanelId.removeValue(forKey: panelId) != nil else { return } recordAgentLifecycleChange(panelId: panelId) - sidebarAgentRuntimeObservationToken &+= 1 } func clearAllAgentLifecycleStates() { @@ -4599,7 +4590,6 @@ final class Workspace: Identifiable, ObservableObject { for panelId in panelIds { recordAgentLifecycleChange(panelId: panelId) } - sidebarAgentRuntimeObservationToken &+= 1 } private func recordAgentLifecycleChange(panelId: UUID) { diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index 35d5b8b17f62..8c091757ca18 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -1,5 +1,6 @@ import Combine import CmuxCore +import Darwin import Foundation import CmuxSidebar @@ -39,10 +40,17 @@ private struct SidebarObservationState: Equatable { let remoteConnectionDetail: String? let activeRemoteTerminalSessionCount: Int let listeningPorts: [Int] - let agentRuntimeObservationToken: UInt64 + let agentRuntime: SidebarAgentRuntimeObservationState let browserMediaActivity: BrowserMediaActivity } +private struct SidebarAgentRuntimeObservationState: Equatable { + let agentPIDs: [String: pid_t] + let agentPIDPanelIdsByKey: [String: UUID] + let agentPIDKeysByPanelId: [UUID: Set] + let agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] +} + extension Workspace { func makeSidebarImmediateObservationPublisher() -> AnyPublisher { let workspaceFields = Publishers.CombineLatest4( @@ -100,9 +108,23 @@ extension Workspace { $remoteConnectionDetail, $activeRemoteTerminalSessionCount ) + let agentRuntimeFields = Publishers.CombineLatest4( + $agentPIDs, + $agentPIDPanelIdsByKey, + $agentPIDKeysByPanelId, + $agentLifecycleStatesByPanelId + ) + .map { agentPIDs, agentPIDPanelIdsByKey, agentPIDKeysByPanelId, agentLifecycleStatesByPanelId in + SidebarAgentRuntimeObservationState( + agentPIDs: agentPIDs, + agentPIDPanelIdsByKey: agentPIDPanelIdsByKey, + agentPIDKeysByPanelId: agentPIDKeysByPanelId, + agentLifecycleStatesByPanelId: agentLifecycleStatesByPanelId + ) + } let presentationInvalidationFields = Publishers.CombineLatest( $listeningPorts, - $sidebarAgentRuntimeObservationToken + agentRuntimeFields ) return Publishers.CombineLatest4( @@ -119,7 +141,7 @@ extension Workspace { let gitFields = groupedFields.2 let remoteFields = groupedFields.3 let listeningPorts = presentationInvalidationFields.0 - let agentRuntimeObservationToken = presentationInvalidationFields.1 + let agentRuntime = presentationInvalidationFields.1 return SidebarObservationState( currentDirectory: workspaceFields.0, extensionSidebarProjectRootPath: workspaceFields.1, @@ -138,7 +160,7 @@ extension Workspace { remoteConnectionDetail: remoteFields.2, activeRemoteTerminalSessionCount: remoteFields.3, listeningPorts: listeningPorts, - agentRuntimeObservationToken: agentRuntimeObservationToken, + agentRuntime: agentRuntime, browserMediaActivity: self.browserMediaActivity ) } From fd846a9b2f038d8fe2b44b17a2fa3d516b321460 Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 22:26:54 -0700 Subject: [PATCH 4/9] Address sidebar observation review feedback --- Sources/WorkspaceSidebarObservation.swift | 34 ++++++---------- .../WorkspaceSidebarObservationTests.swift | 40 +++++++++---------- 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index 8c091757ca18..7da5b4a8075b 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -40,15 +40,11 @@ private struct SidebarObservationState: Equatable { let remoteConnectionDetail: String? let activeRemoteTerminalSessionCount: Int let listeningPorts: [Int] - let agentRuntime: SidebarAgentRuntimeObservationState - let browserMediaActivity: BrowserMediaActivity -} - -private struct SidebarAgentRuntimeObservationState: Equatable { let agentPIDs: [String: pid_t] let agentPIDPanelIdsByKey: [String: UUID] let agentPIDKeysByPanelId: [UUID: Set] let agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] + let browserMediaActivity: BrowserMediaActivity } extension Workspace { @@ -108,23 +104,14 @@ extension Workspace { $remoteConnectionDetail, $activeRemoteTerminalSessionCount ) - let agentRuntimeFields = Publishers.CombineLatest4( - $agentPIDs, - $agentPIDPanelIdsByKey, - $agentPIDKeysByPanelId, - $agentLifecycleStatesByPanelId - ) - .map { agentPIDs, agentPIDPanelIdsByKey, agentPIDKeysByPanelId, agentLifecycleStatesByPanelId in - SidebarAgentRuntimeObservationState( - agentPIDs: agentPIDs, - agentPIDPanelIdsByKey: agentPIDPanelIdsByKey, - agentPIDKeysByPanelId: agentPIDKeysByPanelId, - agentLifecycleStatesByPanelId: agentLifecycleStatesByPanelId - ) - } let presentationInvalidationFields = Publishers.CombineLatest( $listeningPorts, - agentRuntimeFields + Publishers.CombineLatest4( + $agentPIDs, + $agentPIDPanelIdsByKey, + $agentPIDKeysByPanelId, + $agentLifecycleStatesByPanelId + ) ) return Publishers.CombineLatest4( @@ -141,7 +128,7 @@ extension Workspace { let gitFields = groupedFields.2 let remoteFields = groupedFields.3 let listeningPorts = presentationInvalidationFields.0 - let agentRuntime = presentationInvalidationFields.1 + let agentRuntimeFields = presentationInvalidationFields.1 return SidebarObservationState( currentDirectory: workspaceFields.0, extensionSidebarProjectRootPath: workspaceFields.1, @@ -160,7 +147,10 @@ extension Workspace { remoteConnectionDetail: remoteFields.2, activeRemoteTerminalSessionCount: remoteFields.3, listeningPorts: listeningPorts, - agentRuntime: agentRuntime, + agentPIDs: agentRuntimeFields.0, + agentPIDPanelIdsByKey: agentRuntimeFields.1, + agentPIDKeysByPanelId: agentRuntimeFields.2, + agentLifecycleStatesByPanelId: agentRuntimeFields.3, browserMediaActivity: self.browserMediaActivity ) } diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index ecada07cdcc9..ba194dac603b 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -1,5 +1,5 @@ import Foundation -import XCTest +import Testing import CmuxSidebar @@ -10,8 +10,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,24 +27,23 @@ 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 testSidebarObservationPublisherEmitsWhenAgentPIDMakesExistingStatusVisible() throws { + @Test func sidebarObservationPublisherEmitsWhenAgentPIDMakesExistingStatusVisible() throws { let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) + let panelId = try #require(workspace.focusedPanelId) workspace.statusEntries["codex"] = SidebarStatusEntry( key: "codex", value: "Running", icon: "bolt.fill", color: "#4C8DFF" ) - XCTAssertFalse( - workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, + #expect( + !workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, "Structured agent statuses stay hidden until a live agent runtime owns the status key." ) @@ -62,18 +61,17 @@ final class WorkspaceSidebarObservationTests: XCTestCase { refreshPorts: false ) - XCTAssertTrue( + #expect( workspace.sidebarStatusEntriesInDisplayOrder().contains { $0.key == "codex" }, "Recording the agent PID makes the existing Running status visible." ) - XCTAssertGreaterThan( - publishCount, - 0, + #expect( + publishCount > 0, "A sidebar row must refresh when agent PID ownership changes status visibility." ) } - func testSidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { + @Test func sidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { let workspace = Workspace() workspace.title = "Restored Workspace" @@ -83,14 +81,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 @@ -103,9 +100,8 @@ 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" ) } From 3dfabcf3be89c547aa500d615275c638654650ad Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 22:46:18 -0700 Subject: [PATCH 5/9] Avoid broad workspace invalidation for agent runtime --- Sources/Workspace.swift | 36 ++++++++++++++++--- Sources/WorkspaceSidebarObservation.swift | 24 ++++++------- .../WorkspaceSidebarObservationTests.swift | 10 ++++++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 42c73ac477a7..37918682428a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2511,10 +2511,25 @@ final class Workspace: Identifiable, ObservableObject { } /// 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. - @Published var agentPIDs: [String: pid_t] = [:] - @Published var agentPIDPanelIdsByKey: [String: UUID] = [:] - @Published var agentPIDKeysByPanelId: [UUID: Set] = [:] - @Published var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] + var agentPIDs: [String: pid_t] = [:] { + didSet { publishAgentRuntimeObservationState() } + } + var agentPIDPanelIdsByKey: [String: UUID] = [:] { + didSet { publishAgentRuntimeObservationState() } + } + var agentPIDKeysByPanelId: [UUID: Set] = [:] { + didSet { publishAgentRuntimeObservationState() } + } + var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] { + didSet { publishAgentRuntimeObservationState() } + } + private lazy var agentRuntimeObservationSubject = + CurrentValueSubject( + currentAgentRuntimeObservationState() + ) + var agentRuntimeObservationPublisher: AnyPublisher { + agentRuntimeObservationSubject.eraseToAnyPublisher() + } var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] @@ -2542,6 +2557,19 @@ final class Workspace: Identifiable, ObservableObject { lazy var sidebarImmediateObservationPublisher: AnyPublisher = makeSidebarImmediateObservationPublisher() lazy var sidebarObservationPublisher: AnyPublisher = makeSidebarObservationPublisher() + private func currentAgentRuntimeObservationState() -> WorkspaceSidebarAgentRuntimeObservationState { + WorkspaceSidebarAgentRuntimeObservationState( + agentPIDs: agentPIDs, + agentPIDPanelIdsByKey: agentPIDPanelIdsByKey, + agentPIDKeysByPanelId: agentPIDKeysByPanelId, + agentLifecycleStatesByPanelId: agentLifecycleStatesByPanelId + ) + } + + private func publishAgentRuntimeObservationState() { + agentRuntimeObservationSubject.send(currentAgentRuntimeObservationState()) + } + private func scheduleExtensionSidebarProjectRootRefresh(for directory: String) { extensionSidebarProjectRootRefreshID &+= 1 let refreshID = extensionSidebarProjectRootRefreshID diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index 7da5b4a8075b..ca152d1d64e6 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -22,6 +22,13 @@ private struct SidebarImmediateObservationState: Equatable { let latestSubmittedAt: Date? } +struct WorkspaceSidebarAgentRuntimeObservationState: Equatable { + let agentPIDs: [String: pid_t] + let agentPIDPanelIdsByKey: [String: UUID] + let agentPIDKeysByPanelId: [UUID: Set] + let agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] +} + private struct SidebarObservationState: Equatable { let currentDirectory: String let extensionSidebarProjectRootPath: String? @@ -40,10 +47,7 @@ private struct SidebarObservationState: Equatable { let remoteConnectionDetail: String? let activeRemoteTerminalSessionCount: Int let listeningPorts: [Int] - let agentPIDs: [String: pid_t] - let agentPIDPanelIdsByKey: [String: UUID] - let agentPIDKeysByPanelId: [UUID: Set] - let agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] + let agentRuntime: WorkspaceSidebarAgentRuntimeObservationState let browserMediaActivity: BrowserMediaActivity } @@ -106,12 +110,7 @@ extension Workspace { ) let presentationInvalidationFields = Publishers.CombineLatest( $listeningPorts, - Publishers.CombineLatest4( - $agentPIDs, - $agentPIDPanelIdsByKey, - $agentPIDKeysByPanelId, - $agentLifecycleStatesByPanelId - ) + agentRuntimeObservationPublisher ) return Publishers.CombineLatest4( @@ -147,10 +146,7 @@ extension Workspace { remoteConnectionDetail: remoteFields.2, activeRemoteTerminalSessionCount: remoteFields.3, listeningPorts: listeningPorts, - agentPIDs: agentRuntimeFields.0, - agentPIDPanelIdsByKey: agentRuntimeFields.1, - agentPIDKeysByPanelId: agentRuntimeFields.2, - agentLifecycleStatesByPanelId: agentRuntimeFields.3, + agentRuntime: agentRuntimeFields, browserMediaActivity: self.browserMediaActivity ) } diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index ba194dac603b..307adbd3b89a 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import Testing @@ -52,6 +53,11 @@ struct WorkspaceSidebarObservationTests { publishCount += 1 } defer { cancellable.cancel() } + var workspaceWillChangeCount = 0 + let objectWillChangeCancellable = workspace.objectWillChange.sink { + workspaceWillChangeCount += 1 + } + defer { objectWillChangeCancellable.cancel() } publishCount = 0 workspace.recordAgentPID( @@ -69,6 +75,10 @@ struct WorkspaceSidebarObservationTests { publishCount > 0, "A sidebar row must refresh when agent PID ownership changes status visibility." ) + #expect( + workspaceWillChangeCount == 0, + "Agent PID ownership is sidebar presentation state and must not broadly invalidate Workspace observers." + ) } @Test func sidebarImmediateObservationPublisherEmitsForLateTitleSubscriber() { From b760f8a041540e5cf00888782caf6f3c41757f33 Mon Sep 17 00:00:00 2001 From: austinywang Date: Thu, 25 Jun 2026 23:09:23 -0700 Subject: [PATCH 6/9] Use observable agent runtime sidebar updates --- Sources/ContentView.swift | 16 +++++ Sources/Workspace.swift | 41 ++++--------- ...eSidebarAgentRuntimeObservationModel.swift | 60 +++++++++++++++++++ Sources/WorkspaceSidebarObservation.swift | 21 +------ cmux.xcodeproj/project.pbxproj | 4 ++ .../WorkspaceSidebarObservationTests.swift | 13 ++-- 6 files changed, 99 insertions(+), 56 deletions(-) create mode 100644 Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ac3cff00d88b..5848477a83b7 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13788,6 +13788,22 @@ struct TabItemView: View, Equatable { guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } + .task(id: tab.id) { @MainActor in + for await _ in tab.sidebarAgentRuntimeObservation.changes() { + if Task.isCancelled { break } +#if DEBUG + let description = tab.customDescription ?? "" + cmuxDebugLog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=agentRuntime " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + refreshWorkspaceSnapshot() + } + } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 37918682428a..feee6e049e40 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2511,24 +2511,22 @@ final class Workspace: Identifiable, ObservableObject { } /// 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] = [:] { - didSet { publishAgentRuntimeObservationState() } + let sidebarAgentRuntimeObservation = WorkspaceSidebarAgentRuntimeObservationModel() + var agentPIDs: [String: pid_t] { + get { sidebarAgentRuntimeObservation.agentPIDs } + set { sidebarAgentRuntimeObservation.setAgentPIDs(newValue) } } - var agentPIDPanelIdsByKey: [String: UUID] = [:] { - didSet { publishAgentRuntimeObservationState() } + var agentPIDPanelIdsByKey: [String: UUID] { + get { sidebarAgentRuntimeObservation.agentPIDPanelIdsByKey } + set { sidebarAgentRuntimeObservation.setAgentPIDPanelIdsByKey(newValue) } } - var agentPIDKeysByPanelId: [UUID: Set] = [:] { - didSet { publishAgentRuntimeObservationState() } + var agentPIDKeysByPanelId: [UUID: Set] { + get { sidebarAgentRuntimeObservation.agentPIDKeysByPanelId } + set { sidebarAgentRuntimeObservation.setAgentPIDKeysByPanelId(newValue) } } - var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] { - didSet { publishAgentRuntimeObservationState() } - } - private lazy var agentRuntimeObservationSubject = - CurrentValueSubject( - currentAgentRuntimeObservationState() - ) - var agentRuntimeObservationPublisher: AnyPublisher { - agentRuntimeObservationSubject.eraseToAnyPublisher() + var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] { + get { sidebarAgentRuntimeObservation.agentLifecycleStatesByPanelId } + set { sidebarAgentRuntimeObservation.setAgentLifecycleStatesByPanelId(newValue) } } var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG @@ -2557,19 +2555,6 @@ final class Workspace: Identifiable, ObservableObject { lazy var sidebarImmediateObservationPublisher: AnyPublisher = makeSidebarImmediateObservationPublisher() lazy var sidebarObservationPublisher: AnyPublisher = makeSidebarObservationPublisher() - private func currentAgentRuntimeObservationState() -> WorkspaceSidebarAgentRuntimeObservationState { - WorkspaceSidebarAgentRuntimeObservationState( - agentPIDs: agentPIDs, - agentPIDPanelIdsByKey: agentPIDPanelIdsByKey, - agentPIDKeysByPanelId: agentPIDKeysByPanelId, - agentLifecycleStatesByPanelId: agentLifecycleStatesByPanelId - ) - } - - private func publishAgentRuntimeObservationState() { - agentRuntimeObservationSubject.send(currentAgentRuntimeObservationState()) - } - private func scheduleExtensionSidebarProjectRootRefresh(for directory: String) { extensionSidebarProjectRootRefreshID &+= 1 let refreshID = extensionSidebarProjectRootRefreshID diff --git a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift new file mode 100644 index 000000000000..4444bac02857 --- /dev/null +++ b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift @@ -0,0 +1,60 @@ +import Darwin +import Foundation +import Observation + +/// Owns agent runtime maps that affect whether structured sidebar statuses are visible. +@MainActor +@Observable +final class WorkspaceSidebarAgentRuntimeObservationModel { + private(set) var agentPIDs: [String: pid_t] = [:] + private(set) var agentPIDPanelIdsByKey: [String: UUID] = [:] + private(set) var agentPIDKeysByPanelId: [UUID: Set] = [:] + private(set) var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:] + private(set) var changeGeneration: UInt64 = 0 + + @ObservationIgnored + private var changeObservers: [UUID: AsyncStream.Continuation] = [:] + + /// Emits immediately on subscription and again whenever any runtime map changes. + func changes() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let id = UUID() + changeObservers[id] = continuation + continuation.yield(()) + 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 ca152d1d64e6..aa70f1d39d4e 100644 --- a/Sources/WorkspaceSidebarObservation.swift +++ b/Sources/WorkspaceSidebarObservation.swift @@ -1,6 +1,5 @@ import Combine import CmuxCore -import Darwin import Foundation import CmuxSidebar @@ -22,13 +21,6 @@ private struct SidebarImmediateObservationState: Equatable { let latestSubmittedAt: Date? } -struct WorkspaceSidebarAgentRuntimeObservationState: Equatable { - let agentPIDs: [String: pid_t] - let agentPIDPanelIdsByKey: [String: UUID] - let agentPIDKeysByPanelId: [UUID: Set] - let agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] -} - private struct SidebarObservationState: Equatable { let currentDirectory: String let extensionSidebarProjectRootPath: String? @@ -47,7 +39,6 @@ private struct SidebarObservationState: Equatable { let remoteConnectionDetail: String? let activeRemoteTerminalSessionCount: Int let listeningPorts: [Int] - let agentRuntime: WorkspaceSidebarAgentRuntimeObservationState let browserMediaActivity: BrowserMediaActivity } @@ -108,26 +99,19 @@ extension Workspace { $remoteConnectionDetail, $activeRemoteTerminalSessionCount ) - let presentationInvalidationFields = Publishers.CombineLatest( - $listeningPorts, - agentRuntimeObservationPublisher - ) - return Publishers.CombineLatest4( workspaceFields, metadataFields, gitFields, remoteFields ) - .combineLatest(presentationInvalidationFields) - .compactMap { [weak self] groupedFields, presentationInvalidationFields -> SidebarObservationState? in + .combineLatest($listeningPorts) + .compactMap { [weak self] groupedFields, listeningPorts -> SidebarObservationState? in guard let self else { return nil } let workspaceFields = groupedFields.0 let metadataFields = groupedFields.1 let gitFields = groupedFields.2 let remoteFields = groupedFields.3 - let listeningPorts = presentationInvalidationFields.0 - let agentRuntimeFields = presentationInvalidationFields.1 return SidebarObservationState( currentDirectory: workspaceFields.0, extensionSidebarProjectRootPath: workspaceFields.1, @@ -146,7 +130,6 @@ extension Workspace { remoteConnectionDetail: remoteFields.2, activeRemoteTerminalSessionCount: remoteFields.3, listeningPorts: listeningPorts, - agentRuntime: agentRuntimeFields, browserMediaActivity: self.browserMediaActivity ) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8da4291ed351..647eb3fab162 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1088,6 +1088,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 */; }; @@ -2193,6 +2194,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 = ""; }; @@ -2647,6 +2649,7 @@ A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, A5001416 /* Workspace.swift */, + 6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */, 5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */, 5659B0025659B0025659B002 /* WorkspaceSidebarLogEntryLimitProvider.swift */, D7AB3605C10DEF0000000004 /* WorkspaceCloseTabsBatching.swift */, @@ -4499,6 +4502,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 307adbd3b89a..f0106f0ad2b8 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -34,7 +34,7 @@ struct WorkspaceSidebarObservationTests { ) } - @Test func sidebarObservationPublisherEmitsWhenAgentPIDMakesExistingStatusVisible() throws { + @Test func agentRuntimeObservationChangesWhenAgentPIDMakesExistingStatusVisible() throws { let workspace = Workspace() let panelId = try #require(workspace.focusedPanelId) workspace.statusEntries["codex"] = SidebarStatusEntry( @@ -48,17 +48,12 @@ struct WorkspaceSidebarObservationTests { "Structured agent statuses stay hidden until a live agent runtime owns the status key." ) - var publishCount = 0 - let cancellable = workspace.sidebarObservationPublisher.sink { - publishCount += 1 - } - defer { cancellable.cancel() } + let generationBeforeRecord = workspace.sidebarAgentRuntimeObservation.changeGeneration var workspaceWillChangeCount = 0 let objectWillChangeCancellable = workspace.objectWillChange.sink { workspaceWillChangeCount += 1 } defer { objectWillChangeCancellable.cancel() } - publishCount = 0 workspace.recordAgentPID( key: "codex.session-b", @@ -72,8 +67,8 @@ struct WorkspaceSidebarObservationTests { "Recording the agent PID makes the existing Running status visible." ) #expect( - publishCount > 0, - "A sidebar row must refresh when agent PID ownership changes status visibility." + workspace.sidebarAgentRuntimeObservation.changeGeneration > generationBeforeRecord, + "Agent PID ownership changes must notify the sidebar row runtime observation stream." ) #expect( workspaceWillChangeCount == 0, From 1e838f06ce1952f1244632a9c0dad879c0573a29 Mon Sep 17 00:00:00 2001 From: austinywang Date: Fri, 26 Jun 2026 16:16:58 -0700 Subject: [PATCH 7/9] Avoid initial sidebar runtime replay during row mount --- Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift index 4444bac02857..8d5177c1600f 100644 --- a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift +++ b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift @@ -15,12 +15,11 @@ final class WorkspaceSidebarAgentRuntimeObservationModel { @ObservationIgnored private var changeObservers: [UUID: AsyncStream.Continuation] = [:] - /// Emits immediately on subscription and again whenever any runtime map changes. + /// Emits whenever any runtime map changes. func changes() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in let id = UUID() changeObservers[id] = continuation - continuation.yield(()) continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.changeObservers[id] = nil } } From 69c6ab77e219ab0444f9eba0f19f72d0d9ae7df0 Mon Sep 17 00:00:00 2001 From: austinywang Date: Fri, 26 Jun 2026 16:29:01 -0700 Subject: [PATCH 8/9] Trim sidebar runtime observation from large files --- Sources/ContentView.swift | 18 +----------------- Sources/Workspace+PanelLifecycle.swift | 20 ++++++++++++++++++++ Sources/Workspace.swift | 19 +------------------ Sources/WorkspaceSidebarObservation.swift | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5848477a83b7..cc38163a13bd 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13788,22 +13788,7 @@ struct TabItemView: View, Equatable { guard !Task.isCancelled, workspaceFinderDirectoryOpenRequest == request else { return } workspaceFinderDirectoryOpenRequest = nil } - .task(id: tab.id) { @MainActor in - for await _ in tab.sidebarAgentRuntimeObservation.changes() { - if Task.isCancelled { break } -#if DEBUG - let description = tab.customDescription ?? "" - cmuxDebugLog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=agentRuntime " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - refreshWorkspaceSnapshot() - } - } + .sidebarAgentRuntimeObservation(id: tab.id, model: tab.sidebarAgentRuntimeObservation) { refreshWorkspaceSnapshot() } .onReceive( tab.sidebarImmediateObservationPublisher .receive(on: RunLoop.main) @@ -14025,7 +14010,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 feee6e049e40..d8113fc2b0c8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2509,25 +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. + /// Agent runtime maps that affect sidebar status visibility. let sidebarAgentRuntimeObservation = WorkspaceSidebarAgentRuntimeObservationModel() - 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) } - } var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] #if DEBUG var debugSessionSnapshotScrollbackFallbackPanelIds: Set = [] diff --git a/Sources/WorkspaceSidebarObservation.swift b/Sources/WorkspaceSidebarObservation.swift index aa70f1d39d4e..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? From 75f392c667bf904422341846b1eb925ce8bbf59d Mon Sep 17 00:00:00 2001 From: austinywang Date: Fri, 26 Jun 2026 21:20:52 -0700 Subject: [PATCH 9/9] Avoid observing sidebar runtime maps from terminal views --- ...eSidebarAgentRuntimeObservationModel.swift | 5 +++ .../WorkspaceSidebarObservationTests.swift | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift index 8d5177c1600f..00f4b889a880 100644 --- a/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift +++ b/Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift @@ -6,10 +6,15 @@ import Observation @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 diff --git a/cmuxTests/WorkspaceSidebarObservationTests.swift b/cmuxTests/WorkspaceSidebarObservationTests.swift index f0106f0ad2b8..791bda6b9417 100644 --- a/cmuxTests/WorkspaceSidebarObservationTests.swift +++ b/cmuxTests/WorkspaceSidebarObservationTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import Observation import Testing import CmuxSidebar @@ -76,6 +77,31 @@ struct WorkspaceSidebarObservationTests { ) } + @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" @@ -111,3 +137,12 @@ struct WorkspaceSidebarObservationTests { ) } } + +// 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 + } +}