diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f991a7c0f0df..df19e1e9862b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -672,10 +672,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 @@ -690,6 +686,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``. @@ -708,9 +726,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 @@ -760,7 +775,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 { @@ -1741,7 +1755,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() @@ -1829,7 +1843,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent lastVisibleFrameFitTopologySignature = MainWindowVisibleFrameFitCore() .trustedTopologySignature(of: currentDisplayGeometries().available) prepareStartupSessionSnapshotIfNeeded() - startSessionAutosaveTimerIfNeeded() + sessionAutosaveCoordinator.startIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() terminalCmdClickUITestCoordinator.startIfNeeded() @@ -2580,35 +2594,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 @@ -2914,7 +2899,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 { @@ -2923,147 +2911,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, @@ -3106,12 +2953,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, @@ -3122,18 +2969,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( @@ -3152,25 +2989,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) { @@ -16389,7 +16216,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 91e40c96f603..9c6e1b8236ee 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1844,6 +1844,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 */; }; @@ -4621,6 +4622,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 = ""; }; @@ -6882,6 +6884,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E7D100000000000000000004 /* CmuxMainRunLoopStallMonitor.swift */, E7D100000000000000000006 /* CmuxMainThreadTurnProfiler.swift */, D9E700000000000000000002 /* SessionSnapshotPersistenceWriter.swift */, + E7E300000000000000000002 /* SessionAutosaveCoordinator.swift */, FEE012340000000000000002 /* FeedSidebarUITestCoordinator.swift */, E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */, E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */, @@ -10062,6 +10065,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 */,