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
2 changes: 1 addition & 1 deletion Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -14306,7 +14307,6 @@ struct TabItemView: View, Equatable {
tabManager.clearCustomDescription(tabId: tab.id)
}
}

}

if !remoteContextMenuWorkspaceIds.isEmpty {
Expand Down
20 changes: 20 additions & 0 deletions Sources/Workspace+PanelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ extension Workspace {
private static let managedSubagentEnvironmentKey = "CMUX_AGENT_MANAGED_SUBAGENT"
private static let truthyStartupEnvironmentValues: Set<String> = ["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<String>] {
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] ?? []

Expand Down
8 changes: 2 additions & 6 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] = [:]
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<UUID> = []
Expand Down
64 changes: 64 additions & 0 deletions Sources/WorkspaceSidebarAgentRuntimeObservationModel.swift
Original file line number Diff line number Diff line change
@@ -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<String>] = [:]
@ObservationIgnored
private(set) var agentLifecycleStatesByPanelId: [UUID: [String: AgentHibernationLifecycleState]] = [:]
@ObservationIgnored
private(set) var changeGeneration: UInt64 = 0

@ObservationIgnored
private var changeObservers: [UUID: AsyncStream<Void>.Continuation] = [:]

/// Emits whenever any runtime map changes.
func changes() -> AsyncStream<Void> {
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<String>]) {
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(())
}
}
}
17 changes: 16 additions & 1 deletion Sources/WorkspaceSidebarObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Combine
import CmuxCore
import Foundation
import CmuxSidebar
import SwiftUI

private struct SidebarPanelObservationState: Equatable {
let panelIds: [UUID]
Expand All @@ -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?
Expand Down Expand Up @@ -99,7 +115,6 @@ extension Workspace {
$remoteConnectionDetail,
$activeRemoteTerminalSessionCount
)

return Publishers.CombineLatest4(
workspaceFields,
metadataFields,
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2251,6 +2252,7 @@
EE30D6000000000000000001 /* WorkspaceRemoteSessionHostAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSessionHostAdapter.swift; sourceTree = "<group>"; };
92CA6F2F239631988B2FB0C8 /* WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteTmuxNonInteractiveCloseRoute.swift; sourceTree = "<group>"; };
9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/WorkspaceRuntimeSettings.swift; sourceTree = "<group>"; };
6799A0026799A0026799A002 /* WorkspaceSidebarAgentRuntimeObservationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarAgentRuntimeObservationModel.swift; sourceTree = "<group>"; };
5659B0025659B0025659B002 /* WorkspaceSidebarLogEntryLimitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarLogEntryLimitProvider.swift; sourceTree = "<group>"; };
5659A0025659A0025659A002 /* WorkspaceSidebarObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservation.swift; sourceTree = "<group>"; };
5659A0045659A0045659A004 /* WorkspaceSidebarObservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarObservationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
103 changes: 89 additions & 14 deletions cmuxTests/WorkspaceSidebarObservationTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Combine
import Foundation
import XCTest
import Observation
import Testing

import CmuxSidebar

Expand All @@ -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",
Expand All @@ -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"

Expand All @@ -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
Expand All @@ -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
}
}
Loading