From 4949ac80278e350205fc23aed82ce0a51c34dde4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 18 Sep 2026 17:54:53 -0700 Subject: [PATCH 1/2] Extract session snapshot persistence writer --- ...Delegate+CrashSessionSnapshotRemoval.swift | 10 +-- Sources/AppDelegate.swift | 46 +++------- .../SessionSnapshotPersistenceWriter.swift | 87 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 20 +++++ 4 files changed, 123 insertions(+), 40 deletions(-) create mode 100644 Sources/SessionSnapshotPersistenceWriter.swift diff --git a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift index 96334527a2e2..6b22a4dd56b1 100644 --- a/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift +++ b/Sources/AppDelegate+CrashSessionSnapshotRemoval.swift @@ -24,25 +24,21 @@ extension AppDelegate { } } - private nonisolated static var crashOnlyPrimarySnapshotRemovalDefaultsKey: String { - "cmux.session.crashOnlyPrimarySnapshotRemoval.v1" - } - nonisolated static func markCrashOnlyPrimarySnapshotRemoval( defaults: UserDefaults = .standard ) { - defaults.set(true, forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + SessionSnapshotPersistenceWriter.markCrashOnlyPrimarySnapshotRemoval(defaults: defaults) } nonisolated static func hasCrashOnlyPrimarySnapshotRemovalMarker( defaults: UserDefaults = .standard ) -> Bool { - defaults.bool(forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + SessionSnapshotPersistenceWriter.hasCrashOnlyPrimarySnapshotRemovalMarker(defaults: defaults) } nonisolated static func clearCrashOnlyPrimarySnapshotRemovalMarker( defaults: UserDefaults = .standard ) { - defaults.removeObject(forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + SessionSnapshotPersistenceWriter.clearCrashOnlyPrimarySnapshotRemovalMarker(defaults: defaults) } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2d62aa3a112e..33623f992cd0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -665,13 +665,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } nonisolated static let persistedWindowGeometrySchemaVersion = 2 - private nonisolated static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v2" + private nonisolated static let persistedWindowGeometryDefaultsKey = SessionSnapshotPersistenceWriter.persistedWindowGeometryDefaultsKey #if DEBUG nonisolated static var debugPersistedWindowGeometryDefaultsKey: String { persistedWindowGeometryDefaultsKey } #endif - private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ - "cmux.session.lastWindowGeometry.v1" - ] weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? @@ -1101,6 +1098,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent schemaVersion: SessionSnapshotSchema.currentVersion, bundleIdentifier: Bundle.main.bundleIdentifier ) + private lazy var sessionSnapshotPersistenceWriter = SessionSnapshotPersistenceWriter( + store: sessionSnapshotStore, + queue: sessionPersistenceQueue + ) /// Accessibility window-hierarchy cache (CmuxWindowing); composition-root /// owned. The `NSApplication` AX swizzle forwards to it behind /// ``AccessibilityWindowCaching``. @@ -3540,7 +3541,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private nonisolated static func removeLegacyPersistedWindowGeometry( defaults: UserDefaults = .standard ) { - legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + SessionSnapshotPersistenceWriter.removeLegacyPersistedWindowGeometry(defaults: defaults) } private func persistWindowGeometry(from window: NSWindow?) { @@ -4684,34 +4685,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent synchronously: Bool, preserveManualRestoreBackupOnMissingPrimary: Bool = false ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } - - let writeBlock = { - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { - UserDefaults.standard.set( - persistedGeometryData, - forKey: Self.persistedWindowGeometryDefaultsKey - ) - } - if let snapshot { - Self.clearCrashOnlyPrimarySnapshotRemovalMarker() - _ = self.sessionSnapshotStore.save(snapshot, fileURL: nil) - } else if removeWhenEmpty { - if preserveManualRestoreBackupOnMissingPrimary { - Self.markCrashOnlyPrimarySnapshotRemoval() - } else { - Self.clearCrashOnlyPrimarySnapshotRemovalMarker() - } - self.sessionSnapshotStore.removeSnapshot(fileURL: nil) - } - } - - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: writeBlock) - } + sessionSnapshotPersistenceWriter.persist( + snapshot, + removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: persistedGeometryData, + synchronously: synchronously, + preserveManualRestoreBackupOnMissingPrimary: preserveManualRestoreBackupOnMissingPrimary + ) } func sortedMainWindowContextsForSessionSnapshot() -> [MainWindowContext] { diff --git a/Sources/SessionSnapshotPersistenceWriter.swift b/Sources/SessionSnapshotPersistenceWriter.swift new file mode 100644 index 000000000000..64b16ec78333 --- /dev/null +++ b/Sources/SessionSnapshotPersistenceWriter.swift @@ -0,0 +1,87 @@ +import Foundation +import CmuxWorkspaces + +/// Owns the durable write side of the application session snapshot. +/// +/// Snapshot construction and restore remain in `AppDelegate`, but the file +/// write, geometry cache write, and crash-recovery marker all follow the same +/// synchronous/asynchronous rules and belong behind this small seam. +// The store is Sendable by contract and the queue is immutable; the unchecked +// conformance only accounts for Foundation's DispatchQueue annotation on the +// deployment SDK. All mutable persistence work is serialized by `queue`. +struct SessionSnapshotPersistenceWriter: @unchecked Sendable { + typealias Store = any SessionSnapshotStoring + + private let store: Store + private let queue: DispatchQueue + + init( + store: Store, + queue: DispatchQueue + ) { + self.store = store + self.queue = queue + } + + var snapshotStore: Store { store } + + func persist( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool, + preserveManualRestoreBackupOnMissingPrimary: Bool = false + ) { + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + + let writeBlock = { + Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } + if let snapshot { + Self.clearCrashOnlyPrimarySnapshotRemovalMarker() + _ = self.store.save(snapshot, fileURL: nil) + } else if removeWhenEmpty { + if preserveManualRestoreBackupOnMissingPrimary { + Self.markCrashOnlyPrimarySnapshotRemoval() + } else { + Self.clearCrashOnlyPrimarySnapshotRemovalMarker() + } + self.store.removeSnapshot(fileURL: nil) + } + } + + if synchronously { + writeBlock() + } else { + queue.async(execute: writeBlock) + } + } + + static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v2" + private static let legacyPersistedWindowGeometryDefaultsKeys = [ + "cmux.session.lastWindowGeometry.v1" + ] + private static let crashOnlyPrimarySnapshotRemovalDefaultsKey = + "cmux.session.crashOnlyPrimarySnapshotRemoval.v1" + + static func removeLegacyPersistedWindowGeometry(defaults: UserDefaults = .standard) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } + + static func markCrashOnlyPrimarySnapshotRemoval(defaults: UserDefaults = .standard) { + defaults.set(true, forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + } + + static func hasCrashOnlyPrimarySnapshotRemovalMarker(defaults: UserDefaults = .standard) -> Bool { + defaults.bool(forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + } + + static func clearCrashOnlyPrimarySnapshotRemovalMarker(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: crashOnlyPrimarySnapshotRemovalDefaultsKey) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c0e450261217..456724be470b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1061,6 +1061,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00A /* FeedPreviewWindowController.swift */; }; + FEE012340000000000000001 /* FeedSidebarUITestCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */; }; + FEE012340000000000000003 /* FeedSidebarUITestPushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE012340000000000000004 /* FeedSidebarUITestPushClient.swift */; }; + FEE012340000000000000005 /* FeedSidebarUITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE012340000000000000006 /* FeedSidebarUITestRecorder.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */; }; D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */; }; @@ -1895,6 +1898,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 806600000000000000000002 /* SessionRestorableAgentSnapshotPermissionModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806600000000000000000001 /* SessionRestorableAgentSnapshotPermissionModeTests.swift */; }; C54860090000000000000001 /* SessionRestoreIdentityExclusions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */; }; A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */; }; + D9E700000000000000000001 /* SessionSnapshotPersistenceWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */; }; D86840000000000000000007 /* SessionSplitContainerLayoutCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000008 /* SessionSplitContainerLayoutCodec.swift */; }; D86840000000000000000009 /* SessionSplitContainerSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8684000000000000000000A /* SessionSplitContainerSnapshot.swift */; }; FE00310B /* SessionTranscriptPopoverLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00300B /* SessionTranscriptPopoverLayout.swift */; }; @@ -2531,6 +2535,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E30760000000000000000002 /* TmuxWorkspacePaneOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */; }; D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */; }; 468100000000000000000003 /* TypingHotPathRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468100000000000000000004 /* TypingHotPathRegressionTests.swift */; }; + D9A1B2C3D4E5000000000001 /* UITestDiagnosticsWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A1B2C3D4E5000000000002 /* UITestDiagnosticsWriter.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; @@ -3836,6 +3841,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; FEED0000000000000000F00A /* FeedPreviewWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPreviewWindowController.swift; sourceTree = ""; }; + FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/FeedSidebarUITestCoordinator.swift"; sourceTree = ""; }; + FEE012340000000000000004 /* FeedSidebarUITestPushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/FeedSidebarUITestPushClient.swift"; sourceTree = ""; }; + FEE012340000000000000006 /* FeedSidebarUITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/FeedSidebarUITestRecorder.swift"; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; FEED0000000000000000F00E /* FeedTextEditorDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTextEditorDebugWindowController.swift; sourceTree = ""; }; D0B1001BA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropHintBadgeView.swift; sourceTree = ""; }; @@ -4660,6 +4668,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 806600000000000000000001 /* SessionRestorableAgentSnapshotPermissionModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestorableAgentSnapshotPermissionModeTests.swift; sourceTree = ""; }; C54860090000000000000002 /* SessionRestoreIdentityExclusions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRestoreIdentityExclusions.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; + D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSnapshotPersistenceWriter.swift; sourceTree = ""; }; D86840000000000000000008 /* SessionSplitContainerLayoutCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSplitContainerLayoutCodec.swift; sourceTree = ""; }; D8684000000000000000000A /* SessionSplitContainerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionSplitContainerSnapshot.swift; sourceTree = ""; }; FE00300B /* SessionTranscriptPopoverLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTranscriptPopoverLayout.swift; sourceTree = ""; }; @@ -5289,6 +5298,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxWorkspacePaneOverlayView.swift; sourceTree = ""; }; D3284002A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraditionalChineseIMENumpadRegressionTests.swift; sourceTree = ""; }; 468100000000000000000004 /* TypingHotPathRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingHotPathRegressionTests.swift; sourceTree = ""; }; + D9A1B2C3D4E5000000000002 /* UITestDiagnosticsWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/UITestDiagnosticsWriter.swift"; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; @@ -6853,6 +6863,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */, + FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */, + FEE012340000000000000004 /* FeedSidebarUITestPushClient.swift */, + FEE012340000000000000006 /* FeedSidebarUITestRecorder.swift */, + D9A1B2C3D4E5000000000002 /* UITestDiagnosticsWriter.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -9503,6 +9518,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEED0000000000000000F00B /* FeedPreviewWindowController.swift in Sources */, + FEE012340000000000000001 /* FeedSidebarUITestCoordinator.swift in Sources */, + FEE012340000000000000003 /* FeedSidebarUITestPushClient.swift in Sources */, + FEE012340000000000000005 /* FeedSidebarUITestRecorder.swift in Sources */, FEED0000000000000000F00F /* FeedTextEditorDebugWindowController.swift in Sources */, D0B1001AA1B2C3D4E5F60001 /* FileDropHintBadgeView.swift in Sources */, D0B10020A1B2C3D4E5F60001 /* FileDropOverlayView.swift in Sources */, @@ -10066,6 +10084,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 0A1107110000000000000022 /* SessionRestorableAgentSnapshot+Commands.swift in Sources */, C54860090000000000000001 /* SessionRestoreIdentityExclusions.swift in Sources */, A50016B1A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift in Sources */, + D9E700000000000000000001 /* SessionSnapshotPersistenceWriter.swift in Sources */, D86840000000000000000007 /* SessionSplitContainerLayoutCodec.swift in Sources */, D86840000000000000000009 /* SessionSplitContainerSnapshot.swift in Sources */, FE00310B /* SessionTranscriptPopoverLayout.swift in Sources */, @@ -10537,6 +10556,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B35751000000000000000002 /* TmuxResumeParser.swift in Sources */, E30760050000000000000002 /* TmuxWorkspacePaneOverlayRenderState.swift in Sources */, E30760000000000000000002 /* TmuxWorkspacePaneOverlayView.swift in Sources */, + D9A1B2C3D4E5000000000001 /* UITestDiagnosticsWriter.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A500120D /* UpdateLogStore.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, From 33125b1d337ed12cccfdd324c3d579661cea722a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 18 Sep 2026 18:21:08 -0700 Subject: [PATCH 2/2] refactor: extract session autosave coordinator --- Sources/AppDelegate.swift | 255 ++++------------------ Sources/SessionAutosaveCoordinator.swift | 263 +++++++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 308 insertions(+), 214 deletions(-) create mode 100644 Sources/SessionAutosaveCoordinator.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 33623f992cd0..274951acec7e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1084,10 +1084,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Durable navigation links that arrived before startup restore registered /// their target workspaces. var pendingStartupNavigationURLRequests: [CmuxNavigationURLRequest] = [] - private var sessionAutosaveTimer: DispatchSourceTimer? - private var sessionAutosaveTickInFlight = false - private var sessionAutosaveDeferredRetryPending = false - private var processDetectedSessionSaveGeneration: UInt64 = 0 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -1102,6 +1098,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent store: sessionSnapshotStore, queue: sessionPersistenceQueue ) + private lazy var sessionAutosaveCoordinator = SessionAutosaveCoordinator( + isTerminatingApp: { [weak self] in self?.isTerminatingApp ?? true }, + isStartupSessionRestorePending: { [weak self] in + guard let self else { return true } + return !self.didAttemptStartupSessionRestore + }, + fingerprint: { [weak self] restorableAgentIndex, surfaceResumeBindingIndex in + self?.sessionAutosaveFingerprint( + includeScrollback: false, + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex + ) + }, + save: { [weak self] restorableAgentIndex, surfaceResumeBindingIndex in + guard let self else { return false } + return self.saveSessionSnapshot( + includeScrollback: false, + restorableAgentIndex: restorableAgentIndex, + surfaceResumeBindingIndex: surfaceResumeBindingIndex + ) + } + ) /// Accessibility window-hierarchy cache (CmuxWindowing); composition-root /// owned. The `NSApplication` AX swizzle forwards to it behind /// ``AccessibilityWindowCaching``. @@ -1120,9 +1138,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { launchServicesRegistrationQueue.async(execute: work) } - private var lastSessionAutosaveFingerprint: Int? - private var lastSessionAutosavePersistedAt: Date = .distantPast - private var lastTypingActivityAt: TimeInterval = 0 var didHandleExplicitOpenIntentAtStartup = false private var didScheduleInitialMainWindowBootstrap = false var shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false @@ -1172,7 +1187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didDisableSuddenTermination = false /// Owns the per-window command-palette state. let commandPaletteWindowStore = CommandPaletteWindowStore() - private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 private let mainThreadHangWatchdog: MainThreadHangWatchdog var updateViewModel: UpdateStateModel { @@ -2264,7 +2278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent PresenceHeartbeatClient.shared.appWillTerminate() connectivityInvalidationSubscriberCoordinator.appWillTerminate() closeAllWebInspectorsBeforeAppTeardown() - stopSessionAutosaveTimer() + sessionAutosaveCoordinator.stop() CloudVMActionLauncher.shared.terminateAll() CmuxSSHURLProcessLauncher.shared.terminateAll() MobileHostService.shared.stop() @@ -2352,7 +2366,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent lastVisibleFrameFitTopologySignature = MainWindowVisibleFrameFitCore() .trustedTopologySignature(of: currentDisplayGeometries().available) prepareStartupSessionSnapshotIfNeeded() - startSessionAutosaveTimerIfNeeded() + sessionAutosaveCoordinator.startIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupTerminalCmdClickUITestIfNeeded() @@ -4074,35 +4088,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance } - private func startSessionAutosaveTimerIfNeeded() { - guard sessionAutosaveTimer == nil else { return } - let env = ProcessInfo.processInfo.environment - guard !isRunningUnderXCTest(env) else { return } - - let timer = DispatchSource.makeTimerSource(queue: .main) - let interval = SessionPersistencePolicy.autosaveInterval - timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) - timer.setEventHandler { [weak self] in - guard let self, - Self.shouldRunSessionAutosaveTick( - isTerminatingApp: self.isTerminatingApp, - isStartupSessionRestorePending: !self.didAttemptStartupSessionRestore - ) else { - return - } - self.runSessionAutosaveTick(source: "timer") - } - sessionAutosaveTimer = timer - timer.resume() - } - - private func stopSessionAutosaveTimer() { - sessionAutosaveTimer?.cancel() - sessionAutosaveTimer = nil - sessionAutosaveTickInFlight = false - sessionAutosaveDeferredRetryPending = false - } - private func installLifecycleSnapshotObserversIfNeeded() { guard !didInstallLifecycleSnapshotObservers else { return } didInstallLifecycleSnapshotObservers = true @@ -4408,7 +4393,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp: Bool, isStartupSessionRestorePending: Bool ) -> Bool { - !isTerminatingApp && !isStartupSessionRestorePending + SessionAutosaveCoordinator.shouldRunSessionAutosaveTick( + isTerminatingApp: isTerminatingApp, + isStartupSessionRestorePending: isStartupSessionRestorePending + ) } nonisolated static func shouldSaveSessionSnapshotOnApplicationResign(isTerminatingApp _: Bool) -> Bool { @@ -4417,147 +4405,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent false } - private func remainingSessionAutosaveTypingQuietPeriod( - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> TimeInterval? { - guard lastTypingActivityAt > 0 else { return nil } - let elapsed = nowUptime - lastTypingActivityAt - guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } - return Self.sessionAutosaveTypingQuietPeriod - elapsed - } - - private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { - guard delay.isFinite, delay > 0 else { return } - guard !sessionAutosaveDeferredRetryPending else { return } - sessionAutosaveDeferredRetryPending = true - sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.sessionAutosaveDeferredRetryPending = false - self.runSessionAutosaveTick(source: "typingQuietRetry") - } - } - } - - private func runSessionAutosaveTick(source: String) { - guard Self.shouldRunSessionAutosaveTick( - isTerminatingApp: isTerminatingApp, - isStartupSessionRestorePending: !didAttemptStartupSessionRestore - ) else { - return - } - guard !sessionAutosaveTickInFlight else { return } - if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { -#if DEBUG - cmuxDebugLog( - "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + - "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" - ) -#endif - scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) - return - } - - sessionAutosaveTickInFlight = true - let generation = nextProcessDetectedSessionSaveGeneration() - Task { @MainActor in await self.finishSessionAutosaveTick(source: source, generation: generation) } - } - - private func finishSessionAutosaveTick(source: String, generation: UInt64) async { -#if DEBUG - let timingStart = CmuxTypingTiming.start() - let phaseStart = ProcessInfo.processInfo.systemUptime - var loadMs: Double = 0 - var fingerprintMs: Double = 0 - var saveMs: Double = 0 - defer { - sessionAutosaveTickInFlight = false - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 - CmuxTypingTiming.logBreakdown( - path: "session.autosaveTick.phase", - totalMs: totalMs, - thresholdMs: 2.0, - parts: [ - // loadMs is await wall time on a detached utility task, not - // main-thread blocking; fingerprintMs and saveMs are the - // synchronous main-thread portions. - ("loadMs", loadMs), - ("fingerprintMs", fingerprintMs), - ("saveMs", saveMs), - ], - extra: "source=\(source)" - ) - CmuxTypingTiming.logDuration( - path: "session.autosaveTick", - startedAt: timingStart, - extra: "source=\(source)" - ) - } -#else - defer { sessionAutosaveTickInFlight = false } -#endif - - let now = Date() -#if DEBUG - let loadStart = ProcessInfo.processInfo.systemUptime -#endif - let resumeIndexes = await ProcessDetectedResumeIndexes.load() -#if DEBUG - loadMs = (ProcessInfo.processInfo.systemUptime - loadStart) * 1000.0 - let fingerprintStart = ProcessInfo.processInfo.systemUptime -#endif - guard !isTerminatingApp, - isCurrentProcessDetectedSessionSaveGeneration(generation) else { -#if DEBUG - cmuxDebugLog( - "session.save.skipped reason=stale_process_detected_scan includeScrollback=0 source=\(source)" - ) -#endif - return - } - let autosaveFingerprint = sessionAutosaveFingerprint( - includeScrollback: false, - restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex - ) -#if DEBUG - fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 -#endif - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: isTerminatingApp, - includeScrollback: false, - previousFingerprint: lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: lastSessionAutosavePersistedAt, - now: now - ) { -#if DEBUG - cmuxDebugLog( - "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" - ) -#endif - return - } - -#if DEBUG - let saveStart = ProcessInfo.processInfo.systemUptime -#endif - let didSave = saveSessionSnapshot( - includeScrollback: false, - restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex - ) -#if DEBUG - saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 -#endif - guard didSave else { return } - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) - } - @discardableResult private func saveSessionSnapshotUsingCachedProcessDetectedIndexes( includeScrollback: Bool, @@ -4600,12 +4447,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent removeWhenEmpty: Bool = false, preserveManualRestoreBackupOnMissingPrimary: Bool = false ) { - let generation = nextProcessDetectedSessionSaveGeneration() + let generation = sessionAutosaveCoordinator.nextProcessDetectedSaveGeneration() Task { @MainActor [weak self] in let resumeIndexes = await ProcessDetectedResumeIndexes.load() guard let self, !self.isTerminatingApp, - self.isCurrentProcessDetectedSessionSaveGeneration(generation) else { return } + self.sessionAutosaveCoordinator.isCurrentProcessDetectedSaveGeneration(generation) else { return } _ = self.saveSessionSnapshot( includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, @@ -4616,18 +4463,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - @discardableResult - private func nextProcessDetectedSessionSaveGeneration() -> UInt64 { - processDetectedSessionSaveGeneration &+= 1 - return processDetectedSessionSaveGeneration - } - - private func isCurrentProcessDetectedSessionSaveGeneration(_ generation: UInt64) -> Bool { - generation == processDetectedSessionSaveGeneration - } - fileprivate func recordTypingActivity() { - lastTypingActivityAt = ProcessInfo.processInfo.systemUptime + sessionAutosaveCoordinator.recordTypingActivity() } nonisolated static func shouldWriteSessionSnapshotSynchronously( @@ -4646,25 +4483,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent now: Date, maximumAutosaveSkippableInterval: TimeInterval = 60 ) -> Bool { - guard !isTerminatingApp, - !includeScrollback, - let previousFingerprint, - let currentFingerprint, - previousFingerprint == currentFingerprint else { - return false - } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval - } - - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt + SessionAutosaveCoordinator.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp, + includeScrollback: includeScrollback, + previousFingerprint: previousFingerprint, + currentFingerprint: currentFingerprint, + lastPersistedAt: lastPersistedAt, + now: now, + maximumAutosaveSkippableInterval: maximumAutosaveSkippableInterval + ) } private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { @@ -17883,7 +17710,7 @@ private extension NSWindow { CmuxTypingTiming.logEventDelay(path: "window.sendEvent", event: event) } #endif - // recordTypingActivity must run in all builds so runSessionAutosaveTick + // recordTypingActivity runs in all builds so the autosave coordinator // can honor the typing quiet period in release. if event.type == .keyDown, let app = AppDelegate.shared, cmuxCloseFocusedTerminalFindForEscape(event: event, appDelegate: app) { return } if event.type == .keyDown { AppDelegate.shared?.recordTypingActivity() } diff --git a/Sources/SessionAutosaveCoordinator.swift b/Sources/SessionAutosaveCoordinator.swift new file mode 100644 index 000000000000..afc7106d73b5 --- /dev/null +++ b/Sources/SessionAutosaveCoordinator.swift @@ -0,0 +1,263 @@ +import Foundation + +/// Coordinates periodic and typing-debounced session autosave work. +/// +/// The application supplies snapshot fingerprinting and persistence closures so +/// snapshot construction remains owned by ``AppDelegate`` while timer, retry, +/// generation, and skip-state transitions live in one focused object. +@MainActor +final class SessionAutosaveCoordinator { + private typealias Fingerprint = @MainActor ( + RestorableAgentSessionIndex, + SurfaceResumeBindingIndex + ) -> Int? + private typealias Save = @MainActor ( + RestorableAgentSessionIndex, + SurfaceResumeBindingIndex + ) -> Bool + + private let isTerminatingApp: @MainActor () -> Bool + private let isStartupSessionRestorePending: @MainActor () -> Bool + private let fingerprint: Fingerprint + private let save: Save + + private static let typingQuietPeriod: TimeInterval = 0.65 + + private var timer: DispatchSourceTimer? + private var deferredRetryTask: Task? + private var tickInFlight = false + private var processDetectedSaveGeneration: UInt64 = 0 + private var lastFingerprint: Int? + private var lastPersistedAt = Date.distantPast + private var lastTypingActivityAt: TimeInterval = 0 + + init( + isTerminatingApp: @escaping @MainActor () -> Bool, + isStartupSessionRestorePending: @escaping @MainActor () -> Bool, + fingerprint: @escaping Fingerprint, + save: @escaping Save + ) { + self.isTerminatingApp = isTerminatingApp + self.isStartupSessionRestorePending = isStartupSessionRestorePending + self.fingerprint = fingerprint + self.save = save + } + + nonisolated static func shouldRunSessionAutosaveTick( + isTerminatingApp: Bool, + isStartupSessionRestorePending: Bool + ) -> Bool { + !isTerminatingApp && !isStartupSessionRestorePending + } + + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: Bool, + includeScrollback: Bool, + previousFingerprint: Int?, + currentFingerprint: Int?, + lastPersistedAt: Date, + now: Date, + maximumAutosaveSkippableInterval: TimeInterval = 60 + ) -> Bool { + guard !isTerminatingApp, + !includeScrollback, + let previousFingerprint, + let currentFingerprint, + previousFingerprint == currentFingerprint else { + return false + } + + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval + } + + func startIfNeeded() { + guard timer == nil else { return } + guard !MacSentryStartupPolicy.isRunningUnderXCTest(environment: ProcessInfo.processInfo.environment) else { return } + + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self, + Self.shouldRunSessionAutosaveTick( + isTerminatingApp: self.isTerminatingApp(), + isStartupSessionRestorePending: self.isStartupSessionRestorePending() + ) else { + return + } + self.run(source: "timer") + } + self.timer = timer + timer.resume() + } + + func stop() { + timer?.cancel() + timer = nil + deferredRetryTask?.cancel() + deferredRetryTask = nil + tickInFlight = false + } + + func run(source: String) { + guard Self.shouldRunSessionAutosaveTick( + isTerminatingApp: isTerminatingApp(), + isStartupSessionRestorePending: isStartupSessionRestorePending() + ) else { + return + } + guard !tickInFlight else { return } + if let remainingQuietPeriod = remainingTypingQuietPeriod() { +#if DEBUG + cmuxDebugLog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) +#endif + scheduleDeferredRetry(after: remainingQuietPeriod) + return + } + + tickInFlight = true + let generation = nextProcessDetectedSaveGeneration() + Task { @MainActor [weak self] in + await self?.finish(source: source, generation: generation) + } + } + + func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime + } + + func nextProcessDetectedSaveGeneration() -> UInt64 { + processDetectedSaveGeneration &+= 1 + return processDetectedSaveGeneration + } + + func isCurrentProcessDetectedSaveGeneration(_ generation: UInt64) -> Bool { + generation == processDetectedSaveGeneration + } + + func updateSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? + ) { + guard !isTerminatingApp(), !includeScrollback else { return } + lastFingerprint = fingerprint + lastPersistedAt = persistedAt + } + + private func finish(source: String, generation: UInt64) async { +#if DEBUG + let timingStart = CmuxTypingTiming.start() + let phaseStart = ProcessInfo.processInfo.systemUptime + var loadMs: Double = 0 + var fingerprintMs: Double = 0 + var saveMs: Double = 0 + defer { + tickInFlight = false + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("loadMs", loadMs), + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + CmuxTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) + } +#else + defer { tickInFlight = false } +#endif + + let now = Date() +#if DEBUG + let loadStart = ProcessInfo.processInfo.systemUptime +#endif + let resumeIndexes = await ProcessDetectedResumeIndexes.load() +#if DEBUG + loadMs = (ProcessInfo.processInfo.systemUptime - loadStart) * 1000.0 + let fingerprintStart = ProcessInfo.processInfo.systemUptime +#endif + guard !isTerminatingApp(), isCurrentProcessDetectedSaveGeneration(generation) else { +#if DEBUG + cmuxDebugLog( + "session.save.skipped reason=stale_process_detected_scan includeScrollback=0 source=\(source)" + ) +#endif + return + } + let autosaveFingerprint = fingerprint( + resumeIndexes.restorableAgentIndex, + resumeIndexes.surfaceResumeBindingIndex + ) +#if DEBUG + fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 +#endif + if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp(), + includeScrollback: false, + previousFingerprint: lastFingerprint, + currentFingerprint: autosaveFingerprint, + lastPersistedAt: lastPersistedAt, + now: now + ) { +#if DEBUG + cmuxDebugLog( + "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + ) +#endif + return + } + +#if DEBUG + let saveStart = ProcessInfo.processInfo.systemUptime +#endif + let didSave = save( + resumeIndexes.restorableAgentIndex, + resumeIndexes.surfaceResumeBindingIndex + ) +#if DEBUG + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 +#endif + guard didSave else { return } + updateSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint + ) + } + + private func remainingTypingQuietPeriod( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> TimeInterval? { + guard lastTypingActivityAt > 0 else { return nil } + let elapsed = nowUptime - lastTypingActivityAt + guard elapsed < Self.typingQuietPeriod else { return nil } + return Self.typingQuietPeriod - elapsed + } + + private func scheduleDeferredRetry(after delay: TimeInterval) { + guard delay.isFinite, delay > 0 else { return } + guard deferredRetryTask == nil else { return } + deferredRetryTask = Task { @MainActor [weak self] in + do { + try await ContinuousClock().sleep(for: .seconds(delay)) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + self.deferredRetryTask = nil + self.run(source: "typingQuietRetry") + } + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 456724be470b..38c7e77b2ef2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1840,6 +1840,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; FE003106 /* SessionAgentPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003006 /* SessionAgentPresentation.swift */; }; A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */; }; + E7E300000000000000000001 /* SessionAutosaveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E300000000000000000002 /* SessionAutosaveCoordinator.swift */; }; A74770010000000000000005 /* SessionConfigFrameEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74770010000000000000006 /* SessionConfigFrameEntry.swift */; }; A74770010000000000000007 /* SessionConfigFrameRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74770010000000000000008 /* SessionConfigFrameRing.swift */; }; C71500020000000000000001 /* SessionContentWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71500010000000000000001 /* SessionContentWidthModifier.swift */; }; @@ -4610,6 +4611,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; FE003006 /* SessionAgentPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentPresentation.swift; sourceTree = ""; }; A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentSessionPanelSnapshot.swift; sourceTree = ""; }; + E7E300000000000000000002 /* SessionAutosaveCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinator.swift; sourceTree = ""; }; A74770010000000000000006 /* SessionConfigFrameEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionConfigFrameEntry.swift; sourceTree = ""; }; A74770010000000000000008 /* SessionConfigFrameRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionConfigFrameRing.swift; sourceTree = ""; }; C71500010000000000000001 /* SessionContentWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/SessionContentWidthModifier.swift; sourceTree = ""; }; @@ -6864,6 +6866,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */, + E7E300000000000000000002 /* SessionAutosaveCoordinator.swift */, FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */, FEE012340000000000000004 /* FeedSidebarUITestPushClient.swift */, FEE012340000000000000006 /* FeedSidebarUITestRecorder.swift */, @@ -10037,6 +10040,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001601 /* SentryHelper.swift in Sources */, FE003106 /* SessionAgentPresentation.swift in Sources */, A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */, + E7E300000000000000000001 /* SessionAutosaveCoordinator.swift in Sources */, A74770010000000000000005 /* SessionConfigFrameEntry.swift in Sources */, A74770010000000000000007 /* SessionConfigFrameRing.swift in Sources */, C71500020000000000000001 /* SessionContentWidthModifier.swift in Sources */,