From 1e8cb847815d6f79063045c0b9c3bb878584de4e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 28 Jul 2026 22:41:48 -0700 Subject: [PATCH 01/76] =?UTF-8?q?Vault:=20add=20History=20section=20?= =?UTF-8?q?=E2=80=94=20unified=20timeline=20with=20group-by-anything?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a History tab to the Vault sidebar pane: a unified, browsable timeline of workspace/window lifecycle events interleaved with the agent sessions Vault already indexes. Architecture: one general event model with pluggable sources, grouping as a pure function. - VaultHistoryEvent: flat, locale-independent Codable value (timestamp, kind, subject identity, payload fields). - VaultHistoryEventStore (actor): append-only JSONL under Application Support, bounded by construction — tail-bounded loads (maxLoadBytes), compacting rewrites past maxFileBytes, memory capped at maxStoredEvents. History survives restarts without unbounded scans. - VaultHistoryEventLog: main-actor recording facade; lifecycle hooks in TabManager/AppDelegate are one-liners (workspace create/rename/close, window open/close), suppressed during session restore and termination. - Sessions are derived, not double-written: VaultHistorySessionEvents projects SessionEntry values into the same event model at read time. - VaultHistoryGrouper: pure grouping over events — date buckets (Last 24 hours first, then Yesterday / This week / This month / Older), plus workspace, window, agent, and event-type keys that fall out of the same pass. - UI: VaultPaneView adds a Sessions/History tab bar at both Vault mounts (right sidebar + pop-out pane); VaultHistoryView renders grouped sections with value-only rows below the lazy boundary. Tests cover persistence round-trip, retention/compaction, bounded loads, corrupt-line tolerance, date bucketing (including the 24-hour boundary), group-by-workspace/window/agent/kind, ordering, and session projection. All strings localized (en + ja). Closes #9127 Co-Authored-By: Claude Fable 5 --- Resources/Localizable.xcstrings | 510 ++++++++++++++++++ Sources/AppDelegate.swift | 2 + Sources/RightSidebarPanelView.swift | 2 +- Sources/RightSidebarToolPanel.swift | 2 +- Sources/TabManager+WorkspaceCustomTitle.swift | 7 + Sources/TabManager.swift | 4 + Sources/VaultHistoryEvent.swift | 113 ++++ Sources/VaultHistoryEventLog.swift | 71 +++ Sources/VaultHistoryEventStore.swift | 146 +++++ Sources/VaultHistoryGrouping.swift | 214 ++++++++ Sources/VaultHistoryLifecycleRecording.swift | 86 +++ Sources/VaultHistoryRetentionPolicy.swift | 27 + Sources/VaultHistorySessionEvents.swift | 26 + Sources/VaultHistoryTimelineModel.swift | 79 +++ Sources/VaultHistoryView.swift | 277 ++++++++++ Sources/VaultPaneView.swift | 95 ++++ cmux.xcodeproj/project.pbxproj | 48 ++ cmuxTests/VaultHistoryEventStoreTests.swift | 160 ++++++ cmuxTests/VaultHistoryGroupingTests.swift | 198 +++++++ 19 files changed, 2065 insertions(+), 2 deletions(-) create mode 100644 Sources/VaultHistoryEvent.swift create mode 100644 Sources/VaultHistoryEventLog.swift create mode 100644 Sources/VaultHistoryEventStore.swift create mode 100644 Sources/VaultHistoryGrouping.swift create mode 100644 Sources/VaultHistoryLifecycleRecording.swift create mode 100644 Sources/VaultHistoryRetentionPolicy.swift create mode 100644 Sources/VaultHistorySessionEvents.swift create mode 100644 Sources/VaultHistoryTimelineModel.swift create mode 100644 Sources/VaultHistoryView.swift create mode 100644 Sources/VaultPaneView.swift create mode 100644 cmuxTests/VaultHistoryEventStoreTests.swift create mode 100644 cmuxTests/VaultHistoryGroupingTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 40590e122216..43ea2ac6a124 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -258643,6 +258643,516 @@ } } } + }, + "vaultHistory.kind.workspaceCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace created" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを作成" + } + } + } + }, + "vaultHistory.kind.workspaceRenamed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace renamed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名を変更" + } + } + } + }, + "vaultHistory.kind.workspaceClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じました" + } + } + } + }, + "vaultHistory.kind.windowOpened": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window opened" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを開きました" + } + } + } + }, + "vaultHistory.kind.windowClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを閉じました" + } + } + } + }, + "vaultHistory.kind.sessionActivity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントセッション" + } + } + } + }, + "vaultHistory.group.date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" + } + } + } + }, + "vaultHistory.group.workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + } + } + }, + "vaultHistory.group.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + } + } + }, + "vaultHistory.group.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + } + } + }, + "vaultHistory.group.kind": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "種類" + } + } + } + }, + "vaultHistory.group.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + } + } + }, + "vaultHistory.group.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + } + } + }, + "vaultHistory.bucket.last24Hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 24 hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去24時間" + } + } + } + }, + "vaultHistory.bucket.yesterday": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + } + } + }, + "vaultHistory.bucket.thisWeek": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This week" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週" + } + } + } + }, + "vaultHistory.bucket.thisMonth": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月" + } + } + } + }, + "vaultHistory.bucket.older": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Older" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "それ以前" + } + } + } + }, + "vaultHistory.untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題" + } + } + } + }, + "vaultHistory.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + } + } + }, + "vaultHistory.groupPicker.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Group history by" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のグループ化" + } + } + } + }, + "vaultHistory.reload.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を再読み込み" + } + } + } + }, + "vaultHistory.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading history…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を読み込み中…" + } + } + } + }, + "vaultHistory.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No history yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴はまだありません" + } + } + } + }, + "vaultHistory.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace, window, and agent session activity will appear here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース、ウインドウ、エージェントセッションのアクティビティがここに表示されます。" + } + } + } + }, + "vaultHistory.detail.renamedFrom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "was “%@”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "旧名 “%@”" + } + } + } + }, + "vaultHistory.workspaceCount.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 個のワークスペース" + } + } + } + }, + "vaultHistory.workspaceCount.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のワークスペース" + } + } + } + }, + "vaultPane.tab.sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + } + } + }, + "vaultPane.tab.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index be9ca327ca77..349f4c7d8bf3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4711,6 +4711,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) mainWindowContexts[key] = context context.closeObserver = WindowCloseObserver(window: window) { [weak self] in self?.unregisterMainWindow($0) } + recordVaultHistoryWindowOpened(windowId: windowId) } commandPaletteWindowStore.registerWindow(windowId) @@ -16523,6 +16524,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent snapshot: snapshot, workspaceIds: snapshot.tabManager.workspaces.compactMap(\.workspaceId) ))) + recordVaultHistoryWindowClosed(windowId: context.windowId, snapshot: snapshot) } #if DEBUG diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index aa6dd66c2f90..d8a377f4b219 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -393,7 +393,7 @@ struct RightSidebarPanelView: View { presentation: .find ) case .sessions: - SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) + VaultPaneView(store: sessionIndexStore, onResume: onResumeSession) .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) } diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index f923507d70a5..640773826bd3 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -284,7 +284,7 @@ struct RightSidebarToolPanelView: View { onContainerChange: panel.attachFileExplorerContainer ) case .sessions: - SessionIndexView( + VaultPaneView( store: panel.sessionIndexStore, onResume: { entry in SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) diff --git a/Sources/TabManager+WorkspaceCustomTitle.swift b/Sources/TabManager+WorkspaceCustomTitle.swift index a1afe9e9a7ac..9526506bdc35 100644 --- a/Sources/TabManager+WorkspaceCustomTitle.swift +++ b/Sources/TabManager+WorkspaceCustomTitle.swift @@ -46,6 +46,13 @@ extension TabManager { object: self, userInfo: [GhosttyNotificationKey.tabId: tabId] ) + if source == .user { + recordVaultHistoryWorkspaceRenamed( + tabs[index], + previousTitle: previousDisplayTitle, + currentTitle: currentDisplayTitle + ) + } } // A remote tmux mirror workspace rename propagates to `rename-session`, // but only when the write landed (an `.auto` write rejected over a diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 20869c300d27..f4d1b25fbc23 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1240,6 +1240,7 @@ class TabManager: ObservableObject { } publishCmuxWorkspaceCreated(newWorkspace, selected: select) publishCmuxInitialSurfaceCreated(newWorkspace, selected: select) + recordVaultHistoryWorkspaceCreated(newWorkspace) if select { #if DEBUG debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) @@ -2119,6 +2120,9 @@ class TabManager: ObservableObject { snapshot: snapshot ))) } + if recordHistory { + recordVaultHistoryWorkspaceClosed(workspace) + } sidebarGitMetadataService.clearWorkspaceGitProbes(workspaceId: workspace.id) pullRequestProbing.clearWorkspacePullRequestTracking(workspaceId: workspace.id) sidebarMultiSelection.removeFromSelection(workspace.id) diff --git a/Sources/VaultHistoryEvent.swift b/Sources/VaultHistoryEvent.swift new file mode 100644 index 000000000000..b40961c75abb --- /dev/null +++ b/Sources/VaultHistoryEvent.swift @@ -0,0 +1,113 @@ +import Foundation + +/// What happened in a single history timeline entry. +/// +/// Persisted kinds (workspace/window lifecycle) are recorded by +/// ``VaultHistoryEventLog`` as they happen; `sessionActivity` events are +/// derived at read time from the Vault session index and never written to +/// the history store. +enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { + case workspaceCreated + case workspaceRenamed + case workspaceClosed + case windowOpened + case windowClosed + case sessionActivity + + /// Localized label describing the event kind, used for rows and + /// group-by-type section headers. + var label: String { + switch self { + case .workspaceCreated: + return String(localized: "vaultHistory.kind.workspaceCreated", defaultValue: "Workspace created") + case .workspaceRenamed: + return String(localized: "vaultHistory.kind.workspaceRenamed", defaultValue: "Workspace renamed") + case .workspaceClosed: + return String(localized: "vaultHistory.kind.workspaceClosed", defaultValue: "Workspace closed") + case .windowOpened: + return String(localized: "vaultHistory.kind.windowOpened", defaultValue: "Window opened") + case .windowClosed: + return String(localized: "vaultHistory.kind.windowClosed", defaultValue: "Window closed") + case .sessionActivity: + return String(localized: "vaultHistory.kind.sessionActivity", defaultValue: "Agent session") + } + } + + var symbolName: String { + switch self { + case .workspaceCreated: return "plus.square" + case .workspaceRenamed: return "pencil" + case .workspaceClosed: return "xmark.square" + case .windowOpened: return "macwindow.badge.plus" + case .windowClosed: return "macwindow" + case .sessionActivity: return "sparkles" + } + } +} + +/// Identity of the thing an event is about. All fields are optional; each +/// event kind fills the ones that apply. Group-by-workspace/window/agent +/// read these fields, so a missing field lands the event in the shared +/// "Other" bucket for that grouping. +struct VaultHistorySubject: Hashable, Codable, Sendable { + var workspaceId: UUID? + var windowId: UUID? + /// Native agent session identifier for `sessionActivity` events. + var sessionId: String? + /// `SessionAgent` raw value for `sessionActivity` events. + var agent: String? + /// Working directory associated with the subject, when known. + var directory: String? + + init( + workspaceId: UUID? = nil, + windowId: UUID? = nil, + sessionId: String? = nil, + agent: String? = nil, + directory: String? = nil + ) { + self.workspaceId = workspaceId + self.windowId = windowId + self.sessionId = sessionId + self.agent = agent + self.directory = directory + } +} + +/// One entry in the unified Vault history timeline. +/// +/// The model is deliberately flat and locale-independent: display strings +/// (kind labels, "N workspaces" details) are derived in the UI layer so a +/// persisted event renders correctly after a language change. +struct VaultHistoryEvent: Identifiable, Hashable, Codable, Sendable { + /// Stable identity. Recorded events use a fresh UUID string; derived + /// session events reuse the session entry id so re-derivation is stable. + let id: String + let timestamp: Date + let kind: VaultHistoryEventKind + /// Display title of the subject at event time (workspace or session title). + let title: String + /// Previous title, set for `workspaceRenamed`. + let previousTitle: String? + /// Number of workspaces involved, set for window events. + let workspaceCount: Int? + let subject: VaultHistorySubject + + init( + id: String = UUID().uuidString, + timestamp: Date, + kind: VaultHistoryEventKind, + title: String, + previousTitle: String? = nil, + workspaceCount: Int? = nil, + subject: VaultHistorySubject = VaultHistorySubject() + ) { + self.id = id + self.timestamp = timestamp + self.kind = kind + self.title = title + self.previousTitle = previousTitle + self.workspaceCount = workspaceCount + self.subject = subject + } +} diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift new file mode 100644 index 000000000000..3a6178f8f05e --- /dev/null +++ b/Sources/VaultHistoryEventLog.swift @@ -0,0 +1,71 @@ +import Foundation +import Observation + +/// Main-actor recording facade for the Vault history timeline. +/// +/// Lifecycle hooks (workspace create/rename/close, window open/close) call +/// ``record(_:)``, which stamps nothing itself — callers build the full +/// ``VaultHistoryEvent`` — and forwards to the persistence actor off the +/// caller's critical path. `revision` bumps on every recorded event so the +/// History view knows to re-query without observing the store directly. +@MainActor +@Observable +final class VaultHistoryEventLog { + static let shared = VaultHistoryEventLog( + store: VaultHistoryEventStore(fileURL: defaultFileURL()) + ) + + /// Monotonic count of recorded events this launch; observed by the + /// History view to refresh after new activity. + private(set) var revision: UInt64 = 0 + + private let store: VaultHistoryEventStore + + init(store: VaultHistoryEventStore) { + self.store = store + } + + /// Whether lifecycle hooks should skip recording right now (bulk + /// programmatic churn like session restore, or app termination). + static var isRecordingSuppressed: Bool { + guard let appDelegate = AppDelegate.shared else { return false } + return appDelegate.isApplyingSessionRestore || appDelegate.isTerminatingApp + } + + func record(_ event: VaultHistoryEvent) { + revision &+= 1 + let store = store + Task.detached(priority: .utility) { + await store.append(event) + } + } + + /// Persisted events, newest first, bounded by the store's retention policy. + func recentEvents(limit: Int = Int.max) async -> [VaultHistoryEvent] { + await store.recentEvents(limit: limit) + } + + nonisolated private static func defaultFileURL( + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isRunningUnderAutomatedTests: Bool = SessionRestorePolicy.isRunningUnderAutomatedTests() + ) -> URL? { + guard !isRunningUnderAutomatedTests else { return nil } + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return nil + } + let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? bundleIdentifier! + : "com.cmuxterm.app" + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return appSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("vault-history-\(safeBundleId).jsonl", isDirectory: false) + } +} diff --git a/Sources/VaultHistoryEventStore.swift b/Sources/VaultHistoryEventStore.swift new file mode 100644 index 000000000000..d79c8cd04707 --- /dev/null +++ b/Sources/VaultHistoryEventStore.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Append-only persistence for recorded ``VaultHistoryEvent`` values. +/// +/// Events are stored one JSON object per line in an append-only file so a +/// crash can lose at most the final partial line. Loading reads a bounded +/// tail (never the whole file), and appends trigger a compacting rewrite +/// once the file passes the retention policy's size threshold, so neither +/// the file nor memory can grow unbounded. +/// +/// With a `nil` file URL (automated tests, sandboxed fallback) the store is +/// memory-only but keeps the same bounded behavior. +actor VaultHistoryEventStore { + private let fileURL: URL? + private let retention: VaultHistoryRetentionPolicy + private var didLoad = false + /// Ascending by append order; bounded by `retention.maxStoredEvents`. + private var events: [VaultHistoryEvent] = [] + private var fileBytes = 0 + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return decoder + } + + init(fileURL: URL?, retention: VaultHistoryRetentionPolicy = .default) { + self.fileURL = fileURL + self.retention = retention + } + + /// Appends one event, persisting it and compacting the file if it has + /// outgrown the retention policy. + func append(_ event: VaultHistoryEvent) { + loadIfNeeded() + events.append(event) + trimMemoryIfNeeded() + guard let fileURL else { return } + guard let line = encodedLine(for: event) else { return } + if appendLine(line, to: fileURL) { + fileBytes += line.count + } + if fileBytes > retention.maxFileBytes { + compact(to: fileURL) + } + } + + /// Most recent events, newest first, capped at `limit`. + func recentEvents(limit: Int = Int.max) -> [VaultHistoryEvent] { + loadIfNeeded() + let sorted = events.sorted { $0.timestamp > $1.timestamp } + guard limit < sorted.count else { return sorted } + return Array(sorted.prefix(limit)) + } + + // MARK: - Load + + private func loadIfNeeded() { + guard !didLoad else { return } + didLoad = true + guard let fileURL else { return } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return } + defer { try? handle.close() } + + let fileSize = (try? handle.seekToEnd()).map(Int.init) ?? 0 + fileBytes = fileSize + guard fileSize > 0 else { return } + + let readBytes = min(fileSize, retention.maxLoadBytes) + let startOffset = UInt64(fileSize - readBytes) + try? handle.seek(toOffset: startOffset) + guard var data = try? handle.read(upToCount: readBytes) else { return } + + // A mid-file start lands inside a record: drop up to the first newline. + if startOffset > 0, let firstNewline = data.firstIndex(of: 0x0a) { + data = data[data.index(after: firstNewline)...] + } + + let decoder = Self.makeDecoder() + var loaded: [VaultHistoryEvent] = [] + var lineStart = data.startIndex + while lineStart < data.endIndex { + let lineEnd = data[lineStart...].firstIndex(of: 0x0a) ?? data.endIndex + let line = data[lineStart.. retention.maxStoredEvents { + loaded.removeFirst(loaded.count - retention.maxStoredEvents) + } + events = loaded + } + + // MARK: - Persistence + + private func encodedLine(for event: VaultHistoryEvent) -> Data? { + guard var data = try? Self.makeEncoder().encode(event) else { return nil } + data.append(0x0a) + return data + } + + private func appendLine(_ line: Data, to fileURL: URL) -> Bool { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: fileURL.path) { + try? fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + return (try? line.write(to: fileURL, options: .atomic)) != nil + } + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return false } + defer { try? handle.close() } + guard (try? handle.seekToEnd()) != nil else { return false } + return (try? handle.write(contentsOf: line)) != nil + } + + /// Rewrites the file from the bounded in-memory buffer, dropping history + /// beyond `retention.maxStoredEvents`. + private func compact(to fileURL: URL) { + trimMemoryIfNeeded() + let encoder = Self.makeEncoder() + var data = Data() + for event in events { + guard let encoded = try? encoder.encode(event) else { continue } + data.append(encoded) + data.append(0x0a) + } + guard (try? data.write(to: fileURL, options: .atomic)) != nil else { return } + fileBytes = data.count + } + + private func trimMemoryIfNeeded() { + if events.count > retention.maxStoredEvents { + events.removeFirst(events.count - retention.maxStoredEvents) + } + } +} diff --git a/Sources/VaultHistoryGrouping.swift b/Sources/VaultHistoryGrouping.swift new file mode 100644 index 000000000000..c2cc66c9f620 --- /dev/null +++ b/Sources/VaultHistoryGrouping.swift @@ -0,0 +1,214 @@ +import Foundation + +/// The dimension the History timeline is grouped by. Date is the flagship +/// (browser-history-style buckets, "Last 24 hours" first); every other key +/// falls out of the same pure grouping pass over the event list. +enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable { + case date + case workspace + case window + case agent + case kind + + var id: String { rawValue } + + var label: String { + switch self { + case .date: return String(localized: "vaultHistory.group.date", defaultValue: "Date") + case .workspace: return String(localized: "vaultHistory.group.workspace", defaultValue: "Workspace") + case .window: return String(localized: "vaultHistory.group.window", defaultValue: "Window") + case .agent: return String(localized: "vaultHistory.group.agent", defaultValue: "Agent") + case .kind: return String(localized: "vaultHistory.group.kind", defaultValue: "Type") + } + } + + var symbolName: String { + switch self { + case .date: return "clock" + case .workspace: return "square.on.square" + case .window: return "macwindow" + case .agent: return "person.2" + case .kind: return "tag" + } + } +} + +/// Browser-history-style date buckets, newest bucket first. +enum VaultHistoryDateBucket: Int, CaseIterable, Identifiable, Sendable { + case last24Hours + case yesterday + case thisWeek + case thisMonth + case older + + var id: Int { rawValue } + + /// Buckets a timestamp relative to `now`. "Last 24 hours" is a rolling + /// window (also absorbing any same-day stragglers, e.g. across a DST + /// fold); the calendar buckets catch everything older, so an event from + /// yesterday morning can land in `yesterday` while one from yesterday + /// evening is still inside `last24Hours`. + static func bucket(for date: Date, now: Date, calendar: Calendar) -> VaultHistoryDateBucket { + if date >= now.addingTimeInterval(-24 * 60 * 60) || calendar.isDate(date, inSameDayAs: now) { + return .last24Hours + } + if isYesterday(date, now: now, calendar: calendar) { + return .yesterday + } + if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { + return .thisWeek + } + if calendar.isDate(date, equalTo: now, toGranularity: .month) { + return .thisMonth + } + return .older + } + + private static func isYesterday(_ date: Date, now: Date, calendar: Calendar) -> Bool { + guard let yesterday = calendar.date(byAdding: .day, value: -1, to: now) else { return false } + return calendar.isDate(date, inSameDayAs: yesterday) + } + + var label: String { + switch self { + case .last24Hours: + return String(localized: "vaultHistory.bucket.last24Hours", defaultValue: "Last 24 hours") + case .yesterday: + return String(localized: "vaultHistory.bucket.yesterday", defaultValue: "Yesterday") + case .thisWeek: + return String(localized: "vaultHistory.bucket.thisWeek", defaultValue: "This week") + case .thisMonth: + return String(localized: "vaultHistory.bucket.thisMonth", defaultValue: "This month") + case .older: + return String(localized: "vaultHistory.bucket.older", defaultValue: "Older") + } + } +} + +/// One rendered section of the History timeline: a stable identity, a +/// header title, and the section's events sorted newest first. +struct VaultHistoryGroup: Identifiable, Equatable, Sendable { + let id: String + let title: String + let events: [VaultHistoryEvent] +} + +/// Pure grouping over a flat event list. Given the same events, key, `now`, +/// and calendar, the output is deterministic — no clock or store access — +/// so every grouping behavior is unit-testable. +struct VaultHistoryGrouper: Sendable { + var calendar: Calendar + + init(calendar: Calendar = .current) { + self.calendar = calendar + } + + static let otherGroupID = "other" + + func groups( + events: [VaultHistoryEvent], + by key: VaultHistoryGroupKey, + now: Date + ) -> [VaultHistoryGroup] { + let sorted = events.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.id > $1.id + } + switch key { + case .date: + return dateGroups(sortedEvents: sorted, now: now) + case .workspace: + return subjectGroups(sortedEvents: sorted, idFor: { event in + event.subject.workspaceId.map { "workspace:\($0.uuidString)" } + }) + case .window: + return subjectGroups(sortedEvents: sorted, idFor: { event in + event.subject.windowId.map { "window:\($0.uuidString)" } + }) + case .agent: + return agentGroups(sortedEvents: sorted) + case .kind: + return kindGroups(sortedEvents: sorted) + } + } + + private func dateGroups(sortedEvents: [VaultHistoryEvent], now: Date) -> [VaultHistoryGroup] { + var byBucket: [VaultHistoryDateBucket: [VaultHistoryEvent]] = [:] + for event in sortedEvents { + let bucket = VaultHistoryDateBucket.bucket(for: event.timestamp, now: now, calendar: calendar) + byBucket[bucket, default: []].append(event) + } + return VaultHistoryDateBucket.allCases.compactMap { bucket in + guard let events = byBucket[bucket], !events.isEmpty else { return nil } + return VaultHistoryGroup(id: "date:\(bucket.rawValue)", title: bucket.label, events: events) + } + } + + /// Groups by an identity extracted from the subject. Group order follows + /// each group's most recent event; events without the identity land in a + /// trailing "Other" group. The group title is the newest event's title, + /// so renamed workspaces show their latest name. + private func subjectGroups( + sortedEvents: [VaultHistoryEvent], + idFor: (VaultHistoryEvent) -> String? + ) -> [VaultHistoryGroup] { + var order: [String] = [] + var byId: [String: [VaultHistoryEvent]] = [:] + for event in sortedEvents { + let id = idFor(event) ?? Self.otherGroupID + if byId[id] == nil { order.append(id) } + byId[id, default: []].append(event) + } + if let otherIndex = order.firstIndex(of: Self.otherGroupID), otherIndex != order.count - 1 { + order.remove(at: otherIndex) + order.append(Self.otherGroupID) + } + return order.compactMap { id in + guard let events = byId[id], !events.isEmpty else { return nil } + let title = id == Self.otherGroupID + ? String(localized: "vaultHistory.group.other", defaultValue: "Other") + : (events.first(where: { !$0.title.isEmpty })?.title + ?? String(localized: "vaultHistory.untitled", defaultValue: "Untitled")) + return VaultHistoryGroup(id: id, title: title, events: events) + } + } + + private func agentGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { + var order: [String] = [] + var byId: [String: [VaultHistoryEvent]] = [:] + for event in sortedEvents { + let id = event.subject.agent.map { "agent:\($0)" } ?? Self.otherGroupID + if byId[id] == nil { order.append(id) } + byId[id, default: []].append(event) + } + if let otherIndex = order.firstIndex(of: Self.otherGroupID), otherIndex != order.count - 1 { + order.remove(at: otherIndex) + order.append(Self.otherGroupID) + } + return order.compactMap { id in + guard let events = byId[id], !events.isEmpty else { return nil } + let title: String + if id == Self.otherGroupID { + title = String(localized: "vaultHistory.group.cmux", defaultValue: "cmux") + } else if let raw = events.first?.subject.agent, let agent = SessionAgent(rawValue: raw) { + title = agent.displayName + } else { + title = String(localized: "vaultHistory.group.other", defaultValue: "Other") + } + return VaultHistoryGroup(id: id, title: title, events: events) + } + } + + private func kindGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { + var order: [VaultHistoryEventKind] = [] + var byKind: [VaultHistoryEventKind: [VaultHistoryEvent]] = [:] + for event in sortedEvents { + if byKind[event.kind] == nil { order.append(event.kind) } + byKind[event.kind, default: []].append(event) + } + return order.compactMap { kind in + guard let events = byKind[kind], !events.isEmpty else { return nil } + return VaultHistoryGroup(id: "kind:\(kind.rawValue)", title: kind.label, events: events) + } + } +} diff --git a/Sources/VaultHistoryLifecycleRecording.swift b/Sources/VaultHistoryLifecycleRecording.swift new file mode 100644 index 000000000000..5d791ef15359 --- /dev/null +++ b/Sources/VaultHistoryLifecycleRecording.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Lifecycle hooks that feed the Vault History timeline. Call sites in +/// `TabManager` / `AppDelegate` stay one-liners; every hook here guards +/// suppression (session restore, app termination) and builds the event. +extension TabManager { + func recordVaultHistoryWorkspaceCreated(_ workspace: Workspace) { + guard !VaultHistoryEventLog.isRecordingSuppressed else { return } + VaultHistoryEventLog.shared.record(VaultHistoryEvent( + timestamp: Date(), + kind: .workspaceCreated, + title: resolvedWorkspaceDisplayTitle(for: workspace), + subject: VaultHistorySubject( + workspaceId: workspace.id, + windowId: AppDelegate.shared?.windowId(for: self), + directory: workspace.currentDirectory + ) + )) + } + + func recordVaultHistoryWorkspaceRenamed( + _ workspace: Workspace, + previousTitle: String, + currentTitle: String + ) { + guard !VaultHistoryEventLog.isRecordingSuppressed else { return } + VaultHistoryEventLog.shared.record(VaultHistoryEvent( + timestamp: Date(), + kind: .workspaceRenamed, + title: currentTitle, + previousTitle: previousTitle, + subject: VaultHistorySubject( + workspaceId: workspace.id, + windowId: AppDelegate.shared?.windowId(for: self), + directory: workspace.currentDirectory + ) + )) + } + + func recordVaultHistoryWorkspaceClosed(_ workspace: Workspace) { + guard !VaultHistoryEventLog.isRecordingSuppressed else { return } + VaultHistoryEventLog.shared.record(VaultHistoryEvent( + timestamp: Date(), + kind: .workspaceClosed, + title: resolvedWorkspaceDisplayTitle(for: workspace), + subject: VaultHistorySubject( + workspaceId: workspace.id, + windowId: AppDelegate.shared?.windowId(for: self), + directory: workspace.currentDirectory + ) + )) + } +} + +extension AppDelegate { + func recordVaultHistoryWindowOpened(windowId: UUID) { + guard !VaultHistoryEventLog.isRecordingSuppressed else { return } + VaultHistoryEventLog.shared.record(VaultHistoryEvent( + timestamp: Date(), + kind: .windowOpened, + title: "", + subject: VaultHistorySubject(windowId: windowId) + )) + } + + /// Called from the same choke point that records closed-window restore + /// history, so suppression (terminating app, session restore, explicit + /// suppression sets) is already decided by the caller. + func recordVaultHistoryWindowClosed(windowId: UUID, snapshot: SessionWindowSnapshot) { + let workspaces = snapshot.tabManager.workspaces + let title = workspaces + .compactMap { workspace -> String? in + let candidate = workspace.customTitle ?? workspace.processTitle + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + .first ?? "" + VaultHistoryEventLog.shared.record(VaultHistoryEvent( + timestamp: Date(), + kind: .windowClosed, + title: title, + workspaceCount: workspaces.count, + subject: VaultHistorySubject(windowId: windowId) + )) + } +} diff --git a/Sources/VaultHistoryRetentionPolicy.swift b/Sources/VaultHistoryRetentionPolicy.swift new file mode 100644 index 000000000000..a8426da3b77a --- /dev/null +++ b/Sources/VaultHistoryRetentionPolicy.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Size bounds for the persisted Vault history event log. +/// +/// The store enforces these on every append and load so the on-disk file and +/// the in-memory buffer are bounded by construction — there is no code path +/// that scans an unbounded file (cf. issue #4536). +struct VaultHistoryRetentionPolicy: Sendable { + /// Maximum events kept in memory and rewritten to disk on compaction. + let maxStoredEvents: Int + /// File size that triggers a compacting rewrite on the next append. + let maxFileBytes: Int + /// Byte budget for the tail read performed when loading persisted events. + let maxLoadBytes: Int + + static let `default` = VaultHistoryRetentionPolicy( + maxStoredEvents: 2000, + maxFileBytes: 4 * 1024 * 1024, + maxLoadBytes: 2 * 1024 * 1024 + ) + + init(maxStoredEvents: Int, maxFileBytes: Int, maxLoadBytes: Int) { + self.maxStoredEvents = max(1, maxStoredEvents) + self.maxFileBytes = max(1024, maxFileBytes) + self.maxLoadBytes = max(1024, maxLoadBytes) + } +} diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift new file mode 100644 index 000000000000..e3da581837c2 --- /dev/null +++ b/Sources/VaultHistorySessionEvents.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Projects Vault session index entries into history events at read time. +/// +/// Sessions are already durably indexed by Vault from the agents' own +/// on-disk stores, so the timeline derives them instead of double-writing: +/// whatever the session index covers automatically appears in History. +struct VaultHistorySessionEventProjection: Sendable { + init() {} + + func events(from entries: [SessionEntry]) -> [VaultHistoryEvent] { + entries.map { entry in + VaultHistoryEvent( + id: "session:\(entry.agent.rawValue):\(entry.id)", + timestamp: entry.modified, + kind: .sessionActivity, + title: entry.title, + subject: VaultHistorySubject( + sessionId: entry.sessionId, + agent: entry.agent.rawValue, + directory: entry.cwd + ) + ) + } + } +} diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift new file mode 100644 index 000000000000..970c55e3e630 --- /dev/null +++ b/Sources/VaultHistoryTimelineModel.swift @@ -0,0 +1,79 @@ +import Foundation +import Observation + +/// View model for the Vault History tab: merges persisted lifecycle events +/// with session events derived from the Vault index, and exposes grouped +/// sections computed by the pure ``VaultHistoryGrouper``. +@MainActor +@Observable +final class VaultHistoryTimelineModel { + /// Persisted grouping selection. Defaults to date (last-24-hours first). + var groupKey: VaultHistoryGroupKey { + didSet { + guard groupKey != oldValue else { return } + defaults.set(groupKey.rawValue, forKey: Self.groupKeyDefaultsKey) + regroup() + } + } + + private(set) var groups: [VaultHistoryGroup] = [] + private(set) var isLoading = false + /// True once the first refresh completed, so the empty state does not + /// flash before anything loaded. + private(set) var didLoad = false + + private static let groupKeyDefaultsKey = "vaultHistory.groupKey" + /// Cap on merged timeline size handed to grouping; both inputs are + /// already bounded (store retention, session index page caps) — this is + /// a final guard so the UI never renders an unbounded list. + private static let maxTimelineEvents = 3000 + + private let log: VaultHistoryEventLog + private let grouper: VaultHistoryGrouper + private let projection = VaultHistorySessionEventProjection() + private let defaults: UserDefaults + private let now: () -> Date + private var mergedEvents: [VaultHistoryEvent] = [] + private var refreshTask: Task? + + init( + log: VaultHistoryEventLog, + grouper: VaultHistoryGrouper = VaultHistoryGrouper(), + defaults: UserDefaults = .standard, + now: @escaping () -> Date = Date.init + ) { + self.log = log + self.grouper = grouper + self.defaults = defaults + self.now = now + self.groupKey = defaults.string(forKey: Self.groupKeyDefaultsKey) + .flatMap(VaultHistoryGroupKey.init(rawValue:)) ?? .date + } + + /// Reloads persisted events, merges the given session entries, and + /// regroups. Coalesces: a refresh requested while one is in flight + /// cancels and replaces it. + func refresh(sessionEntries: [SessionEntry]) { + refreshTask?.cancel() + isLoading = true + let log = log + refreshTask = Task { [weak self] in + let recorded = await log.recentEvents() + guard !Task.isCancelled, let self else { return } + var merged = recorded + merged.append(contentsOf: self.projection.events(from: sessionEntries)) + merged.sort { $0.timestamp > $1.timestamp } + if merged.count > Self.maxTimelineEvents { + merged.removeLast(merged.count - Self.maxTimelineEvents) + } + self.mergedEvents = merged + self.isLoading = false + self.didLoad = true + self.regroup() + } + } + + private func regroup() { + groups = grouper.groups(events: mergedEvents, by: groupKey, now: now()) + } +} diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift new file mode 100644 index 000000000000..02eeb2951f8c --- /dev/null +++ b/Sources/VaultHistoryView.swift @@ -0,0 +1,277 @@ +import CmuxFoundation +import SwiftUI + +/// The Vault History tab: a unified timeline of workspace/window lifecycle +/// events and agent sessions, grouped by the user's chosen dimension +/// (date buckets by default, "Last 24 hours" first). +struct VaultHistoryView: View { + @ObservedObject var sessionStore: SessionIndexStore + /// Injected so previews/tests can point at an isolated log. + let log: VaultHistoryEventLog + @State private var model: VaultHistoryTimelineModel? + + var body: some View { + VStack(spacing: 0) { + if let model { + VaultHistoryContentView(sessionStore: sessionStore, log: log, model: model) + } else { + Color.clear + .onAppear { + if model == nil { + model = VaultHistoryTimelineModel(log: log) + } + } + } + } + } +} + +private struct VaultHistoryContentView: View { + @ObservedObject var sessionStore: SessionIndexStore + let log: VaultHistoryEventLog + let model: VaultHistoryTimelineModel + + var body: some View { + VStack(spacing: 0) { + controlBar + if !model.didLoad { + loadingView + } else if model.groups.isEmpty { + emptyView + } else { + timelineList + } + } + .onAppear { + if sessionStore.entries.isEmpty && !sessionStore.isLoading { + sessionStore.reload() + } + model.refresh(sessionEntries: sessionStore.entries) + } + .onChange(of: sessionStore.entries) { _, entries in + model.refresh(sessionEntries: entries) + } + .onChange(of: log.revision) { _, _ in + model.refresh(sessionEntries: sessionStore.entries) + } + } + + private var controlBar: some View { + HStack(spacing: 6) { + Menu { + ForEach(VaultHistoryGroupKey.allCases) { key in + Button { + model.groupKey = key + } label: { + if model.groupKey == key { + Label(key.label, systemImage: "checkmark") + } else { + Text(key.label) + } + } + } + } label: { + HStack(spacing: 3) { + Image(systemName: model.groupKey.symbolName) + .cmuxFont( + size: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) + Text(model.groupKey.label) + .cmuxFont( + size: RightSidebarChromeControlStyle.labelSize, + weight: RightSidebarChromeControlStyle.labelWeight + ) + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(String(localized: "vaultHistory.groupPicker.tooltip", defaultValue: "Group history by")) + .accessibilityIdentifier("VaultHistoryGroupPicker") + .titlebarInteractiveControl() + + Spacer(minLength: 4) + + Button { + sessionStore.reload() + model.refresh(sessionEntries: sessionStore.entries) + } label: { + Image(systemName: "arrow.clockwise") + .cmuxFont(size: 10, weight: .medium) + } + .buttonStyle(.borderless) + .help(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) + .disabled(model.isLoading || sessionStore.isLoading) + .titlebarInteractiveControl() + } + .rightSidebarChromeBar() + .rightSidebarChromeBottomBorder() + } + + private var loadingView: some View { + VStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "vaultHistory.loading", defaultValue: "Loading history…")) + .cmuxFont(size: 11) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + VStack(spacing: 4) { + Text(String(localized: "vaultHistory.empty.title", defaultValue: "No history yet")) + .cmuxFont(size: 12) + .foregroundColor(.secondary) + Text(String( + localized: "vaultHistory.empty.subtitle", + defaultValue: "Workspace, window, and agent session activity will appear here." + )) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var timelineList: some View { + // Snapshot value data before entering the lazy subtree: rows below + // the LazyVStack boundary receive plain values, never store refs. + let groups = model.groups + return ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(groups) { group in + Section { + ForEach(group.events) { event in + VaultHistoryEventRow(event: event) + } + } header: { + VaultHistoryGroupHeader(title: group.title, count: group.events.count) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct VaultHistoryGroupHeader: View, Equatable { + let title: String + let count: Int + + var body: some View { + HStack(spacing: 4) { + Text(title) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(.secondary) + .lineLimit(1) + Text("\(count)") + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.6)) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } +} + +struct VaultHistoryEventRow: View, Equatable { + let event: VaultHistoryEvent + + nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { + lhs.event == rhs.event + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + private static let absoluteFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: event.kind.symbolName) + .cmuxFont(size: 10, weight: .regular) + .foregroundColor(.secondary) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(displayTitle) + .cmuxFont(size: 11.5) + .foregroundColor(.primary.opacity(0.85)) + .lineLimit(1) + Text(subtitle) + .cmuxFont(size: 10) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer(minLength: 4) + Text(Self.relativeFormatter.localizedString(for: event.timestamp, relativeTo: Date())) + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.7)) + .help(Self.absoluteFormatter.string(from: event.timestamp)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private var displayTitle: String { + let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty else { return trimmed } + switch event.kind { + case .windowOpened, .windowClosed: + return String(localized: "vaultHistory.window", defaultValue: "Window") + default: + return String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + } + } + + private var subtitle: String { + var parts: [String] = [] + if event.kind == .sessionActivity, + let raw = event.subject.agent, + let agent = SessionAgent(rawValue: raw) { + parts.append(agent.displayName) + } else { + parts.append(event.kind.label) + } + if event.kind == .workspaceRenamed, let previousTitle = event.previousTitle, !previousTitle.isEmpty { + parts.append(String( + format: String(localized: "vaultHistory.detail.renamedFrom", defaultValue: "was “%@”"), + previousTitle + )) + } + if let count = event.workspaceCount { + parts.append(Self.workspaceCountLabel(count)) + } + if let directory = event.subject.directory, !directory.isEmpty { + // String-only path math: URL(fileURLWithPath:) would stat the path. + let component = (directory as NSString).lastPathComponent + if !component.isEmpty, component != "." { + parts.append(component) + } + } + return parts.joined(separator: " · ") + } + + private static func workspaceCountLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "vaultHistory.workspaceCount.one", defaultValue: "1 workspace") + } + return String.localizedStringWithFormat( + String(localized: "vaultHistory.workspaceCount.other", defaultValue: "%d workspaces"), + count + ) + } +} diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift new file mode 100644 index 000000000000..520da24186b6 --- /dev/null +++ b/Sources/VaultPaneView.swift @@ -0,0 +1,95 @@ +import CmuxFoundation +import SwiftUI + +/// Sub-navigation inside the Vault sidebar mode: the existing agent session +/// index ("Sessions") and the unified activity timeline ("History"). +enum VaultPaneTab: String, CaseIterable, Identifiable { + case sessions + case history + + var id: String { rawValue } + + var label: String { + switch self { + case .sessions: + return String(localized: "vaultPane.tab.sessions", defaultValue: "Sessions") + case .history: + return String(localized: "vaultPane.tab.history", defaultValue: "History") + } + } + + var symbolName: String { + switch self { + case .sessions: return "books.vertical" + case .history: return "clock.arrow.circlepath" + } + } +} + +/// Hosts the Vault mode's content behind a Sessions/History tab bar. Both +/// the right-sidebar mount and the pop-out pane mount render this view so +/// the two entrypoints share one implementation. +struct VaultPaneView: View { + @ObservedObject var store: SessionIndexStore + let onResume: ((SessionEntry) -> Void)? + @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.sessions.rawValue + + private var selectedTab: VaultPaneTab { + VaultPaneTab(rawValue: selectedTabRawValue) ?? .sessions + } + + var body: some View { + VStack(spacing: 0) { + tabBar + switch selectedTab { + case .sessions: + SessionIndexView(store: store, onResume: onResume) + case .history: + VaultHistoryView(sessionStore: store, log: .shared) + } + } + } + + private var tabBar: some View { + HStack(spacing: 6) { + ForEach(VaultPaneTab.allCases) { tab in + VaultPaneTabButton(tab: tab, isSelected: selectedTab == tab) { + selectedTabRawValue = tab.rawValue + } + } + Spacer(minLength: 0) + } + .rightSidebarChromeBar() + .rightSidebarChromeBottomBorder() + } +} + +private struct VaultPaneTabButton: View { + let tab: VaultPaneTab + let isSelected: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Image(systemName: tab.symbolName) + .cmuxFont( + size: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) + Text(tab.label) + .cmuxFont( + size: RightSidebarChromeControlStyle.labelSize, + weight: RightSidebarChromeControlStyle.labelWeight + ) + } + .rightSidebarChromePill(isSelected: isSelected, isHovered: isHovered) + } + .buttonStyle(.plain) + .titlebarInteractiveControl() + .onHover { isHovered = $0 } + .help(tab.label) + .accessibilityIdentifier("VaultPaneTabButton.\(tab.rawValue)") + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 426b4582ad8e..0cb3d7d1a990 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2251,7 +2251,19 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3575000000000000000000D /* VaultAgentRegistry+Campfire.swift */; }; 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858200000000000000000003 /* VaultAgentRegistry+Kimi.swift */; }; B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000001 /* VaultAgentRegistry.swift */; }; + 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */; }; + 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */; }; + A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */; }; + 59E6509DA68F5DB3550040DC /* VaultHistoryEventStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */; }; + 54D2FC0F493B36ACF14EB2CA /* VaultHistoryGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */; }; + 6C5464BB6BC11C869723318F /* VaultHistoryGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */; }; + 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */; }; + C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */; }; + 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */; }; + 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */; }; + 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */; }; F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76550060000000000000002 /* VaultObservedAgentProcess.swift */; }; + 437E021ADABFA29ADC68547C /* VaultPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */; }; F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */; }; C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */; }; A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */; }; @@ -4650,7 +4662,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000D /* VaultAgentRegistry+Campfire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentRegistry+Campfire.swift"; sourceTree = ""; }; 858200000000000000000003 /* VaultAgentRegistry+Kimi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentRegistry+Kimi.swift"; sourceTree = ""; }; B35750000000000000000001 /* VaultAgentRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultAgentRegistry.swift; sourceTree = ""; }; + A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEvent.swift; sourceTree = ""; }; + BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventLog.swift; sourceTree = ""; }; + EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventStore.swift; sourceTree = ""; }; + EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventStoreTests.swift; sourceTree = ""; }; + 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGrouping.swift; sourceTree = ""; }; + FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGroupingTests.swift; sourceTree = ""; }; + 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryLifecycleRecording.swift; sourceTree = ""; }; + 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryRetentionPolicy.swift; sourceTree = ""; }; + C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistorySessionEvents.swift; sourceTree = ""; }; + EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryTimelineModel.swift; sourceTree = ""; }; + 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryView.swift; sourceTree = ""; }; F76550060000000000000002 /* VaultObservedAgentProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultObservedAgentProcess.swift; sourceTree = ""; }; + BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaneView.swift; sourceTree = ""; }; F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+EmptyAreasAndFooter.swift"; sourceTree = ""; }; C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+WorkspaceGroups.swift"; sourceTree = ""; }; A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ViewerNavigationKeyRouter.swift; sourceTree = ""; }; @@ -6500,6 +6524,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 850000000000000000000016 /* SessionIndexTableView.swift */, 850000000000000000000017 /* SessionIndexSnapshotLoader.swift */, 850000000000000000000018 /* SessionIndexJSONLReader.swift */, + A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */, + 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */, + EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */, + BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */, + 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */, + C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */, + EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */, + 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */, + BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */, + 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */, 850000000000000000000019 /* SessionIndexJSONLReadMetrics.swift */, 85000000000000000000001A /* SessionIndexEntryProjection.swift */, 85000000000000000000001B /* SessionIndexTableEnvironmentSnapshot.swift */, @@ -7132,6 +7166,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */, C06552020000000000000002 /* TabManagerTitleUpdateTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */, + FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */, C9D9924AC89D7C3408DBC56A /* WorkspaceRecoveryTests.swift */, 939AA681FBA244B994634EE0 /* WorkspaceRecoveryReviewRegressionTests.swift */, AB12CD340000000000000002 /* WorkspaceTodoNotificationRegressionTests.swift */, @@ -9261,7 +9297,17 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */, 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */, B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */, + 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */, + 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */, + A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */, + 54D2FC0F493B36ACF14EB2CA /* VaultHistoryGrouping.swift in Sources */, + 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */, + C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */, + 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */, + 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */, + 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */, F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */, + 437E021ADABFA29ADC68547C /* VaultPaneView.swift in Sources */, F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */, C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */, A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */, @@ -10140,6 +10186,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, 468100000000000000000003 /* TypingHotPathRegressionTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + 59E6509DA68F5DB3550040DC /* VaultHistoryEventStoreTests.swift in Sources */, + 6C5464BB6BC11C869723318F /* VaultHistoryGroupingTests.swift in Sources */, A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */, C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */, diff --git a/cmuxTests/VaultHistoryEventStoreTests.swift b/cmuxTests/VaultHistoryEventStoreTests.swift new file mode 100644 index 000000000000..95be170d8c7e --- /dev/null +++ b/cmuxTests/VaultHistoryEventStoreTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct VaultHistoryEventStoreTests { + private func makeTempFileURL() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("vault-history-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("history.jsonl") + } + + private func event( + id: String, + secondsAgo: TimeInterval, + kind: VaultHistoryEventKind = .workspaceCreated, + title: String = "ws", + reference: Date = Date(timeIntervalSince1970: 1_800_000_000) + ) -> VaultHistoryEvent { + VaultHistoryEvent( + id: id, + timestamp: reference.addingTimeInterval(-secondsAgo), + kind: kind, + title: title, + subject: VaultHistorySubject(workspaceId: UUID()) + ) + } + + @Test func appendedEventsRoundTripThroughDisk() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let store = VaultHistoryEventStore(fileURL: fileURL) + let created = VaultHistoryEvent( + id: "e1", + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + kind: .workspaceRenamed, + title: "renamed", + previousTitle: "original", + subject: VaultHistorySubject(workspaceId: UUID(), directory: "/tmp/project") + ) + let closed = VaultHistoryEvent( + id: "e2", + timestamp: Date(timeIntervalSince1970: 1_700_000_100), + kind: .windowClosed, + title: "win", + workspaceCount: 3, + subject: VaultHistorySubject(windowId: UUID()) + ) + await store.append(created) + await store.append(closed) + + // A fresh store instance must see the same events after "restart". + let reloaded = VaultHistoryEventStore(fileURL: fileURL) + let events = await reloaded.recentEvents() + #expect(events.count == 2) + #expect(events.first?.id == "e2") + #expect(events.first?.workspaceCount == 3) + #expect(events.last?.id == "e1") + #expect(events.last?.previousTitle == "original") + #expect(events.last?.subject.directory == "/tmp/project") + #expect(events.last?.kind == .workspaceRenamed) + } + + @Test func recentEventsReturnsNewestFirstAndHonorsLimit() async throws { + let store = VaultHistoryEventStore(fileURL: nil) + await store.append(event(id: "old", secondsAgo: 300)) + await store.append(event(id: "new", secondsAgo: 10)) + await store.append(event(id: "mid", secondsAgo: 100)) + + let all = await store.recentEvents() + #expect(all.map(\.id) == ["new", "mid", "old"]) + + let limited = await store.recentEvents(limit: 2) + #expect(limited.map(\.id) == ["new", "mid"]) + } + + @Test func retentionCompactsFileAndKeepsNewestEvents() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + // Tiny budgets: every append overflows maxFileBytes, so the file is + // repeatedly compacted down to the newest maxStoredEvents events. + let retention = VaultHistoryRetentionPolicy( + maxStoredEvents: 5, + maxFileBytes: 1024, + maxLoadBytes: 64 * 1024 + ) + let store = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + for index in 0..<50 { + await store.append(event(id: "e\(index)", secondsAgo: TimeInterval(1000 - index))) + } + + let events = await store.recentEvents() + #expect(events.count == 5) + #expect(events.map(\.id) == ["e49", "e48", "e47", "e46", "e45"]) + + let fileSize = try #require( + try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int + ) + #expect(fileSize <= 1024 + 512) + + // Restarting on the compacted file sees only the retained tail. + let reloaded = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + let reloadedEvents = await reloaded.recentEvents() + #expect(reloadedEvents.map(\.id) == ["e49", "e48", "e47", "e46", "e45"]) + } + + @Test func loadIsBoundedAndSkipsPartialLeadingLine() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + // Write with generous limits, reload with a small load budget: only + // the newest events that fit inside maxLoadBytes come back, and the + // partial leading line inside the tail window is skipped cleanly. + let writer = VaultHistoryEventStore(fileURL: fileURL) + for index in 0..<200 { + await writer.append(event(id: "e\(index)", secondsAgo: TimeInterval(10_000 - index))) + } + + let reader = VaultHistoryEventStore( + fileURL: fileURL, + retention: VaultHistoryRetentionPolicy( + maxStoredEvents: 1000, + maxFileBytes: 64 * 1024 * 1024, + maxLoadBytes: 4096 + ) + ) + let events = await reader.recentEvents() + #expect(!events.isEmpty) + #expect(events.count < 200) + // The newest event is always inside the tail window. + #expect(events.first?.id == "e199") + // Every decoded event is intact (a torn first line would decode to + // nothing rather than a corrupt event). + #expect(events.allSatisfy { $0.title == "ws" }) + } + + @Test func corruptLinesAreSkippedOnLoad() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let writer = VaultHistoryEventStore(fileURL: fileURL) + await writer.append(event(id: "good", secondsAgo: 60)) + + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("not json at all\n".utf8)) + try handle.close() + + let reloaded = VaultHistoryEventStore(fileURL: fileURL) + let events = await reloaded.recentEvents() + #expect(events.map(\.id) == ["good"]) + } +} diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift new file mode 100644 index 000000000000..d39605846cea --- /dev/null +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -0,0 +1,198 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct VaultHistoryGroupingTests { + private static func utcCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + calendar.firstWeekday = 2 + return calendar + } + + /// 2026-06-18 12:00:00 UTC — a Thursday, mid-month, so every date + /// bucket below it is reachable. + private static let now = Date(timeIntervalSince1970: 1_781_784_000) + + private func event( + id: String, + secondsAgo: TimeInterval, + kind: VaultHistoryEventKind = .workspaceCreated, + title: String = "", + workspaceId: UUID? = nil, + windowId: UUID? = nil, + agent: String? = nil + ) -> VaultHistoryEvent { + VaultHistoryEvent( + id: id, + timestamp: Self.now.addingTimeInterval(-secondsAgo), + kind: kind, + title: title, + subject: VaultHistorySubject( + workspaceId: workspaceId, + windowId: windowId, + sessionId: agent == nil ? nil : "session-\(id)", + agent: agent + ) + ) + } + + // MARK: - Date bucketing + + @Test func dateBucketBoundariesAroundLast24Hours() { + let calendar = Self.utcCalendar() + let now = Self.now + + let justInside = now.addingTimeInterval(-24 * 3600 + 60) + #expect(VaultHistoryDateBucket.bucket(for: justInside, now: now, calendar: calendar) == .last24Hours) + + // 24h01m ago is 11:59 the previous day: outside the rolling window, + // inside calendar-yesterday. + let justOutside = now.addingTimeInterval(-24 * 3600 - 60) + #expect(VaultHistoryDateBucket.bucket(for: justOutside, now: now, calendar: calendar) == .yesterday) + + #expect(VaultHistoryDateBucket.bucket(for: now, now: now, calendar: calendar) == .last24Hours) + } + + @Test func dateBucketCalendarBuckets() { + let calendar = Self.utcCalendar() + let now = Self.now + + // Tuesday 2026-06-16 08:00 UTC: same Monday-start week as Thursday + // the 18th, older than calendar-yesterday. + let sameWeek = Date(timeIntervalSince1970: 1_781_596_800) + #expect(VaultHistoryDateBucket.bucket(for: sameWeek, now: now, calendar: calendar) == .thisWeek) + + // 2026-06-06: same month, previous week. + let sameMonth = Date(timeIntervalSince1970: 1_780_704_000) + #expect(VaultHistoryDateBucket.bucket(for: sameMonth, now: now, calendar: calendar) == .thisMonth) + + // 2026-05-18: previous month. + let older = Date(timeIntervalSince1970: 1_779_105_600) + #expect(VaultHistoryDateBucket.bucket(for: older, now: now, calendar: calendar) == .older) + } + + @Test func dateGroupingOrdersBucketsNewestFirstAndSkipsEmptyBuckets() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let events = [ + event(id: "older", secondsAgo: 40 * 24 * 3600), + event(id: "recent", secondsAgo: 600), + event(id: "yesterday", secondsAgo: 25 * 3600), + ] + let groups = grouper.groups(events: events, by: .date, now: Self.now) + + #expect(groups.map(\.id) == [ + "date:\(VaultHistoryDateBucket.last24Hours.rawValue)", + "date:\(VaultHistoryDateBucket.yesterday.rawValue)", + "date:\(VaultHistoryDateBucket.older.rawValue)", + ]) + #expect(groups[0].events.map(\.id) == ["recent"]) + #expect(groups[1].events.map(\.id) == ["yesterday"]) + #expect(groups[2].events.map(\.id) == ["older"]) + } + + // MARK: - Group by workspace + + @Test func workspaceGroupingClustersByIdAndOrdersByRecency() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let workspaceA = UUID() + let workspaceB = UUID() + let events = [ + event(id: "a-old", secondsAgo: 500, kind: .workspaceCreated, title: "Old name", workspaceId: workspaceA), + event(id: "b-only", secondsAgo: 100, kind: .workspaceClosed, title: "Beta", workspaceId: workspaceB), + event(id: "a-new", secondsAgo: 50, kind: .workspaceRenamed, title: "New name", workspaceId: workspaceA), + event(id: "no-workspace", secondsAgo: 10, kind: .windowOpened), + ] + let groups = grouper.groups(events: events, by: .workspace, now: Self.now) + + #expect(groups.count == 3) + // Groups ordered by their newest event; "Other" always trails. + #expect(groups[0].id == "workspace:\(workspaceA.uuidString)") + #expect(groups[1].id == "workspace:\(workspaceB.uuidString)") + #expect(groups[2].id == VaultHistoryGrouper.otherGroupID) + // Events inside a group stay newest-first. + #expect(groups[0].events.map(\.id) == ["a-new", "a-old"]) + // Group title follows the newest event (the rename's new name). + #expect(groups[0].title == "New name") + #expect(groups[2].events.map(\.id) == ["no-workspace"]) + } + + @Test func windowGroupingClustersByWindowId() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let window = UUID() + let events = [ + event(id: "w-open", secondsAgo: 200, kind: .windowOpened, windowId: window), + event(id: "w-close", secondsAgo: 20, kind: .windowClosed, title: "Main", windowId: window), + event(id: "session", secondsAgo: 10, kind: .sessionActivity, title: "Fix bug", agent: "claude"), + ] + let groups = grouper.groups(events: events, by: .window, now: Self.now) + + #expect(groups.map(\.id) == ["window:\(window.uuidString)", VaultHistoryGrouper.otherGroupID]) + #expect(groups[0].events.map(\.id) == ["w-close", "w-open"]) + } + + // MARK: - Group by agent and kind + + @Test func agentGroupingSeparatesAgentsFromAppEvents() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let events = [ + event(id: "claude-1", secondsAgo: 30, kind: .sessionActivity, title: "s1", agent: "claude"), + event(id: "codex-1", secondsAgo: 20, kind: .sessionActivity, title: "s2", agent: "codex"), + event(id: "ws", secondsAgo: 10, kind: .workspaceCreated, title: "ws"), + event(id: "claude-2", secondsAgo: 5, kind: .sessionActivity, title: "s3", agent: "claude"), + ] + let groups = grouper.groups(events: events, by: .agent, now: Self.now) + + #expect(groups.map(\.id) == ["agent:claude", "agent:codex", VaultHistoryGrouper.otherGroupID]) + #expect(groups[0].events.map(\.id) == ["claude-2", "claude-1"]) + #expect(groups[2].events.map(\.id) == ["ws"]) + } + + @Test func kindGroupingUsesEventKindOrderedByRecency() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let events = [ + event(id: "c1", secondsAgo: 300, kind: .workspaceCreated), + event(id: "x1", secondsAgo: 200, kind: .workspaceClosed), + event(id: "c2", secondsAgo: 100, kind: .workspaceCreated), + ] + let groups = grouper.groups(events: events, by: .kind, now: Self.now) + + #expect(groups.map(\.id) == ["kind:workspaceCreated", "kind:workspaceClosed"]) + #expect(groups[0].events.map(\.id) == ["c2", "c1"]) + } + + // MARK: - Session projection + + @Test func sessionProjectionProducesStableDerivedEvents() throws { + let entry = SessionEntry( + id: "abc123", + agent: .claude, + sessionId: "abc123", + title: "Fix the tests", + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_700_000_000), + fileURL: nil, + specifics: .claude(model: nil, permissionMode: nil, configDirectoryForResume: nil) + ) + let projection = VaultHistorySessionEventProjection() + + let first = projection.events(from: [entry]) + let second = projection.events(from: [entry]) + #expect(first == second) + + let event = try #require(first.first) + #expect(event.kind == .sessionActivity) + #expect(event.timestamp == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(event.title == "Fix the tests") + #expect(event.subject.agent == "claude") + #expect(event.subject.sessionId == "abc123") + #expect(event.subject.directory == "/tmp/repo") + } +} From bdf0a43fa499463b7d8655399a733e641bb4b703 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 28 Jul 2026 23:03:25 -0700 Subject: [PATCH 02/76] Fix warning-budget drift inherited from main in AgentSessionRetryCoordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main's #9024 landed `case .awaitingLaunch, .running where ...` — the `where` clause only ever applied to `.running`, so `.awaitingLaunch` always matched the first case and its listing in the fallback case was dead, producing two Swift warnings with no budget rows. CI is dispatch-only, so the drift surfaced on this branch's validation run. Split the case to state the compiled behavior explicitly (awaitingLaunch always adopts the binding; running adopts only for the same managed session) and drop the dead pattern. No behavior change. Co-Authored-By: Claude Fable 5 --- Sources/AgentSessionRetryCoordinator.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/AgentSessionRetryCoordinator.swift b/Sources/AgentSessionRetryCoordinator.swift index a05c63070632..024e40d5353a 100644 --- a/Sources/AgentSessionRetryCoordinator.swift +++ b/Sources/AgentSessionRetryCoordinator.swift @@ -108,11 +108,15 @@ final class AgentSessionRetryCoordinator { if let state = statesByPanelId[panelId] { switch state.phase { - case .awaitingLaunch, .running where state.binding.isSameManagedSession(as: binding): + case .awaitingLaunch: var updatedState = state updatedState.binding = binding statesByPanelId[panelId] = updatedState - case .waiting, .ready, .awaitingLaunch, .running, .exhausted: + case .running where state.binding.isSameManagedSession(as: binding): + var updatedState = state + updatedState.binding = binding + statesByPanelId[panelId] = updatedState + case .waiting, .ready, .running, .exhausted: // A different managed session, or a manual resume while waiting // or exhausted, owns the pane now and starts with a fresh budget. clearRecovery(panelId: panelId) From ae2d08f1a01ce1b41caeb1b9587a044181a5755c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 29 Jul 2026 00:26:34 -0700 Subject: [PATCH 03/76] Address review: serialize history appends before publishing revision; ja phrasing - VaultHistoryEventLog.record now chains appends on a stored task and bumps `revision` only after the event reached the store, so a refresh triggered by the bump always sees the event it announces (no fire-and-forget detached task). - Japanese labels for workspaceCreated/workspaceRenamed use completed-event phrasing to match the other timeline kinds. Co-Authored-By: Claude Fable 5 --- Resources/Localizable.xcstrings | 4 ++-- Sources/VaultHistoryEventLog.swift | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 43ea2ac6a124..fc34b40c71c3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -258656,7 +258656,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペースを作成" + "value": "ワークスペースを作成しました" } } } @@ -258673,7 +258673,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペース名を変更" + "value": "ワークスペース名を変更しました" } } } diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index 3a6178f8f05e..cc38899ddaa6 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -16,10 +16,15 @@ final class VaultHistoryEventLog { ) /// Monotonic count of recorded events this launch; observed by the - /// History view to refresh after new activity. + /// History view to refresh after new activity. Bumped only after the + /// event reached the store, so a refresh triggered by the bump always + /// sees the event it announces. private(set) var revision: UInt64 = 0 private let store: VaultHistoryEventStore + /// Tail of the serialized append chain; each record awaits its + /// predecessor so events persist in call order. + private var pendingRecordTask: Task? init(store: VaultHistoryEventStore) { self.store = store @@ -33,10 +38,12 @@ final class VaultHistoryEventLog { } func record(_ event: VaultHistoryEvent) { - revision &+= 1 let store = store - Task.detached(priority: .utility) { + let previous = pendingRecordTask + pendingRecordTask = Task(priority: .utility) { [weak self] in + await previous?.value await store.append(event) + self?.revision &+= 1 } } From 55530294e65c5d4ebc92e64d5cc95e1003e5a218 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 29 Jul 2026 01:21:27 -0700 Subject: [PATCH 04/76] Address review: single sort per refresh; drop redundant init - VaultHistoryTimelineModel sorts merged events only when the timeline cap forces truncation (using the grouper's comparator); ordering otherwise stays the grouper's single pass. - Remove the synthesized-equivalent init() on VaultHistorySessionEventProjection. Co-Authored-By: Claude Fable 5 --- Sources/VaultHistorySessionEvents.swift | 2 -- Sources/VaultHistoryTimelineModel.swift | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift index e3da581837c2..4f945f49a57e 100644 --- a/Sources/VaultHistorySessionEvents.swift +++ b/Sources/VaultHistorySessionEvents.swift @@ -6,8 +6,6 @@ import Foundation /// on-disk stores, so the timeline derives them instead of double-writing: /// whatever the session index covers automatically appears in History. struct VaultHistorySessionEventProjection: Sendable { - init() {} - func events(from entries: [SessionEntry]) -> [VaultHistoryEvent] { entries.map { entry in VaultHistoryEvent( diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 970c55e3e630..67c99fed44ad 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -62,8 +62,14 @@ final class VaultHistoryTimelineModel { guard !Task.isCancelled, let self else { return } var merged = recorded merged.append(contentsOf: self.projection.events(from: sessionEntries)) - merged.sort { $0.timestamp > $1.timestamp } + // Ordering is the grouper's job; sort here only when the cap + // forces dropping the oldest events, so the common path pays + // for a single sort per refresh. if merged.count > Self.maxTimelineEvents { + merged.sort { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.id > $1.id + } merged.removeLast(merged.count - Self.maxTimelineEvents) } self.mergedEvents = merged From adf94782b3d1397eb8425bfd6d3e2409a8045bb3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:29:13 -0700 Subject: [PATCH 05/76] Extract Vault History domain and harden lifecycle recording --- .github/workflows/ci.yml | 1 + Packages/macOS/CmuxVaultHistory/Package.swift | 35 +++ Packages/macOS/CmuxVaultHistory/README.md | 25 ++ .../VaultHistoryDateBucket.swift | 53 +++++ .../CmuxVaultHistory/VaultHistoryEvent.swift | 60 +++++ .../VaultHistoryEventKind.swift | 17 ++ .../VaultHistoryEventStore.swift | 170 ++++++++++++++ .../VaultHistoryEventStoring.swift | 16 ++ .../CmuxVaultHistory/VaultHistoryGroup.swift | 30 +++ .../VaultHistoryGroupIdentity.swift | 35 +++ .../VaultHistoryGroupKey.swift | 18 ++ .../VaultHistoryGrouper.swift | 102 +++++++++ .../VaultHistoryRetentionPolicy.swift | 30 +++ .../VaultHistorySubject.swift | 37 +++ .../VaultHistoryEventStoreTests.swift | 81 ++++--- .../VaultHistoryGrouperTests.swift | 142 ++++++++++++ Sources/AppDelegate.swift | 43 +++- Sources/RightSidebarPanelView.swift | 12 +- Sources/RightSidebarToolPanel.swift | 22 +- Sources/TabManager+DetachedWorkspace.swift | 1 + Sources/TabManager.swift | 28 ++- .../VaultHistoryDateBucket+Presentation.swift | 19 ++ Sources/VaultHistoryEvent.swift | 113 --------- .../VaultHistoryEventKind+Presentation.swift | 32 +++ Sources/VaultHistoryEventLog.swift | 72 ++---- Sources/VaultHistoryEventRow.swift | 104 +++++++++ Sources/VaultHistoryEventStore.swift | 146 ------------ Sources/VaultHistoryGroup+Presentation.swift | 32 +++ Sources/VaultHistoryGroupHeader.swift | 25 ++ .../VaultHistoryGroupKey+Presentation.swift | 29 +++ Sources/VaultHistoryGrouping.swift | 214 ------------------ Sources/VaultHistoryLifecycleRecording.swift | 31 ++- Sources/VaultHistoryRecordingPhase.swift | 13 ++ Sources/VaultHistoryRetentionPolicy.swift | 27 --- Sources/VaultHistorySessionEvents.swift | 1 + Sources/VaultHistoryStoreLocation.swift | 28 +++ Sources/VaultHistoryTimelineModel.swift | 8 +- Sources/VaultHistoryView.swift | 177 +++------------ ...VaultHistoryWorkspaceCreationContext.swift | 17 ++ Sources/VaultPaneTab.swift | 25 ++ Sources/VaultPaneTabButton.swift | 33 +++ Sources/VaultPaneView.swift | 58 +---- Sources/cmuxApp.swift | 25 +- cmux.xcodeproj/project.pbxproj | 94 ++++++-- cmux.xcworkspace/contents.xcworkspacedata | 3 + cmuxTests/VaultHistoryEventLogTests.swift | 95 ++++++++ cmuxTests/VaultHistoryGroupingTests.swift | 198 ---------------- ...ltHistorySessionEventProjectionTests.swift | 43 ++++ 48 files changed, 1563 insertions(+), 1057 deletions(-) create mode 100644 Packages/macOS/CmuxVaultHistory/Package.swift create mode 100644 Packages/macOS/CmuxVaultHistory/README.md create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryDateBucket.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventKind.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroup.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupIdentity.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupKey.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGrouper.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift create mode 100644 Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistorySubject.swift rename {cmuxTests => Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests}/VaultHistoryEventStoreTests.swift (65%) create mode 100644 Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift create mode 100644 Sources/VaultHistoryDateBucket+Presentation.swift delete mode 100644 Sources/VaultHistoryEvent.swift create mode 100644 Sources/VaultHistoryEventKind+Presentation.swift create mode 100644 Sources/VaultHistoryEventRow.swift delete mode 100644 Sources/VaultHistoryEventStore.swift create mode 100644 Sources/VaultHistoryGroup+Presentation.swift create mode 100644 Sources/VaultHistoryGroupHeader.swift create mode 100644 Sources/VaultHistoryGroupKey+Presentation.swift delete mode 100644 Sources/VaultHistoryGrouping.swift create mode 100644 Sources/VaultHistoryRecordingPhase.swift delete mode 100644 Sources/VaultHistoryRetentionPolicy.swift create mode 100644 Sources/VaultHistoryStoreLocation.swift create mode 100644 Sources/VaultHistoryWorkspaceCreationContext.swift create mode 100644 Sources/VaultPaneTab.swift create mode 100644 Sources/VaultPaneTabButton.swift create mode 100644 cmuxTests/VaultHistoryEventLogTests.swift delete mode 100644 cmuxTests/VaultHistoryGroupingTests.swift create mode 100644 cmuxTests/VaultHistorySessionEventProjectionTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1edb0bdb4761..f2c4feb46253 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1370,6 +1370,7 @@ jobs: CmuxTerminal CmuxTerminalCore CmuxUpdater + CmuxVaultHistory CMUXAgentLaunch ) # SwiftPM emits an error-severity diagnostic while planning the diff --git a/Packages/macOS/CmuxVaultHistory/Package.swift b/Packages/macOS/CmuxVaultHistory/Package.swift new file mode 100644 index 000000000000..e40699e0bbc3 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxVaultHistory", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxVaultHistory", + targets: ["CmuxVaultHistory"] + ), + ], + targets: [ + .target( + name: "CmuxVaultHistory", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxVaultHistoryTests", + dependencies: ["CmuxVaultHistory"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxVaultHistory/README.md b/Packages/macOS/CmuxVaultHistory/README.md new file mode 100644 index 000000000000..6c071092d6f6 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/README.md @@ -0,0 +1,25 @@ +# CmuxVaultHistory + +`CmuxVaultHistory` owns the locale-independent values, bounded JSONL +persistence, and deterministic grouping used by the macOS History timeline. +The app target owns lifecycle coordination and localized presentation. + +Construct a repository with an explicit file URL. Passing `nil` gives tests an +in-memory repository with the same retention behavior: + +```swift +let store = VaultHistoryEventStore(fileURL: nil) +let accepted = await store.append(event) +let events = await store.recentEvents() +``` + +Grouping is a pure operation. Inject `now` at the call site and inspect group +identities without loading AppKit or SwiftUI: + +```swift +let groups = VaultHistoryGrouper(calendar: calendar).groups( + events: events, + by: .date, + now: now +) +``` diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryDateBucket.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryDateBucket.swift new file mode 100644 index 000000000000..b227bb08a3fd --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryDateBucket.swift @@ -0,0 +1,53 @@ +public import Foundation + +/// Browser-history-style date buckets ordered from newest to oldest. +public enum VaultHistoryDateBucket: Int, CaseIterable, Identifiable, Sendable { + /// Events in the rolling 24-hour window or on the current calendar day. + case last24Hours + /// Events on the previous calendar day that are outside the rolling window. + case yesterday + /// Older events in the current calendar week. + case thisWeek + /// Older events in the current calendar month. + case thisMonth + /// Events preceding the current calendar month. + case older + + /// Stable ordinal identity used by grouped views. + public var id: Int { rawValue } + + /// Classifies a timestamp relative to an injected clock and calendar. + /// + /// - Parameters: + /// - date: Event timestamp to classify. + /// - now: Reference time used for the rolling 24-hour window. + /// - calendar: Calendar used for day, week, and month boundaries. + /// - Returns: The single date bucket containing `date`. + public static func bucket( + for date: Date, + now: Date, + calendar: Calendar + ) -> VaultHistoryDateBucket { + if date >= now.addingTimeInterval(-24 * 60 * 60) + || calendar.isDate(date, inSameDayAs: now) { + return .last24Hours + } + if isYesterday(date, now: now, calendar: calendar) { + return .yesterday + } + if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { + return .thisWeek + } + if calendar.isDate(date, equalTo: now, toGranularity: .month) { + return .thisMonth + } + return .older + } + + private static func isYesterday(_ date: Date, now: Date, calendar: Calendar) -> Bool { + guard let yesterday = calendar.date(byAdding: .day, value: -1, to: now) else { + return false + } + return calendar.isDate(date, inSameDayAs: yesterday) + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift new file mode 100644 index 000000000000..d05557898e55 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift @@ -0,0 +1,60 @@ +public import Foundation + +/// One locale-independent entry in the unified History timeline. +public struct VaultHistoryEvent: Identifiable, Hashable, Codable, Sendable { + /// Stable event identity. + public let id: String + /// Time at which the activity occurred. + public let timestamp: Date + /// Category of activity represented by this event. + public let kind: VaultHistoryEventKind + /// Subject title captured at event time. + public let title: String + /// Previous title for a workspace rename event. + public let previousTitle: String? + /// Number of workspaces represented by a window event. + public let workspaceCount: Int? + /// Identity and context of the event subject. + public let subject: VaultHistorySubject + + /// Creates a timeline event from a complete immutable snapshot. + /// + /// - Parameters: + /// - id: Stable identity. Recorded events default to a fresh UUID string. + /// - timestamp: Time at which the activity occurred. + /// - kind: Category of activity. + /// - title: Subject title captured at event time. + /// - previousTitle: Previous title for a rename event. + /// - workspaceCount: Number of workspaces represented by a window event. + /// - subject: Identity and context of the event subject. + public init( + id: String = UUID().uuidString, + timestamp: Date, + kind: VaultHistoryEventKind, + title: String, + previousTitle: String? = nil, + workspaceCount: Int? = nil, + subject: VaultHistorySubject = VaultHistorySubject() + ) { + self.id = id + self.timestamp = timestamp + self.kind = kind + self.title = title + self.previousTitle = previousTitle + self.workspaceCount = workspaceCount + self.subject = subject + } + + /// Orders events newest first and uses the stable identifier as a tie-breaker. + /// + /// - Parameters: + /// - lhs: First event to compare. + /// - rhs: Second event to compare. + /// - Returns: `true` when `lhs` should appear before `rhs`. + public static func newestFirst(_ lhs: Self, _ rhs: Self) -> Bool { + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp > rhs.timestamp + } + return lhs.id > rhs.id + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventKind.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventKind.swift new file mode 100644 index 000000000000..d773debec80c --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventKind.swift @@ -0,0 +1,17 @@ +import Foundation + +/// A locale-independent category for one History timeline event. +public enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { + /// A workspace was created by the user or an external command. + case workspaceCreated + /// A user-assigned workspace title changed. + case workspaceRenamed + /// A workspace was explicitly closed. + case workspaceClosed + /// A main cmux window was opened. + case windowOpened + /// A main cmux window was explicitly closed. + case windowClosed + /// An agent session was projected from the durable session index. + case sessionActivity +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift new file mode 100644 index 000000000000..b56915d39241 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift @@ -0,0 +1,170 @@ +public import Foundation + +/// Actor-isolated, retention-bounded JSONL persistence for recorded History events. +public actor VaultHistoryEventStore: VaultHistoryEventStoring { + private let fileURL: URL? + private let retention: VaultHistoryRetentionPolicy + private let fileManager: FileManager + private var didLoad = false + private var events: [VaultHistoryEvent] = [] + private var fileBytes = 0 + + /// Creates a store backed by one JSONL file or by bounded memory when `fileURL` is `nil`. + /// + /// - Parameters: + /// - fileURL: JSONL file location, or `nil` for an in-memory store. + /// - retention: Memory, file, and load bounds. + /// - fileManager: Filesystem dependency used for creation and inspection. + public init( + fileURL: URL?, + retention: VaultHistoryRetentionPolicy = .default, + fileManager: FileManager = .default + ) { + self.fileURL = fileURL + self.retention = retention + self.fileManager = fileManager + } + + /// Persists one event and adds it to the in-memory snapshot only after acceptance. + /// + /// - Parameter event: Immutable event to append. + /// - Returns: `true` when the event was accepted by memory-only storage or persisted. + public func append(_ event: VaultHistoryEvent) async -> Bool { + loadIfNeeded() + if let fileURL { + guard let line = encodedLine(for: event), appendLine(line, to: fileURL) else { + return false + } + fileBytes += line.count + } + + events.append(event) + trimMemoryIfNeeded() + if let fileURL, fileBytes > retention.maxFileBytes { + compact(to: fileURL) + } + return true + } + + /// Returns the newest recorded events in deterministic order. + /// + /// - Parameter limit: Maximum number of events returned. + /// - Returns: Events ordered by timestamp and stable identifier, newest first. + public func recentEvents(limit: Int = .max) async -> [VaultHistoryEvent] { + loadIfNeeded() + let sortedEvents = events.sorted(by: VaultHistoryEvent.newestFirst) + guard limit < sortedEvents.count else { + return sortedEvents + } + return Array(sortedEvents.prefix(max(0, limit))) + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return decoder + } + + private func loadIfNeeded() { + guard !didLoad else { return } + didLoad = true + guard let fileURL, + let handle = try? FileHandle(forReadingFrom: fileURL) else { + return + } + defer { try? handle.close() } + + let fileSize = (try? handle.seekToEnd()).map(Int.init) ?? 0 + fileBytes = fileSize + guard fileSize > 0 else { return } + + let readBytes = min(fileSize, retention.maxLoadBytes) + let startOffset = UInt64(fileSize - readBytes) + try? handle.seek(toOffset: startOffset) + guard var data = try? handle.read(upToCount: readBytes) else { return } + + if startOffset > 0, let firstNewline = data.firstIndex(of: 0x0a) { + data = data[data.index(after: firstNewline)...] + } + + let decoder = Self.makeDecoder() + var loadedEvents: [VaultHistoryEvent] = [] + var lineStart = data.startIndex + while lineStart < data.endIndex { + let lineEnd = data[lineStart...].firstIndex(of: 0x0a) ?? data.endIndex + let line = data[lineStart.. retention.maxStoredEvents { + loadedEvents.removeFirst(loadedEvents.count - retention.maxStoredEvents) + } + events = loadedEvents + } + + private func encodedLine(for event: VaultHistoryEvent) -> Data? { + guard var data = try? Self.makeEncoder().encode(event) else { + return nil + } + data.append(0x0a) + return data + } + + private func appendLine(_ line: Data, to fileURL: URL) -> Bool { + if !fileManager.fileExists(atPath: fileURL.path) { + do { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try line.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + guard let handle = try? FileHandle(forWritingTo: fileURL) else { + return false + } + defer { try? handle.close() } + do { + _ = try handle.seekToEnd() + try handle.write(contentsOf: line) + return true + } catch { + return false + } + } + + private func compact(to fileURL: URL) { + let encoder = Self.makeEncoder() + var data = Data() + for event in events { + guard let encoded = try? encoder.encode(event) else { continue } + data.append(encoded) + data.append(0x0a) + } + guard (try? data.write(to: fileURL, options: .atomic)) != nil else { + return + } + fileBytes = data.count + } + + private func trimMemoryIfNeeded() { + guard events.count > retention.maxStoredEvents else { return } + events.removeFirst(events.count - retention.maxStoredEvents) + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift new file mode 100644 index 000000000000..fef2818cce94 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Persistence boundary consumed by the app-owned History coordinator. +public protocol VaultHistoryEventStoring: Sendable { + /// Persists one event before making it visible to readers. + /// + /// - Parameter event: Immutable event to append. + /// - Returns: `true` when the event was accepted and is now readable. + func append(_ event: VaultHistoryEvent) async -> Bool + + /// Returns the newest persisted events in deterministic order. + /// + /// - Parameter limit: Maximum number of events returned. + /// - Returns: Events ordered by timestamp and stable identifier, newest first. + func recentEvents(limit: Int) async -> [VaultHistoryEvent] +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroup.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroup.swift new file mode 100644 index 000000000000..829398925aaa --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroup.swift @@ -0,0 +1,30 @@ +import Foundation + +/// One locale-independent section produced by ``VaultHistoryGrouper``. +public struct VaultHistoryGroup: Identifiable, Equatable, Sendable { + /// Grouping dimension that produced this section. + public let key: VaultHistoryGroupKey + /// Concrete subject identity represented by this section. + public let identity: VaultHistoryGroupIdentity + /// Events in deterministic newest-first order. + public let events: [VaultHistoryEvent] + + /// Stable string identity derived from ``identity``. + public var id: String { identity.id } + + /// Creates a grouped section from a grouping key, identity, and ordered events. + /// + /// - Parameters: + /// - key: Grouping dimension that produced the section. + /// - identity: Concrete identity represented by the section. + /// - events: Events in deterministic newest-first order. + public init( + key: VaultHistoryGroupKey, + identity: VaultHistoryGroupIdentity, + events: [VaultHistoryEvent] + ) { + self.key = key + self.identity = identity + self.events = events + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupIdentity.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupIdentity.swift new file mode 100644 index 000000000000..4a3ac6b83127 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupIdentity.swift @@ -0,0 +1,35 @@ +public import Foundation + +/// Locale-independent identity of one grouped History section. +public enum VaultHistoryGroupIdentity: Hashable, Sendable { + /// A rolling or calendar date bucket. + case date(VaultHistoryDateBucket) + /// A concrete workspace identity. + case workspace(UUID) + /// A concrete window identity. + case window(UUID) + /// A concrete locale-independent agent identity. + case agent(String) + /// A concrete event category. + case kind(VaultHistoryEventKind) + /// Events missing the identity required by the selected group key. + case other + + /// Stable string identity used by grouped views and tests. + public var id: String { + switch self { + case let .date(bucket): + "date:\(bucket.rawValue)" + case let .workspace(id): + "workspace:\(id.uuidString)" + case let .window(id): + "window:\(id.uuidString)" + case let .agent(id): + "agent:\(id)" + case let .kind(kind): + "kind:\(kind.rawValue)" + case .other: + "other" + } + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupKey.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupKey.swift new file mode 100644 index 000000000000..364f93822548 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGroupKey.swift @@ -0,0 +1,18 @@ +import Foundation + +/// The dimension used to partition a History timeline. +public enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable, Sendable { + /// Browser-history-style rolling and calendar date buckets. + case date + /// Runtime workspace identity. + case workspace + /// Runtime window identity. + case window + /// Locale-independent agent identity. + case agent + /// History event category. + case kind + + /// Stable raw-value identity used by SwiftUI controls. + public var id: String { rawValue } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGrouper.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGrouper.swift new file mode 100644 index 000000000000..e8ab71f78644 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryGrouper.swift @@ -0,0 +1,102 @@ +public import Foundation + +/// Deterministic grouping over a flat list of History events. +public struct VaultHistoryGrouper: Sendable { + /// Stable identifier assigned to events missing the selected subject identity. + public static let otherGroupID = VaultHistoryGroupIdentity.other.id + + private let calendar: Calendar + + /// Creates a grouper with an explicit calendar for deterministic boundaries. + /// + /// - Parameter calendar: Calendar used by date grouping. + public init(calendar: Calendar = .current) { + self.calendar = calendar + } + + /// Groups events by one dimension after a single deterministic ordering pass. + /// + /// - Parameters: + /// - events: Flat recorded and derived event list. + /// - key: Dimension used to partition the list. + /// - now: Reference time used for date buckets. + /// - Returns: Ordered groups whose events are newest first. + public func groups( + events: [VaultHistoryEvent], + by key: VaultHistoryGroupKey, + now: Date + ) -> [VaultHistoryGroup] { + let sortedEvents = events.sorted(by: VaultHistoryEvent.newestFirst) + switch key { + case .date: + return dateGroups(events: sortedEvents, now: now) + case .workspace: + return identityGroups(events: sortedEvents, key: key) { event in + event.subject.workspaceId.map(VaultHistoryGroupIdentity.workspace) ?? .other + } + case .window: + return identityGroups(events: sortedEvents, key: key) { event in + event.subject.windowId.map(VaultHistoryGroupIdentity.window) ?? .other + } + case .agent: + return identityGroups(events: sortedEvents, key: key) { event in + guard let agent = event.subject.agent?.trimmingCharacters(in: .whitespacesAndNewlines), + !agent.isEmpty else { + return .other + } + return .agent(agent) + } + case .kind: + return identityGroups(events: sortedEvents, key: key) { event in + .kind(event.kind) + } + } + } + + private func dateGroups( + events: [VaultHistoryEvent], + now: Date + ) -> [VaultHistoryGroup] { + var eventsByBucket: [VaultHistoryDateBucket: [VaultHistoryEvent]] = [:] + for event in events { + let bucket = VaultHistoryDateBucket.bucket( + for: event.timestamp, + now: now, + calendar: calendar + ) + eventsByBucket[bucket, default: []].append(event) + } + return VaultHistoryDateBucket.allCases.compactMap { bucket in + guard let events = eventsByBucket[bucket], !events.isEmpty else { + return nil + } + return VaultHistoryGroup(key: .date, identity: .date(bucket), events: events) + } + } + + private func identityGroups( + events: [VaultHistoryEvent], + key: VaultHistoryGroupKey, + identityForEvent: (VaultHistoryEvent) -> VaultHistoryGroupIdentity + ) -> [VaultHistoryGroup] { + var order: [VaultHistoryGroupIdentity] = [] + var eventsByIdentity: [VaultHistoryGroupIdentity: [VaultHistoryEvent]] = [:] + for event in events { + let identity = identityForEvent(event) + if eventsByIdentity[identity] == nil { + order.append(identity) + } + eventsByIdentity[identity, default: []].append(event) + } + if let otherIndex = order.firstIndex(of: .other), otherIndex != order.count - 1 { + order.remove(at: otherIndex) + order.append(.other) + } + return order.compactMap { identity in + guard let events = eventsByIdentity[identity], !events.isEmpty else { + return nil + } + return VaultHistoryGroup(key: key, identity: identity, events: events) + } + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift new file mode 100644 index 000000000000..bf1f264e9881 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Memory and file-size bounds for the persisted History event log. +public struct VaultHistoryRetentionPolicy: Sendable { + /// Maximum number of recorded events retained in memory and after compaction. + public let maxStoredEvents: Int + /// File size that triggers a compacting rewrite after an append. + public let maxFileBytes: Int + /// Maximum number of tail bytes read while loading an existing log. + public let maxLoadBytes: Int + + /// Production retention limits for the macOS History timeline. + public static let `default` = VaultHistoryRetentionPolicy( + maxStoredEvents: 2_000, + maxFileBytes: 4 * 1_024 * 1_024, + maxLoadBytes: 2 * 1_024 * 1_024 + ) + + /// Creates a policy while enforcing nonzero operational minima. + /// + /// - Parameters: + /// - maxStoredEvents: Maximum number of retained events. + /// - maxFileBytes: File size that triggers compaction. + /// - maxLoadBytes: Tail-read byte budget used during initial load. + public init(maxStoredEvents: Int, maxFileBytes: Int, maxLoadBytes: Int) { + self.maxStoredEvents = max(1, maxStoredEvents) + self.maxFileBytes = max(1_024, maxFileBytes) + self.maxLoadBytes = max(1_024, maxLoadBytes) + } +} diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistorySubject.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistorySubject.swift new file mode 100644 index 000000000000..5ab4ea38a3e8 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistorySubject.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// Stable and contextual identity for the subject of a History event. +public struct VaultHistorySubject: Hashable, Codable, Sendable { + /// Runtime identity of the workspace associated with the event. + public let workspaceId: UUID? + /// Runtime identity of the window associated with the event. + public let windowId: UUID? + /// Native agent-session identifier for a session event. + public let sessionId: String? + /// Locale-independent agent identifier for a session event. + public let agent: String? + /// Working directory associated with the subject, when known. + public let directory: String? + + /// Creates a subject from the identities known at event time. + /// + /// - Parameters: + /// - workspaceId: Runtime workspace identity, if the event belongs to a workspace. + /// - windowId: Runtime window identity, if the event belongs to a window. + /// - sessionId: Native agent-session identity for a derived session event. + /// - agent: Locale-independent agent identifier for a derived session event. + /// - directory: Working directory associated with the event, when known. + public init( + workspaceId: UUID? = nil, + windowId: UUID? = nil, + sessionId: String? = nil, + agent: String? = nil, + directory: String? = nil + ) { + self.workspaceId = workspaceId + self.windowId = windowId + self.sessionId = sessionId + self.agent = agent + self.directory = directory + } +} diff --git a/cmuxTests/VaultHistoryEventStoreTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift similarity index 65% rename from cmuxTests/VaultHistoryEventStoreTests.swift rename to Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift index 95be170d8c7e..6b4c74d77cf9 100644 --- a/cmuxTests/VaultHistoryEventStoreTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift @@ -1,18 +1,17 @@ import Foundation import Testing -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif +@testable import CmuxVaultHistory @Suite struct VaultHistoryEventStoreTests { private func makeTempFileURL() throws -> URL { let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("vault-history-tests-\(UUID().uuidString)", isDirectory: true) + .appending( + path: "vault-history-tests-\(UUID().uuidString)", + directoryHint: .isDirectory + ) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory.appendingPathComponent("history.jsonl") + return directory.appending(path: "history.jsonl", directoryHint: .notDirectory) } private func event( @@ -52,10 +51,9 @@ import Testing workspaceCount: 3, subject: VaultHistorySubject(windowId: UUID()) ) - await store.append(created) - await store.append(closed) + #expect(await store.append(created)) + #expect(await store.append(closed)) - // A fresh store instance must see the same events after "restart". let reloaded = VaultHistoryEventStore(fileURL: fileURL) let events = await reloaded.recentEvents() #expect(events.count == 2) @@ -67,77 +65,79 @@ import Testing #expect(events.last?.kind == .workspaceRenamed) } - @Test func recentEventsReturnsNewestFirstAndHonorsLimit() async throws { + @Test func recentEventsReturnsNewestFirstWithStableTieBreakAndLimit() async { let store = VaultHistoryEventStore(fileURL: nil) - await store.append(event(id: "old", secondsAgo: 300)) - await store.append(event(id: "new", secondsAgo: 10)) - await store.append(event(id: "mid", secondsAgo: 100)) + #expect(await store.append(event(id: "old", secondsAgo: 300))) + #expect(await store.append(event(id: "tie-a", secondsAgo: 10))) + #expect(await store.append(event(id: "tie-z", secondsAgo: 10))) + #expect(await store.append(event(id: "mid", secondsAgo: 100))) let all = await store.recentEvents() - #expect(all.map(\.id) == ["new", "mid", "old"]) + #expect(all.map(\.id) == ["tie-z", "tie-a", "mid", "old"]) + #expect(await store.recentEvents(limit: 2).map(\.id) == ["tie-z", "tie-a"]) + } - let limited = await store.recentEvents(limit: 2) - #expect(limited.map(\.id) == ["new", "mid"]) + @Test func failedPersistenceDoesNotEnterTheReadableSnapshot() async { + let unwritableURL = URL(fileURLWithPath: "/dev/null/vault-history.jsonl") + let store = VaultHistoryEventStore(fileURL: unwritableURL) + + #expect(await store.append(event(id: "rejected", secondsAgo: 0)) == false) + #expect(await store.recentEvents().isEmpty) } @Test func retentionCompactsFileAndKeepsNewestEvents() async throws { let fileURL = try makeTempFileURL() defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - // Tiny budgets: every append overflows maxFileBytes, so the file is - // repeatedly compacted down to the newest maxStoredEvents events. let retention = VaultHistoryRetentionPolicy( maxStoredEvents: 5, - maxFileBytes: 1024, - maxLoadBytes: 64 * 1024 + maxFileBytes: 1_024, + maxLoadBytes: 64 * 1_024 ) let store = VaultHistoryEventStore(fileURL: fileURL, retention: retention) for index in 0..<50 { - await store.append(event(id: "e\(index)", secondsAgo: TimeInterval(1000 - index))) + #expect(await store.append(event( + id: "e\(index)", + secondsAgo: TimeInterval(1_000 - index) + ))) } let events = await store.recentEvents() - #expect(events.count == 5) #expect(events.map(\.id) == ["e49", "e48", "e47", "e46", "e45"]) let fileSize = try #require( try FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int ) - #expect(fileSize <= 1024 + 512) + #expect(fileSize <= 1_536) - // Restarting on the compacted file sees only the retained tail. let reloaded = VaultHistoryEventStore(fileURL: fileURL, retention: retention) - let reloadedEvents = await reloaded.recentEvents() - #expect(reloadedEvents.map(\.id) == ["e49", "e48", "e47", "e46", "e45"]) + #expect(await reloaded.recentEvents().map(\.id) == ["e49", "e48", "e47", "e46", "e45"]) } @Test func loadIsBoundedAndSkipsPartialLeadingLine() async throws { let fileURL = try makeTempFileURL() defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } - // Write with generous limits, reload with a small load budget: only - // the newest events that fit inside maxLoadBytes come back, and the - // partial leading line inside the tail window is skipped cleanly. let writer = VaultHistoryEventStore(fileURL: fileURL) for index in 0..<200 { - await writer.append(event(id: "e\(index)", secondsAgo: TimeInterval(10_000 - index))) + #expect(await writer.append(event( + id: "e\(index)", + secondsAgo: TimeInterval(10_000 - index) + ))) } let reader = VaultHistoryEventStore( fileURL: fileURL, retention: VaultHistoryRetentionPolicy( - maxStoredEvents: 1000, - maxFileBytes: 64 * 1024 * 1024, - maxLoadBytes: 4096 + maxStoredEvents: 1_000, + maxFileBytes: 64 * 1_024 * 1_024, + maxLoadBytes: 4_096 ) ) let events = await reader.recentEvents() #expect(!events.isEmpty) #expect(events.count < 200) - // The newest event is always inside the tail window. #expect(events.first?.id == "e199") - // Every decoded event is intact (a torn first line would decode to - // nothing rather than a corrupt event). #expect(events.allSatisfy { $0.title == "ws" }) } @@ -146,15 +146,14 @@ import Testing defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } let writer = VaultHistoryEventStore(fileURL: fileURL) - await writer.append(event(id: "good", secondsAgo: 60)) + #expect(await writer.append(event(id: "good", secondsAgo: 60))) let handle = try FileHandle(forWritingTo: fileURL) - try handle.seekToEnd() + _ = try handle.seekToEnd() try handle.write(contentsOf: Data("not json at all\n".utf8)) try handle.close() let reloaded = VaultHistoryEventStore(fileURL: fileURL) - let events = await reloaded.recentEvents() - #expect(events.map(\.id) == ["good"]) + #expect(await reloaded.recentEvents().map(\.id) == ["good"]) } } diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift new file mode 100644 index 000000000000..3d26fd2e69d7 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing + +@testable import CmuxVaultHistory + +@Suite struct VaultHistoryGrouperTests { + private static func utcCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + calendar.firstWeekday = 2 + return calendar + } + + private static let now = Date(timeIntervalSince1970: 1_781_784_000) + + private func event( + id: String, + secondsAgo: TimeInterval, + kind: VaultHistoryEventKind = .workspaceCreated, + title: String = "", + workspaceId: UUID? = nil, + windowId: UUID? = nil, + agent: String? = nil + ) -> VaultHistoryEvent { + VaultHistoryEvent( + id: id, + timestamp: Self.now.addingTimeInterval(-secondsAgo), + kind: kind, + title: title, + subject: VaultHistorySubject( + workspaceId: workspaceId, + windowId: windowId, + sessionId: agent == nil ? nil : "session-\(id)", + agent: agent + ) + ) + } + + @Test func dateBucketBoundariesAroundLast24Hours() { + let calendar = Self.utcCalendar() + let justInside = Self.now.addingTimeInterval(-24 * 3_600 + 60) + let justOutside = Self.now.addingTimeInterval(-24 * 3_600 - 60) + + #expect(VaultHistoryDateBucket.bucket( + for: justInside, + now: Self.now, + calendar: calendar + ) == .last24Hours) + #expect(VaultHistoryDateBucket.bucket( + for: justOutside, + now: Self.now, + calendar: calendar + ) == .yesterday) + } + + @Test func dateBucketCalendarBoundaries() { + let calendar = Self.utcCalendar() + let sameWeek = Date(timeIntervalSince1970: 1_781_596_800) + let sameMonth = Date(timeIntervalSince1970: 1_780_704_000) + let older = Date(timeIntervalSince1970: 1_779_105_600) + + #expect(VaultHistoryDateBucket.bucket( + for: sameWeek, + now: Self.now, + calendar: calendar + ) == .thisWeek) + #expect(VaultHistoryDateBucket.bucket( + for: sameMonth, + now: Self.now, + calendar: calendar + ) == .thisMonth) + #expect(VaultHistoryDateBucket.bucket( + for: older, + now: Self.now, + calendar: calendar + ) == .older) + } + + @Test func dateGroupingOrdersBucketsAndSkipsEmptyBuckets() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let groups = grouper.groups(events: [ + event(id: "older", secondsAgo: 40 * 24 * 3_600), + event(id: "recent", secondsAgo: 600), + event(id: "yesterday", secondsAgo: 25 * 3_600), + ], by: .date, now: Self.now) + + #expect(groups.map(\.id) == [ + "date:\(VaultHistoryDateBucket.last24Hours.rawValue)", + "date:\(VaultHistoryDateBucket.yesterday.rawValue)", + "date:\(VaultHistoryDateBucket.older.rawValue)", + ]) + #expect(groups.map { $0.events.map(\.id) } == [ + ["recent"], + ["yesterday"], + ["older"], + ]) + } + + @Test func workspaceGroupingClustersByIdentityAndKeepsOtherLast() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let workspaceA = UUID() + let workspaceB = UUID() + let groups = grouper.groups(events: [ + event(id: "a-old", secondsAgo: 500, title: "Old", workspaceId: workspaceA), + event(id: "b-only", secondsAgo: 100, title: "Beta", workspaceId: workspaceB), + event(id: "a-new", secondsAgo: 50, kind: .workspaceRenamed, title: "New", workspaceId: workspaceA), + event(id: "other", secondsAgo: 10, kind: .windowOpened), + ], by: .workspace, now: Self.now) + + #expect(groups.map(\.id) == [ + "workspace:\(workspaceA.uuidString)", + "workspace:\(workspaceB.uuidString)", + VaultHistoryGrouper.otherGroupID, + ]) + #expect(groups[0].events.map(\.id) == ["a-new", "a-old"]) + #expect(groups[2].events.map(\.id) == ["other"]) + } + + @Test func windowAgentAndKindGroupingUseOneGenericPass() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let window = UUID() + let events = [ + event(id: "window", secondsAgo: 30, kind: .windowOpened, windowId: window), + event(id: "codex", secondsAgo: 20, kind: .sessionActivity, agent: "codex"), + event(id: "claude", secondsAgo: 10, kind: .sessionActivity, agent: "claude"), + ] + + #expect(grouper.groups(events: events, by: .window, now: Self.now).map(\.id) == [ + "window:\(window.uuidString)", + VaultHistoryGrouper.otherGroupID, + ]) + #expect(grouper.groups(events: events, by: .agent, now: Self.now).map(\.id) == [ + "agent:claude", + "agent:codex", + VaultHistoryGrouper.otherGroupID, + ]) + #expect(grouper.groups(events: events, by: .kind, now: Self.now).map(\.id) == [ + "kind:sessionActivity", + "kind:windowOpened", + ]) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2dc0ec88cb4a..dff5c6c915a6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -848,6 +848,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserWebViewFirstResponderObserver: NSObjectProtocol? let updateLog = UpdateLogStore() let focusLog = FocusLogStore() + private(set) var vaultHistoryEventLog: VaultHistoryEventLog? /// Process-wide identity of the workspace currently being sidebar-dragged in /// any window. Owned here (the composition root) and injected into every /// window's `SidebarDragState` so cross-window drops resolve a single drag. @@ -1038,7 +1039,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private(set) var startupSessionSnapshot: AppSessionSnapshot? private var didPrepareStartupSessionSnapshot = false var didAttemptStartupSessionRestore = false - var isApplyingSessionRestore = false + var isApplyingSessionRestore = false { + didSet { + guard isApplyingSessionRestore != oldValue else { return } + synchronizeVaultHistoryRecordingPhase() + } + } /// Durable navigation links that arrived before startup restore registered /// their target workspaces. var pendingStartupNavigationURLRequests: [CmuxNavigationURLRequest] = [] @@ -1081,7 +1087,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didScheduleInitialMainWindowBootstrap = false var shouldDeferInitialMainWindowBootstrapForExternalConfirmation = false private var didBootstrapInitialMainWindow = false - var isTerminatingApp = false + var isTerminatingApp = false { + didSet { + guard isTerminatingApp != oldValue else { return } + synchronizeVaultHistoryRecordingPhase() + } + } private var closedWindowHistorySuppressedWindowIds: Set = [] #if DEBUG var closeMainWindowContainingTabIdObserverForTesting: ((UUID, Bool) -> Void)? @@ -2197,7 +2208,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore: TerminalNotificationStore, sidebarState: SidebarState, settingsRuntime: SettingsRuntime, - auth: MacAuthComposition + auth: MacAuthComposition, + vaultHistoryEventLog: VaultHistoryEventLog ) { self.tabManager = tabManager // SwiftUI constructs the initial TabManager before this delegate is @@ -2207,6 +2219,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.notificationStore = notificationStore self.sidebarState = sidebarState self.auth = auth + self.vaultHistoryEventLog = vaultHistoryEventLog + synchronizeVaultHistoryRecordingPhase() VMClient.bootstrap(auth: auth.coordinator) RemotesClient.bootstrap(auth: auth.coordinator) AIAccountsClient.bootstrap(auth: auth.coordinator) @@ -3469,10 +3483,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func synchronizeVaultHistoryRecordingPhase() { + let phase: VaultHistoryRecordingPhase + if isTerminatingApp { + phase = .terminating + } else if isApplyingSessionRestore { + phase = .restoring + } else if didAttemptStartupSessionRestore { + phase = .active + } else { + phase = .launching + } + vaultHistoryEventLog?.transition(to: phase) + } + private func attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: NSWindow) { let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded( primaryWindow: primaryWindow ) + synchronizeVaultHistoryRecordingPhase() if Self.shouldSaveSessionSnapshotAfterMainWindowRegistration( isTerminatingApp: isTerminatingApp, didApplyStartupSessionRestore: didApplyStartupSessionRestore, @@ -8347,6 +8376,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !didAttemptStartupSessionRestore { startupSessionSnapshot = nil didAttemptStartupSessionRestore = true + synchronizeVaultHistoryRecordingPhase() // Explicit open intent cancels restore; deferred links cannot gain targets. flushPendingStartupNavigationURLRequests() } @@ -8944,7 +8974,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pullRequestProbeService: pullRequestProbeService, workspaceCustomizationStore: self.tabManager?.workspaceCustomizationStore ?? WorkspaceCustomizationStore(defaults: .standard), - nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker + nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker, + windowId: windowId, + vaultHistoryEventLog: vaultHistoryEventLog, + initialWorkspaceHistoryContext: sessionWindowSnapshot == nil + ? .semanticCreation + : .restoration ) tabManager.windowId = windowId if let sessionWindowSnapshot { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index d8a377f4b219..46e24cc36190 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -393,7 +393,17 @@ struct RightSidebarPanelView: View { presentation: .find ) case .sessions: - VaultPaneView(store: sessionIndexStore, onResume: onResumeSession) + Group { + if let historyLog = tabManager.vaultHistoryEventLog { + VaultPaneView( + store: sessionIndexStore, + onResume: onResumeSession, + historyLog: historyLog + ) + } else { + SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) + } + } .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) } diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 640773826bd3..5b3cf5081b66 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -284,12 +284,24 @@ struct RightSidebarToolPanelView: View { onContainerChange: panel.attachFileExplorerContainer ) case .sessions: - VaultPaneView( - store: panel.sessionIndexStore, - onResume: { entry in - SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + Group { + if let historyLog = tabManager.vaultHistoryEventLog { + VaultPaneView( + store: panel.sessionIndexStore, + onResume: { entry in + SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + }, + historyLog: historyLog + ) + } else { + SessionIndexView( + store: panel.sessionIndexStore, + onResume: { entry in + SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + } + ) } - ) + } .background( RightSidebarToolFocusAnchor(onViewChange: panel.attachSessionIndexFocusAnchor) .frame(width: 0, height: 0) diff --git a/Sources/TabManager+DetachedWorkspace.swift b/Sources/TabManager+DetachedWorkspace.swift index f3eed51a3377..774fd46d9cda 100644 --- a/Sources/TabManager+DetachedWorkspace.swift +++ b/Sources/TabManager+DetachedWorkspace.swift @@ -91,6 +91,7 @@ extension TabManager { let insertIndex = Self.clampedDetachedWorkspaceInsertIndex(plannedInsertIndex, workspaces: updatedTabs) updatedTabs.insert(newWorkspace, at: insertIndex) tabs = updatedTabs + recordVaultHistoryWorkspaceCreated(newWorkspace) if select { #if DEBUG diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1f0416d622e4..15a6464a632b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -180,8 +180,9 @@ class TabManager: ObservableObject { /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? - /// Stable identifier of the owning macOS window. Used only for opt-in title - /// templates that expose a WM-matchable per-window token. + /// Stable identifier of the owning macOS window. Injected before the first + /// workspace is created when the caller already knows it, then reconciled by + /// `AppDelegate.registerMainWindow()`. var windowId: UUID? // Wave-4 sub-model (TabManager decomposition): the workspace list, the @@ -431,6 +432,7 @@ class TabManager: ObservableObject { private var currentWindowTabBarLeadingInset: CGFloat? private var closeConfirmationInFlight = false let closeTabWarningDefaults: UserDefaults + let vaultHistoryEventLog: VaultHistoryEventLog? var confirmCloseHandler: ((String, String, Bool) -> Bool)? private var agentPIDSweepTimer: DispatchSourceTimer? #if DEBUG @@ -491,7 +493,10 @@ class TabManager: ObservableObject { }, workspaceCustomizationStore: WorkspaceCustomizationStore? = nil, nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker(), - closeTabWarningDefaults: UserDefaults = .standard + closeTabWarningDefaults: UserDefaults = .standard, + windowId: UUID? = nil, + vaultHistoryEventLog: VaultHistoryEventLog? = nil, + initialWorkspaceHistoryContext: VaultHistoryWorkspaceCreationContext = .bootstrap ) { self.settings = settings self.defaultWorkspaceWorkingDirectoryProvider = defaultWorkspaceWorkingDirectoryProvider @@ -507,6 +512,8 @@ class TabManager: ObservableObject { self.nativeSSHConnectionBroker = nativeSSHConnectionBroker self.panelTitleUpdateCoalescer = panelTitleUpdateCoalescer ?? NotificationBurstCoalescer() self.closeTabWarningDefaults = closeTabWarningDefaults + self.windowId = windowId + self.vaultHistoryEventLog = vaultHistoryEventLog workspaceReordering = WorkspaceReorderCoordinator(model: workspaces) workspaceGrouping = WorkspaceGroupCoordinator(model: workspaces) #if DEBUG @@ -565,7 +572,8 @@ class TabManager: ObservableObject { titleSource: .auto, workingDirectory: initialWorkingDirectory, initialTerminalInput: initialTerminalInput, - autoWelcomeIfNeeded: autoWelcomeIfNeeded + autoWelcomeIfNeeded: autoWelcomeIfNeeded, + vaultHistoryContext: initialWorkspaceHistoryContext ) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidSetTitle, @@ -1122,7 +1130,8 @@ class TabManager: ObservableObject { autoRefreshMetadata: Bool = true, normalizeWorkspaceGroupsAfterInsert: Bool = true, applyCreationTitleAsCustomTitle: Bool = true, - allowTextBoxFocusDefault: Bool = true + allowTextBoxFocusDefault: Bool = true, + vaultHistoryContext: VaultHistoryWorkspaceCreationContext = .semanticCreation ) -> Workspace { let sourceWorkspace = selectedWorkspace let capturedTabs = tabs @@ -1243,7 +1252,9 @@ class TabManager: ObservableObject { } publishCmuxWorkspaceCreated(newWorkspace, selected: select) publishCmuxInitialSurfaceCreated(newWorkspace, selected: select) - recordVaultHistoryWorkspaceCreated(newWorkspace) + if vaultHistoryContext.recordsCreationEvent { + recordVaultHistoryWorkspaceCreated(newWorkspace) + } if select { #if DEBUG debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) @@ -2227,7 +2238,7 @@ class TabManager: ObservableObject { if tabs.isEmpty { // The UI assumes each window always has at least one workspace. - _ = addWorkspace() + _ = addWorkspace(vaultHistoryContext: .structuralReplacement) return removed } @@ -4389,7 +4400,8 @@ class TabManager: ObservableObject { workingDirectory: entry.snapshot.currentDirectory, select: false, autoWelcomeIfNeeded: false, - applyCreationTitleAsCustomTitle: false + applyCreationTitleAsCustomTitle: false, + vaultHistoryContext: .restoration ) let restoredPanelIds = workspace.restoreSessionSnapshot(entry.snapshot, excludingStableIdentities: excludedStableIdentities) guard !entry.snapshot.hasRestorablePanels || !restoredPanelIds.isEmpty else { diff --git a/Sources/VaultHistoryDateBucket+Presentation.swift b/Sources/VaultHistoryDateBucket+Presentation.swift new file mode 100644 index 000000000000..2ed026edcd03 --- /dev/null +++ b/Sources/VaultHistoryDateBucket+Presentation.swift @@ -0,0 +1,19 @@ +import CmuxVaultHistory +import Foundation + +extension VaultHistoryDateBucket { + var label: String { + switch self { + case .last24Hours: + String(localized: "vaultHistory.bucket.last24Hours", defaultValue: "Last 24 hours") + case .yesterday: + String(localized: "vaultHistory.bucket.yesterday", defaultValue: "Yesterday") + case .thisWeek: + String(localized: "vaultHistory.bucket.thisWeek", defaultValue: "This week") + case .thisMonth: + String(localized: "vaultHistory.bucket.thisMonth", defaultValue: "This month") + case .older: + String(localized: "vaultHistory.bucket.older", defaultValue: "Older") + } + } +} diff --git a/Sources/VaultHistoryEvent.swift b/Sources/VaultHistoryEvent.swift deleted file mode 100644 index b40961c75abb..000000000000 --- a/Sources/VaultHistoryEvent.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation - -/// What happened in a single history timeline entry. -/// -/// Persisted kinds (workspace/window lifecycle) are recorded by -/// ``VaultHistoryEventLog`` as they happen; `sessionActivity` events are -/// derived at read time from the Vault session index and never written to -/// the history store. -enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { - case workspaceCreated - case workspaceRenamed - case workspaceClosed - case windowOpened - case windowClosed - case sessionActivity - - /// Localized label describing the event kind, used for rows and - /// group-by-type section headers. - var label: String { - switch self { - case .workspaceCreated: - return String(localized: "vaultHistory.kind.workspaceCreated", defaultValue: "Workspace created") - case .workspaceRenamed: - return String(localized: "vaultHistory.kind.workspaceRenamed", defaultValue: "Workspace renamed") - case .workspaceClosed: - return String(localized: "vaultHistory.kind.workspaceClosed", defaultValue: "Workspace closed") - case .windowOpened: - return String(localized: "vaultHistory.kind.windowOpened", defaultValue: "Window opened") - case .windowClosed: - return String(localized: "vaultHistory.kind.windowClosed", defaultValue: "Window closed") - case .sessionActivity: - return String(localized: "vaultHistory.kind.sessionActivity", defaultValue: "Agent session") - } - } - - var symbolName: String { - switch self { - case .workspaceCreated: return "plus.square" - case .workspaceRenamed: return "pencil" - case .workspaceClosed: return "xmark.square" - case .windowOpened: return "macwindow.badge.plus" - case .windowClosed: return "macwindow" - case .sessionActivity: return "sparkles" - } - } -} - -/// Identity of the thing an event is about. All fields are optional; each -/// event kind fills the ones that apply. Group-by-workspace/window/agent -/// read these fields, so a missing field lands the event in the shared -/// "Other" bucket for that grouping. -struct VaultHistorySubject: Hashable, Codable, Sendable { - var workspaceId: UUID? - var windowId: UUID? - /// Native agent session identifier for `sessionActivity` events. - var sessionId: String? - /// `SessionAgent` raw value for `sessionActivity` events. - var agent: String? - /// Working directory associated with the subject, when known. - var directory: String? - - init( - workspaceId: UUID? = nil, - windowId: UUID? = nil, - sessionId: String? = nil, - agent: String? = nil, - directory: String? = nil - ) { - self.workspaceId = workspaceId - self.windowId = windowId - self.sessionId = sessionId - self.agent = agent - self.directory = directory - } -} - -/// One entry in the unified Vault history timeline. -/// -/// The model is deliberately flat and locale-independent: display strings -/// (kind labels, "N workspaces" details) are derived in the UI layer so a -/// persisted event renders correctly after a language change. -struct VaultHistoryEvent: Identifiable, Hashable, Codable, Sendable { - /// Stable identity. Recorded events use a fresh UUID string; derived - /// session events reuse the session entry id so re-derivation is stable. - let id: String - let timestamp: Date - let kind: VaultHistoryEventKind - /// Display title of the subject at event time (workspace or session title). - let title: String - /// Previous title, set for `workspaceRenamed`. - let previousTitle: String? - /// Number of workspaces involved, set for window events. - let workspaceCount: Int? - let subject: VaultHistorySubject - - init( - id: String = UUID().uuidString, - timestamp: Date, - kind: VaultHistoryEventKind, - title: String, - previousTitle: String? = nil, - workspaceCount: Int? = nil, - subject: VaultHistorySubject = VaultHistorySubject() - ) { - self.id = id - self.timestamp = timestamp - self.kind = kind - self.title = title - self.previousTitle = previousTitle - self.workspaceCount = workspaceCount - self.subject = subject - } -} diff --git a/Sources/VaultHistoryEventKind+Presentation.swift b/Sources/VaultHistoryEventKind+Presentation.swift new file mode 100644 index 000000000000..6c2f3501f621 --- /dev/null +++ b/Sources/VaultHistoryEventKind+Presentation.swift @@ -0,0 +1,32 @@ +import CmuxVaultHistory +import Foundation + +extension VaultHistoryEventKind { + var label: String { + switch self { + case .workspaceCreated: + String(localized: "vaultHistory.kind.workspaceCreated", defaultValue: "Workspace created") + case .workspaceRenamed: + String(localized: "vaultHistory.kind.workspaceRenamed", defaultValue: "Workspace renamed") + case .workspaceClosed: + String(localized: "vaultHistory.kind.workspaceClosed", defaultValue: "Workspace closed") + case .windowOpened: + String(localized: "vaultHistory.kind.windowOpened", defaultValue: "Window opened") + case .windowClosed: + String(localized: "vaultHistory.kind.windowClosed", defaultValue: "Window closed") + case .sessionActivity: + String(localized: "vaultHistory.kind.sessionActivity", defaultValue: "Agent session") + } + } + + var symbolName: String { + switch self { + case .workspaceCreated: "plus.square" + case .workspaceRenamed: "pencil" + case .workspaceClosed: "xmark.square" + case .windowOpened: "macwindow.badge.plus" + case .windowClosed: "macwindow" + case .sessionActivity: "sparkles" + } + } +} diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index cc38899ddaa6..974843c18a4c 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -1,78 +1,54 @@ +import CmuxVaultHistory import Foundation import Observation -/// Main-actor recording facade for the Vault history timeline. +/// Main-actor owner that coordinates lifecycle recording and History refreshes. /// -/// Lifecycle hooks (workspace create/rename/close, window open/close) call -/// ``record(_:)``, which stamps nothing itself — callers build the full -/// ``VaultHistoryEvent`` — and forwards to the persistence actor off the -/// caller's critical path. `revision` bumps on every recorded event so the -/// History view knows to re-query without observing the store directly. +/// The app composition root constructs exactly one instance and injects it into +/// every lifecycle producer and History consumer. ``phase`` is the single gate +/// for launch, restore, and termination suppression; accepted events serialize +/// through the actor-backed store before ``revision`` changes. @MainActor @Observable final class VaultHistoryEventLog { - static let shared = VaultHistoryEventLog( - store: VaultHistoryEventStore(fileURL: defaultFileURL()) - ) - - /// Monotonic count of recorded events this launch; observed by the - /// History view to refresh after new activity. Bumped only after the - /// event reached the store, so a refresh triggered by the bump always - /// sees the event it announces. private(set) var revision: UInt64 = 0 + private(set) var phase: VaultHistoryRecordingPhase - private let store: VaultHistoryEventStore - /// Tail of the serialized append chain; each record awaits its - /// predecessor so events persist in call order. + private let store: any VaultHistoryEventStoring private var pendingRecordTask: Task? - init(store: VaultHistoryEventStore) { + init( + store: any VaultHistoryEventStoring, + phase: VaultHistoryRecordingPhase = .launching + ) { self.store = store + self.phase = phase } - /// Whether lifecycle hooks should skip recording right now (bulk - /// programmatic churn like session restore, or app termination). - static var isRecordingSuppressed: Bool { - guard let appDelegate = AppDelegate.shared else { return false } - return appDelegate.isApplyingSessionRestore || appDelegate.isTerminatingApp + func transition(to phase: VaultHistoryRecordingPhase) { + self.phase = phase } func record(_ event: VaultHistoryEvent) { + guard phase == .active else { return } let store = store let previous = pendingRecordTask pendingRecordTask = Task(priority: .utility) { [weak self] in await previous?.value - await store.append(event) - self?.revision &+= 1 + guard !Task.isCancelled, + await store.append(event), + let self else { + return + } + self.revision &+= 1 } } - /// Persisted events, newest first, bounded by the store's retention policy. func recentEvents(limit: Int = Int.max) async -> [VaultHistoryEvent] { await store.recentEvents(limit: limit) } - nonisolated private static func defaultFileURL( - bundleIdentifier: String? = Bundle.main.bundleIdentifier, - isRunningUnderAutomatedTests: Bool = SessionRestorePolicy.isRunningUnderAutomatedTests() - ) -> URL? { - guard !isRunningUnderAutomatedTests else { return nil } - guard let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - ).first else { - return nil - } - let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? bundleIdentifier! - : "com.cmuxterm.app" - let safeBundleId = bundleId.replacingOccurrences( - of: "[^A-Za-z0-9._-]", - with: "_", - options: .regularExpression - ) - return appSupport - .appendingPathComponent("cmux", isDirectory: true) - .appendingPathComponent("vault-history-\(safeBundleId).jsonl", isDirectory: false) + func flushPendingRecords() async { + await pendingRecordTask?.value } } diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift new file mode 100644 index 000000000000..215d2ef9763c --- /dev/null +++ b/Sources/VaultHistoryEventRow.swift @@ -0,0 +1,104 @@ +import CmuxFoundation +import CmuxVaultHistory +import Foundation +import SwiftUI + +/// Value-only row rendered below the History lazy-list boundary. +struct VaultHistoryEventRow: View, Equatable { + let event: VaultHistoryEvent + + nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { + lhs.event == rhs.event + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: event.kind.symbolName) + .cmuxFont(size: 10, weight: .regular) + .foregroundColor(.secondary) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(displayTitle) + .cmuxFont(size: 11.5) + .foregroundColor(.primary.opacity(0.85)) + .lineLimit(1) + Text(subtitle) + .cmuxFont(size: 10) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer(minLength: 4) + Text(event.timestamp, format: .relative( + presentation: .numeric, + unitsStyle: .abbreviated + )) + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.7)) + .help(event.timestamp.formatted(date: .numeric, time: .shortened)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private var displayTitle: String { + let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty else { return trimmed } + switch event.kind { + case .windowOpened, .windowClosed: + String(localized: "vaultHistory.window", defaultValue: "Window") + default: + String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + } + } + + private var subtitle: String { + var parts: [String] = [] + if event.kind == .sessionActivity, + let rawValue = event.subject.agent, + let agent = SessionAgent(rawValue: rawValue) { + parts.append(agent.displayName) + } else { + parts.append(event.kind.label) + } + if event.kind == .workspaceRenamed, + let previousTitle = event.previousTitle, + !previousTitle.isEmpty { + parts.append(String( + format: String( + localized: "vaultHistory.detail.renamedFrom", + defaultValue: "was “%@”" + ), + previousTitle + )) + } + if let count = event.workspaceCount { + parts.append(Self.workspaceCountLabel(count)) + } + if let directory = event.subject.directory, !directory.isEmpty { + // String-only path math avoids filesystem access in a lazy row body. + let component = (directory as NSString).lastPathComponent + if !component.isEmpty, component != "." { + parts.append(component) + } + } + return parts.joined(separator: " · ") + } + + private static func workspaceCountLabel(_ count: Int) -> String { + if count == 1 { + return String( + localized: "vaultHistory.workspaceCount.one", + defaultValue: "1 workspace" + ) + } + return String.localizedStringWithFormat( + String( + localized: "vaultHistory.workspaceCount.other", + defaultValue: "%d workspaces" + ), + count + ) + } +} diff --git a/Sources/VaultHistoryEventStore.swift b/Sources/VaultHistoryEventStore.swift deleted file mode 100644 index d79c8cd04707..000000000000 --- a/Sources/VaultHistoryEventStore.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -/// Append-only persistence for recorded ``VaultHistoryEvent`` values. -/// -/// Events are stored one JSON object per line in an append-only file so a -/// crash can lose at most the final partial line. Loading reads a bounded -/// tail (never the whole file), and appends trigger a compacting rewrite -/// once the file passes the retention policy's size threshold, so neither -/// the file nor memory can grow unbounded. -/// -/// With a `nil` file URL (automated tests, sandboxed fallback) the store is -/// memory-only but keeps the same bounded behavior. -actor VaultHistoryEventStore { - private let fileURL: URL? - private let retention: VaultHistoryRetentionPolicy - private var didLoad = false - /// Ascending by append order; bounded by `retention.maxStoredEvents`. - private var events: [VaultHistoryEvent] = [] - private var fileBytes = 0 - - private static func makeEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .millisecondsSince1970 - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - return encoder - } - - private static func makeDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .millisecondsSince1970 - return decoder - } - - init(fileURL: URL?, retention: VaultHistoryRetentionPolicy = .default) { - self.fileURL = fileURL - self.retention = retention - } - - /// Appends one event, persisting it and compacting the file if it has - /// outgrown the retention policy. - func append(_ event: VaultHistoryEvent) { - loadIfNeeded() - events.append(event) - trimMemoryIfNeeded() - guard let fileURL else { return } - guard let line = encodedLine(for: event) else { return } - if appendLine(line, to: fileURL) { - fileBytes += line.count - } - if fileBytes > retention.maxFileBytes { - compact(to: fileURL) - } - } - - /// Most recent events, newest first, capped at `limit`. - func recentEvents(limit: Int = Int.max) -> [VaultHistoryEvent] { - loadIfNeeded() - let sorted = events.sorted { $0.timestamp > $1.timestamp } - guard limit < sorted.count else { return sorted } - return Array(sorted.prefix(limit)) - } - - // MARK: - Load - - private func loadIfNeeded() { - guard !didLoad else { return } - didLoad = true - guard let fileURL else { return } - guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return } - defer { try? handle.close() } - - let fileSize = (try? handle.seekToEnd()).map(Int.init) ?? 0 - fileBytes = fileSize - guard fileSize > 0 else { return } - - let readBytes = min(fileSize, retention.maxLoadBytes) - let startOffset = UInt64(fileSize - readBytes) - try? handle.seek(toOffset: startOffset) - guard var data = try? handle.read(upToCount: readBytes) else { return } - - // A mid-file start lands inside a record: drop up to the first newline. - if startOffset > 0, let firstNewline = data.firstIndex(of: 0x0a) { - data = data[data.index(after: firstNewline)...] - } - - let decoder = Self.makeDecoder() - var loaded: [VaultHistoryEvent] = [] - var lineStart = data.startIndex - while lineStart < data.endIndex { - let lineEnd = data[lineStart...].firstIndex(of: 0x0a) ?? data.endIndex - let line = data[lineStart.. retention.maxStoredEvents { - loaded.removeFirst(loaded.count - retention.maxStoredEvents) - } - events = loaded - } - - // MARK: - Persistence - - private func encodedLine(for event: VaultHistoryEvent) -> Data? { - guard var data = try? Self.makeEncoder().encode(event) else { return nil } - data.append(0x0a) - return data - } - - private func appendLine(_ line: Data, to fileURL: URL) -> Bool { - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: fileURL.path) { - try? fileManager.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - return (try? line.write(to: fileURL, options: .atomic)) != nil - } - guard let handle = try? FileHandle(forWritingTo: fileURL) else { return false } - defer { try? handle.close() } - guard (try? handle.seekToEnd()) != nil else { return false } - return (try? handle.write(contentsOf: line)) != nil - } - - /// Rewrites the file from the bounded in-memory buffer, dropping history - /// beyond `retention.maxStoredEvents`. - private func compact(to fileURL: URL) { - trimMemoryIfNeeded() - let encoder = Self.makeEncoder() - var data = Data() - for event in events { - guard let encoded = try? encoder.encode(event) else { continue } - data.append(encoded) - data.append(0x0a) - } - guard (try? data.write(to: fileURL, options: .atomic)) != nil else { return } - fileBytes = data.count - } - - private func trimMemoryIfNeeded() { - if events.count > retention.maxStoredEvents { - events.removeFirst(events.count - retention.maxStoredEvents) - } - } -} diff --git a/Sources/VaultHistoryGroup+Presentation.swift b/Sources/VaultHistoryGroup+Presentation.swift new file mode 100644 index 000000000000..5f1b78d2a800 --- /dev/null +++ b/Sources/VaultHistoryGroup+Presentation.swift @@ -0,0 +1,32 @@ +import CmuxVaultHistory +import Foundation + +extension VaultHistoryGroup { + var title: String { + switch identity { + case let .date(bucket): + bucket.label + case .workspace: + capturedTitle + ?? String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + case .window: + capturedTitle + ?? String(localized: "vaultHistory.window", defaultValue: "Window") + case let .agent(rawValue): + SessionAgent(rawValue: rawValue)?.displayName + ?? String(localized: "vaultHistory.group.other", defaultValue: "Other") + case let .kind(kind): + kind.label + case .other: + key == .agent + ? String(localized: "vaultHistory.group.cmux", defaultValue: "cmux") + : String(localized: "vaultHistory.group.other", defaultValue: "Other") + } + } + + private var capturedTitle: String? { + events.lazy + .map { $0.title.trimmingCharacters(in: .whitespacesAndNewlines) } + .first(where: { !$0.isEmpty }) + } +} diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift new file mode 100644 index 000000000000..f4c433e9d114 --- /dev/null +++ b/Sources/VaultHistoryGroupHeader.swift @@ -0,0 +1,25 @@ +import CmuxFoundation +import SwiftUI + +/// Value-only pinned header rendered below the History lazy-list boundary. +struct VaultHistoryGroupHeader: View, Equatable { + let title: String + let count: Int + + var body: some View { + HStack(spacing: 4) { + Text(title) + .cmuxFont(size: 11, weight: .semibold) + .foregroundColor(.secondary) + .lineLimit(1) + Text(count, format: .number) + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.6)) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } +} diff --git a/Sources/VaultHistoryGroupKey+Presentation.swift b/Sources/VaultHistoryGroupKey+Presentation.swift new file mode 100644 index 000000000000..19d348f87684 --- /dev/null +++ b/Sources/VaultHistoryGroupKey+Presentation.swift @@ -0,0 +1,29 @@ +import CmuxVaultHistory +import Foundation + +extension VaultHistoryGroupKey { + var label: String { + switch self { + case .date: + String(localized: "vaultHistory.group.date", defaultValue: "Date") + case .workspace: + String(localized: "vaultHistory.group.workspace", defaultValue: "Workspace") + case .window: + String(localized: "vaultHistory.group.window", defaultValue: "Window") + case .agent: + String(localized: "vaultHistory.group.agent", defaultValue: "Agent") + case .kind: + String(localized: "vaultHistory.group.kind", defaultValue: "Type") + } + } + + var symbolName: String { + switch self { + case .date: "clock" + case .workspace: "square.on.square" + case .window: "macwindow" + case .agent: "person.2" + case .kind: "tag" + } + } +} diff --git a/Sources/VaultHistoryGrouping.swift b/Sources/VaultHistoryGrouping.swift deleted file mode 100644 index c2cc66c9f620..000000000000 --- a/Sources/VaultHistoryGrouping.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Foundation - -/// The dimension the History timeline is grouped by. Date is the flagship -/// (browser-history-style buckets, "Last 24 hours" first); every other key -/// falls out of the same pure grouping pass over the event list. -enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable { - case date - case workspace - case window - case agent - case kind - - var id: String { rawValue } - - var label: String { - switch self { - case .date: return String(localized: "vaultHistory.group.date", defaultValue: "Date") - case .workspace: return String(localized: "vaultHistory.group.workspace", defaultValue: "Workspace") - case .window: return String(localized: "vaultHistory.group.window", defaultValue: "Window") - case .agent: return String(localized: "vaultHistory.group.agent", defaultValue: "Agent") - case .kind: return String(localized: "vaultHistory.group.kind", defaultValue: "Type") - } - } - - var symbolName: String { - switch self { - case .date: return "clock" - case .workspace: return "square.on.square" - case .window: return "macwindow" - case .agent: return "person.2" - case .kind: return "tag" - } - } -} - -/// Browser-history-style date buckets, newest bucket first. -enum VaultHistoryDateBucket: Int, CaseIterable, Identifiable, Sendable { - case last24Hours - case yesterday - case thisWeek - case thisMonth - case older - - var id: Int { rawValue } - - /// Buckets a timestamp relative to `now`. "Last 24 hours" is a rolling - /// window (also absorbing any same-day stragglers, e.g. across a DST - /// fold); the calendar buckets catch everything older, so an event from - /// yesterday morning can land in `yesterday` while one from yesterday - /// evening is still inside `last24Hours`. - static func bucket(for date: Date, now: Date, calendar: Calendar) -> VaultHistoryDateBucket { - if date >= now.addingTimeInterval(-24 * 60 * 60) || calendar.isDate(date, inSameDayAs: now) { - return .last24Hours - } - if isYesterday(date, now: now, calendar: calendar) { - return .yesterday - } - if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { - return .thisWeek - } - if calendar.isDate(date, equalTo: now, toGranularity: .month) { - return .thisMonth - } - return .older - } - - private static func isYesterday(_ date: Date, now: Date, calendar: Calendar) -> Bool { - guard let yesterday = calendar.date(byAdding: .day, value: -1, to: now) else { return false } - return calendar.isDate(date, inSameDayAs: yesterday) - } - - var label: String { - switch self { - case .last24Hours: - return String(localized: "vaultHistory.bucket.last24Hours", defaultValue: "Last 24 hours") - case .yesterday: - return String(localized: "vaultHistory.bucket.yesterday", defaultValue: "Yesterday") - case .thisWeek: - return String(localized: "vaultHistory.bucket.thisWeek", defaultValue: "This week") - case .thisMonth: - return String(localized: "vaultHistory.bucket.thisMonth", defaultValue: "This month") - case .older: - return String(localized: "vaultHistory.bucket.older", defaultValue: "Older") - } - } -} - -/// One rendered section of the History timeline: a stable identity, a -/// header title, and the section's events sorted newest first. -struct VaultHistoryGroup: Identifiable, Equatable, Sendable { - let id: String - let title: String - let events: [VaultHistoryEvent] -} - -/// Pure grouping over a flat event list. Given the same events, key, `now`, -/// and calendar, the output is deterministic — no clock or store access — -/// so every grouping behavior is unit-testable. -struct VaultHistoryGrouper: Sendable { - var calendar: Calendar - - init(calendar: Calendar = .current) { - self.calendar = calendar - } - - static let otherGroupID = "other" - - func groups( - events: [VaultHistoryEvent], - by key: VaultHistoryGroupKey, - now: Date - ) -> [VaultHistoryGroup] { - let sorted = events.sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.id > $1.id - } - switch key { - case .date: - return dateGroups(sortedEvents: sorted, now: now) - case .workspace: - return subjectGroups(sortedEvents: sorted, idFor: { event in - event.subject.workspaceId.map { "workspace:\($0.uuidString)" } - }) - case .window: - return subjectGroups(sortedEvents: sorted, idFor: { event in - event.subject.windowId.map { "window:\($0.uuidString)" } - }) - case .agent: - return agentGroups(sortedEvents: sorted) - case .kind: - return kindGroups(sortedEvents: sorted) - } - } - - private func dateGroups(sortedEvents: [VaultHistoryEvent], now: Date) -> [VaultHistoryGroup] { - var byBucket: [VaultHistoryDateBucket: [VaultHistoryEvent]] = [:] - for event in sortedEvents { - let bucket = VaultHistoryDateBucket.bucket(for: event.timestamp, now: now, calendar: calendar) - byBucket[bucket, default: []].append(event) - } - return VaultHistoryDateBucket.allCases.compactMap { bucket in - guard let events = byBucket[bucket], !events.isEmpty else { return nil } - return VaultHistoryGroup(id: "date:\(bucket.rawValue)", title: bucket.label, events: events) - } - } - - /// Groups by an identity extracted from the subject. Group order follows - /// each group's most recent event; events without the identity land in a - /// trailing "Other" group. The group title is the newest event's title, - /// so renamed workspaces show their latest name. - private func subjectGroups( - sortedEvents: [VaultHistoryEvent], - idFor: (VaultHistoryEvent) -> String? - ) -> [VaultHistoryGroup] { - var order: [String] = [] - var byId: [String: [VaultHistoryEvent]] = [:] - for event in sortedEvents { - let id = idFor(event) ?? Self.otherGroupID - if byId[id] == nil { order.append(id) } - byId[id, default: []].append(event) - } - if let otherIndex = order.firstIndex(of: Self.otherGroupID), otherIndex != order.count - 1 { - order.remove(at: otherIndex) - order.append(Self.otherGroupID) - } - return order.compactMap { id in - guard let events = byId[id], !events.isEmpty else { return nil } - let title = id == Self.otherGroupID - ? String(localized: "vaultHistory.group.other", defaultValue: "Other") - : (events.first(where: { !$0.title.isEmpty })?.title - ?? String(localized: "vaultHistory.untitled", defaultValue: "Untitled")) - return VaultHistoryGroup(id: id, title: title, events: events) - } - } - - private func agentGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { - var order: [String] = [] - var byId: [String: [VaultHistoryEvent]] = [:] - for event in sortedEvents { - let id = event.subject.agent.map { "agent:\($0)" } ?? Self.otherGroupID - if byId[id] == nil { order.append(id) } - byId[id, default: []].append(event) - } - if let otherIndex = order.firstIndex(of: Self.otherGroupID), otherIndex != order.count - 1 { - order.remove(at: otherIndex) - order.append(Self.otherGroupID) - } - return order.compactMap { id in - guard let events = byId[id], !events.isEmpty else { return nil } - let title: String - if id == Self.otherGroupID { - title = String(localized: "vaultHistory.group.cmux", defaultValue: "cmux") - } else if let raw = events.first?.subject.agent, let agent = SessionAgent(rawValue: raw) { - title = agent.displayName - } else { - title = String(localized: "vaultHistory.group.other", defaultValue: "Other") - } - return VaultHistoryGroup(id: id, title: title, events: events) - } - } - - private func kindGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { - var order: [VaultHistoryEventKind] = [] - var byKind: [VaultHistoryEventKind: [VaultHistoryEvent]] = [:] - for event in sortedEvents { - if byKind[event.kind] == nil { order.append(event.kind) } - byKind[event.kind, default: []].append(event) - } - return order.compactMap { kind in - guard let events = byKind[kind], !events.isEmpty else { return nil } - return VaultHistoryGroup(id: "kind:\(kind.rawValue)", title: kind.label, events: events) - } - } -} diff --git a/Sources/VaultHistoryLifecycleRecording.swift b/Sources/VaultHistoryLifecycleRecording.swift index 5d791ef15359..dba62802e6e4 100644 --- a/Sources/VaultHistoryLifecycleRecording.swift +++ b/Sources/VaultHistoryLifecycleRecording.swift @@ -1,3 +1,4 @@ +import CmuxVaultHistory import Foundation /// Lifecycle hooks that feed the Vault History timeline. Call sites in @@ -5,14 +6,13 @@ import Foundation /// suppression (session restore, app termination) and builds the event. extension TabManager { func recordVaultHistoryWorkspaceCreated(_ workspace: Workspace) { - guard !VaultHistoryEventLog.isRecordingSuppressed else { return } - VaultHistoryEventLog.shared.record(VaultHistoryEvent( - timestamp: Date(), + vaultHistoryEventLog?.record(VaultHistoryEvent( + timestamp: .now, kind: .workspaceCreated, title: resolvedWorkspaceDisplayTitle(for: workspace), subject: VaultHistorySubject( workspaceId: workspace.id, - windowId: AppDelegate.shared?.windowId(for: self), + windowId: windowId, directory: workspace.currentDirectory ) )) @@ -23,29 +23,27 @@ extension TabManager { previousTitle: String, currentTitle: String ) { - guard !VaultHistoryEventLog.isRecordingSuppressed else { return } - VaultHistoryEventLog.shared.record(VaultHistoryEvent( - timestamp: Date(), + vaultHistoryEventLog?.record(VaultHistoryEvent( + timestamp: .now, kind: .workspaceRenamed, title: currentTitle, previousTitle: previousTitle, subject: VaultHistorySubject( workspaceId: workspace.id, - windowId: AppDelegate.shared?.windowId(for: self), + windowId: windowId, directory: workspace.currentDirectory ) )) } func recordVaultHistoryWorkspaceClosed(_ workspace: Workspace) { - guard !VaultHistoryEventLog.isRecordingSuppressed else { return } - VaultHistoryEventLog.shared.record(VaultHistoryEvent( - timestamp: Date(), + vaultHistoryEventLog?.record(VaultHistoryEvent( + timestamp: .now, kind: .workspaceClosed, title: resolvedWorkspaceDisplayTitle(for: workspace), subject: VaultHistorySubject( workspaceId: workspace.id, - windowId: AppDelegate.shared?.windowId(for: self), + windowId: windowId, directory: workspace.currentDirectory ) )) @@ -54,9 +52,8 @@ extension TabManager { extension AppDelegate { func recordVaultHistoryWindowOpened(windowId: UUID) { - guard !VaultHistoryEventLog.isRecordingSuppressed else { return } - VaultHistoryEventLog.shared.record(VaultHistoryEvent( - timestamp: Date(), + vaultHistoryEventLog?.record(VaultHistoryEvent( + timestamp: .now, kind: .windowOpened, title: "", subject: VaultHistorySubject(windowId: windowId) @@ -75,8 +72,8 @@ extension AppDelegate { return trimmed.isEmpty ? nil : trimmed } .first ?? "" - VaultHistoryEventLog.shared.record(VaultHistoryEvent( - timestamp: Date(), + vaultHistoryEventLog?.record(VaultHistoryEvent( + timestamp: .now, kind: .windowClosed, title: title, workspaceCount: workspaces.count, diff --git a/Sources/VaultHistoryRecordingPhase.swift b/Sources/VaultHistoryRecordingPhase.swift new file mode 100644 index 000000000000..53f25ea84b25 --- /dev/null +++ b/Sources/VaultHistoryRecordingPhase.swift @@ -0,0 +1,13 @@ +import Foundation + +/// App lifecycle phase that decides whether semantic History events are accepted. +enum VaultHistoryRecordingPhase: Equatable, Sendable { + /// App composition exists, but startup restore intent is not resolved yet. + case launching + /// User and external actions should be recorded. + case active + /// Programmatic session restoration should not emit lifecycle events. + case restoring + /// App teardown should not turn bulk closure into user history. + case terminating +} diff --git a/Sources/VaultHistoryRetentionPolicy.swift b/Sources/VaultHistoryRetentionPolicy.swift deleted file mode 100644 index a8426da3b77a..000000000000 --- a/Sources/VaultHistoryRetentionPolicy.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -/// Size bounds for the persisted Vault history event log. -/// -/// The store enforces these on every append and load so the on-disk file and -/// the in-memory buffer are bounded by construction — there is no code path -/// that scans an unbounded file (cf. issue #4536). -struct VaultHistoryRetentionPolicy: Sendable { - /// Maximum events kept in memory and rewritten to disk on compaction. - let maxStoredEvents: Int - /// File size that triggers a compacting rewrite on the next append. - let maxFileBytes: Int - /// Byte budget for the tail read performed when loading persisted events. - let maxLoadBytes: Int - - static let `default` = VaultHistoryRetentionPolicy( - maxStoredEvents: 2000, - maxFileBytes: 4 * 1024 * 1024, - maxLoadBytes: 2 * 1024 * 1024 - ) - - init(maxStoredEvents: Int, maxFileBytes: Int, maxLoadBytes: Int) { - self.maxStoredEvents = max(1, maxStoredEvents) - self.maxFileBytes = max(1024, maxFileBytes) - self.maxLoadBytes = max(1024, maxLoadBytes) - } -} diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift index 4f945f49a57e..e86fe62245b1 100644 --- a/Sources/VaultHistorySessionEvents.swift +++ b/Sources/VaultHistorySessionEvents.swift @@ -1,3 +1,4 @@ +import CmuxVaultHistory import Foundation /// Projects Vault session index entries into history events at read time. diff --git a/Sources/VaultHistoryStoreLocation.swift b/Sources/VaultHistoryStoreLocation.swift new file mode 100644 index 000000000000..078f1ba3b2a5 --- /dev/null +++ b/Sources/VaultHistoryStoreLocation.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Resolves the bundle-scoped Application Support file used by History persistence. +struct VaultHistoryStoreLocation: Sendable { + private static let fallbackBundleIdentifier = "com.cmuxterm.app" + + let applicationSupportDirectory: URL + let bundleIdentifier: String? + + var fileURL: URL { + let trimmedIdentifier = bundleIdentifier? + .trimmingCharacters(in: .whitespacesAndNewlines) + let identifier = trimmedIdentifier.flatMap { identifier in + identifier.isEmpty ? nil : identifier + } ?? Self.fallbackBundleIdentifier + let allowedScalars = CharacterSet.alphanumerics + .union(CharacterSet(charactersIn: "._-")) + let safeIdentifier = identifier.unicodeScalars + .map { allowedScalars.contains($0) ? String($0) : "_" } + .joined() + return applicationSupportDirectory + .appending(path: "cmux", directoryHint: .isDirectory) + .appending( + path: "vault-history-\(safeIdentifier).jsonl", + directoryHint: .notDirectory + ) + } +} diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 67c99fed44ad..e6e1e3188fe3 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -1,3 +1,4 @@ +import CmuxVaultHistory import Foundation import Observation @@ -40,7 +41,7 @@ final class VaultHistoryTimelineModel { log: VaultHistoryEventLog, grouper: VaultHistoryGrouper = VaultHistoryGrouper(), defaults: UserDefaults = .standard, - now: @escaping () -> Date = Date.init + now: @escaping () -> Date = { .now } ) { self.log = log self.grouper = grouper @@ -66,10 +67,7 @@ final class VaultHistoryTimelineModel { // forces dropping the oldest events, so the common path pays // for a single sort per refresh. if merged.count > Self.maxTimelineEvents { - merged.sort { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.id > $1.id - } + merged.sort(by: VaultHistoryEvent.newestFirst) merged.removeLast(merged.count - Self.maxTimelineEvents) } self.mergedEvents = merged diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index 02eeb2951f8c..3d22c66a1d97 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -1,28 +1,21 @@ +import CmuxVaultHistory import CmuxFoundation import SwiftUI -/// The Vault History tab: a unified timeline of workspace/window lifecycle -/// events and agent sessions, grouped by the user's chosen dimension -/// (date buckets by default, "Last 24 hours" first). +/// The Vault History tab: a unified timeline of workspace, window, and session activity. struct VaultHistoryView: View { @ObservedObject var sessionStore: SessionIndexStore - /// Injected so previews/tests can point at an isolated log. let log: VaultHistoryEventLog - @State private var model: VaultHistoryTimelineModel? + @State private var model: VaultHistoryTimelineModel + + init(sessionStore: SessionIndexStore, log: VaultHistoryEventLog) { + self.sessionStore = sessionStore + self.log = log + _model = State(initialValue: VaultHistoryTimelineModel(log: log)) + } var body: some View { - VStack(spacing: 0) { - if let model { - VaultHistoryContentView(sessionStore: sessionStore, log: log, model: model) - } else { - Color.clear - .onAppear { - if model == nil { - model = VaultHistoryTimelineModel(log: log) - } - } - } - } + VaultHistoryContentView(sessionStore: sessionStore, log: log, model: model) } } @@ -86,7 +79,10 @@ private struct VaultHistoryContentView: View { } .menuStyle(.borderlessButton) .fixedSize() - .help(String(localized: "vaultHistory.groupPicker.tooltip", defaultValue: "Group history by")) + .help(String( + localized: "vaultHistory.groupPicker.tooltip", + defaultValue: "Group history by" + )) .accessibilityIdentifier("VaultHistoryGroupPicker") .titlebarInteractiveControl() @@ -100,7 +96,10 @@ private struct VaultHistoryContentView: View { .cmuxFont(size: 10, weight: .medium) } .buttonStyle(.borderless) - .help(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) + .help(String( + localized: "vaultHistory.reload.tooltip", + defaultValue: "Reload History" + )) .disabled(model.isLoading || sessionStore.isLoading) .titlebarInteractiveControl() } @@ -111,18 +110,24 @@ private struct VaultHistoryContentView: View { private var loadingView: some View { VStack(spacing: 6) { ProgressView().controlSize(.small) - Text(String(localized: "vaultHistory.loading", defaultValue: "Loading history…")) - .cmuxFont(size: 11) - .foregroundColor(.secondary) + Text(String( + localized: "vaultHistory.loading", + defaultValue: "Loading history…" + )) + .cmuxFont(size: 11) + .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var emptyView: some View { VStack(spacing: 4) { - Text(String(localized: "vaultHistory.empty.title", defaultValue: "No history yet")) - .cmuxFont(size: 12) - .foregroundColor(.secondary) + Text(String( + localized: "vaultHistory.empty.title", + defaultValue: "No history yet" + )) + .cmuxFont(size: 12) + .foregroundColor(.secondary) Text(String( localized: "vaultHistory.empty.subtitle", defaultValue: "Workspace, window, and agent session activity will appear here." @@ -136,8 +141,6 @@ private struct VaultHistoryContentView: View { } private var timelineList: some View { - // Snapshot value data before entering the lazy subtree: rows below - // the LazyVStack boundary receive plain values, never store refs. let groups = model.groups return ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { @@ -155,123 +158,3 @@ private struct VaultHistoryContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } - -struct VaultHistoryGroupHeader: View, Equatable { - let title: String - let count: Int - - var body: some View { - HStack(spacing: 4) { - Text(title) - .cmuxFont(size: 11, weight: .semibold) - .foregroundColor(.secondary) - .lineLimit(1) - Text("\(count)") - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.6)) - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) - } -} - -struct VaultHistoryEventRow: View, Equatable { - let event: VaultHistoryEvent - - nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { - lhs.event == rhs.event - } - - private static let relativeFormatter: RelativeDateTimeFormatter = { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter - }() - - private static let absoluteFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .short - return formatter - }() - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: event.kind.symbolName) - .cmuxFont(size: 10, weight: .regular) - .foregroundColor(.secondary) - .frame(width: 14) - VStack(alignment: .leading, spacing: 1) { - Text(displayTitle) - .cmuxFont(size: 11.5) - .foregroundColor(.primary.opacity(0.85)) - .lineLimit(1) - Text(subtitle) - .cmuxFont(size: 10) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer(minLength: 4) - Text(Self.relativeFormatter.localizedString(for: event.timestamp, relativeTo: Date())) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.7)) - .help(Self.absoluteFormatter.string(from: event.timestamp)) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - } - - private var displayTitle: String { - let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty else { return trimmed } - switch event.kind { - case .windowOpened, .windowClosed: - return String(localized: "vaultHistory.window", defaultValue: "Window") - default: - return String(localized: "vaultHistory.untitled", defaultValue: "Untitled") - } - } - - private var subtitle: String { - var parts: [String] = [] - if event.kind == .sessionActivity, - let raw = event.subject.agent, - let agent = SessionAgent(rawValue: raw) { - parts.append(agent.displayName) - } else { - parts.append(event.kind.label) - } - if event.kind == .workspaceRenamed, let previousTitle = event.previousTitle, !previousTitle.isEmpty { - parts.append(String( - format: String(localized: "vaultHistory.detail.renamedFrom", defaultValue: "was “%@”"), - previousTitle - )) - } - if let count = event.workspaceCount { - parts.append(Self.workspaceCountLabel(count)) - } - if let directory = event.subject.directory, !directory.isEmpty { - // String-only path math: URL(fileURLWithPath:) would stat the path. - let component = (directory as NSString).lastPathComponent - if !component.isEmpty, component != "." { - parts.append(component) - } - } - return parts.joined(separator: " · ") - } - - private static func workspaceCountLabel(_ count: Int) -> String { - if count == 1 { - return String(localized: "vaultHistory.workspaceCount.one", defaultValue: "1 workspace") - } - return String.localizedStringWithFormat( - String(localized: "vaultHistory.workspaceCount.other", defaultValue: "%d workspaces"), - count - ) - } -} diff --git a/Sources/VaultHistoryWorkspaceCreationContext.swift b/Sources/VaultHistoryWorkspaceCreationContext.swift new file mode 100644 index 000000000000..760821bf3fbb --- /dev/null +++ b/Sources/VaultHistoryWorkspaceCreationContext.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Semantic origin of a workspace insertion for History recording. +enum VaultHistoryWorkspaceCreationContext: Equatable, Sendable { + /// A user, command, or integration intentionally created a workspace. + case semanticCreation + /// A placeholder workspace was inserted while constructing a manager. + case bootstrap + /// A previously persisted workspace was reconstructed from a snapshot. + case restoration + /// A placeholder preserved the invariant that every live window has a workspace. + case structuralReplacement + + var recordsCreationEvent: Bool { + self == .semanticCreation + } +} diff --git a/Sources/VaultPaneTab.swift b/Sources/VaultPaneTab.swift new file mode 100644 index 000000000000..70017f2fada9 --- /dev/null +++ b/Sources/VaultPaneTab.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Sub-navigation inside the Vault sidebar mode. +enum VaultPaneTab: String, CaseIterable, Identifiable { + case sessions + case history + + var id: String { rawValue } + + var label: String { + switch self { + case .sessions: + String(localized: "vaultPane.tab.sessions", defaultValue: "Sessions") + case .history: + String(localized: "vaultPane.tab.history", defaultValue: "History") + } + } + + var symbolName: String { + switch self { + case .sessions: "books.vertical" + case .history: "clock.arrow.circlepath" + } + } +} diff --git a/Sources/VaultPaneTabButton.swift b/Sources/VaultPaneTabButton.swift new file mode 100644 index 000000000000..9d171376f92b --- /dev/null +++ b/Sources/VaultPaneTabButton.swift @@ -0,0 +1,33 @@ +import CmuxFoundation +import SwiftUI + +/// One selectable item in the Vault Sessions/History tab bar. +struct VaultPaneTabButton: View { + let tab: VaultPaneTab + let isSelected: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Image(systemName: tab.symbolName) + .cmuxFont( + size: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) + Text(tab.label) + .cmuxFont( + size: RightSidebarChromeControlStyle.labelSize, + weight: RightSidebarChromeControlStyle.labelWeight + ) + } + .rightSidebarChromePill(isSelected: isSelected, isHovered: isHovered) + } + .buttonStyle(.plain) + .titlebarInteractiveControl() + .onHover { isHovered = $0 } + .help(tab.label) + .accessibilityIdentifier("VaultPaneTabButton.\(tab.rawValue)") + } +} diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 520da24186b6..cdc8612c4f64 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -1,37 +1,13 @@ import CmuxFoundation import SwiftUI -/// Sub-navigation inside the Vault sidebar mode: the existing agent session -/// index ("Sessions") and the unified activity timeline ("History"). -enum VaultPaneTab: String, CaseIterable, Identifiable { - case sessions - case history - - var id: String { rawValue } - - var label: String { - switch self { - case .sessions: - return String(localized: "vaultPane.tab.sessions", defaultValue: "Sessions") - case .history: - return String(localized: "vaultPane.tab.history", defaultValue: "History") - } - } - - var symbolName: String { - switch self { - case .sessions: return "books.vertical" - case .history: return "clock.arrow.circlepath" - } - } -} - /// Hosts the Vault mode's content behind a Sessions/History tab bar. Both /// the right-sidebar mount and the pop-out pane mount render this view so /// the two entrypoints share one implementation. struct VaultPaneView: View { @ObservedObject var store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? + let historyLog: VaultHistoryEventLog @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.sessions.rawValue private var selectedTab: VaultPaneTab { @@ -45,7 +21,7 @@ struct VaultPaneView: View { case .sessions: SessionIndexView(store: store, onResume: onResume) case .history: - VaultHistoryView(sessionStore: store, log: .shared) + VaultHistoryView(sessionStore: store, log: historyLog) } } } @@ -63,33 +39,3 @@ struct VaultPaneView: View { .rightSidebarChromeBottomBorder() } } - -private struct VaultPaneTabButton: View { - let tab: VaultPaneTab - let isSelected: Bool - let action: () -> Void - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: 3) { - Image(systemName: tab.symbolName) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) - Text(tab.label) - .cmuxFont( - size: RightSidebarChromeControlStyle.labelSize, - weight: RightSidebarChromeControlStyle.labelWeight - ) - } - .rightSidebarChromePill(isSelected: isSelected, isHovered: isHovered) - } - .buttonStyle(.plain) - .titlebarInteractiveControl() - .onHover { isHovered = $0 } - .help(tab.label) - .accessibilityIdentifier("VaultPaneTabButton.\(tab.rawValue)") - } -} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 588cef56ae7c..cf2b193dc18f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -14,6 +14,7 @@ import Darwin import Bonsplit import UniformTypeIdentifiers import CmuxTerminal +import CmuxVaultHistory /// The process entry point. When the binary is launched with a worker flag /// (the app re-executes its own binary that way so a crash in the Simulator, @@ -53,6 +54,7 @@ struct cmuxApp: App { /// hosted-browser sign-in flow). Constructed once at app launch and /// injected into AppDelegate and the auth-consuming services. private let authComposition: MacAuthComposition + private let vaultHistoryEventLog: VaultHistoryEventLog @StateObject private var tabManager: TabManager @StateObject private var notificationStore: TerminalNotificationStore @StateObject var closedItemHistoryStore: ClosedItemHistoryStore @@ -124,7 +126,24 @@ struct cmuxApp: App { let closedItemHistoryStore = ClosedItemHistoryStore.shared let sidebarState = SidebarState() let focusHistoryMenuInvalidator = FocusHistoryMenuInvalidator() + let vaultHistoryFileURL: URL? = if SessionRestorePolicy.isRunningUnderAutomatedTests() { + nil + } else { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first.map { + VaultHistoryStoreLocation( + applicationSupportDirectory: $0, + bundleIdentifier: Bundle.main.bundleIdentifier + ).fileURL + } + } + let vaultHistoryEventLog = VaultHistoryEventLog( + store: VaultHistoryEventStore(fileURL: vaultHistoryFileURL) + ) self.authComposition = authComposition + self.vaultHistoryEventLog = vaultHistoryEventLog // If invoked with CLI-style arguments (e.g. `cmux hooks setup`), exec the // bundled CLI at Contents/Resources/bin/cmux. The GUI binary and the CLI @@ -195,7 +214,8 @@ struct cmuxApp: App { StartupBreadcrumbLog.append("app.init.tabManager.begin") let tabManager = TabManager( workspaceCustomizationStore: workspaceCustomizationStore, - nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker + nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker, + vaultHistoryEventLog: vaultHistoryEventLog ) _tabManager = StateObject(wrappedValue: tabManager) _notificationStore = StateObject(wrappedValue: notificationStore) @@ -235,7 +255,8 @@ struct cmuxApp: App { notificationStore: notificationStore, sidebarState: sidebarState, settingsRuntime: settingsRuntime, - auth: authComposition + auth: authComposition, + vaultHistoryEventLog: vaultHistoryEventLog ) StartupBreadcrumbLog.append("app.init.delegate.configured") } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e0e35860785c..242512f959d9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -858,6 +858,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources CDFEED0300000000CDFEED03 /* CmuxUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = CDFEED0200000000CDFEED02 /* CmuxUpdater */; }; CDFEED0600000000CDFEED06 /* CmuxUpdaterUI in Frameworks */ = {isa = PBXBuildFile; productRef = CDFEED0500000000CDFEED05 /* CmuxUpdaterUI */; }; 858200000000000000000006 /* CmuxVaultAgentRegistration+RegisteredResumeKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858200000000000000000005 /* CmuxVaultAgentRegistration+RegisteredResumeKind.swift */; }; + 9131000000000000000000A3 /* CmuxVaultHistory in Frameworks */ = {isa = PBXBuildFile; productRef = 9131000000000000000000A2 /* CmuxVaultHistory */; }; + 9131000000000000000000A4 /* CmuxVaultHistory in Frameworks */ = {isa = PBXBuildFile; productRef = 9131000000000000000000A2 /* CmuxVaultHistory */; }; D7C0DE00000000000000A101 /* CmuxWebView+ContextMenuLinkCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C0DE00000000000000A102 /* CmuxWebView+ContextMenuLinkCapture.swift */; }; D7AB00000000000000000009 /* CmuxWebView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */; }; C6255D010000000000000001 /* CmuxWebView+ScriptedDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */; }; @@ -2447,18 +2449,25 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3575000000000000000000D /* VaultAgentRegistry+Campfire.swift */; }; 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858200000000000000000003 /* VaultAgentRegistry+Kimi.swift */; }; B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000001 /* VaultAgentRegistry.swift */; }; - 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */; }; + 913100000000000000000001 /* VaultHistoryDateBucket+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000101 /* VaultHistoryDateBucket+Presentation.swift */; }; + 913100000000000000000002 /* VaultHistoryEventKind+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000102 /* VaultHistoryEventKind+Presentation.swift */; }; 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */; }; - A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */; }; - 59E6509DA68F5DB3550040DC /* VaultHistoryEventStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */; }; - 54D2FC0F493B36ACF14EB2CA /* VaultHistoryGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */; }; - 6C5464BB6BC11C869723318F /* VaultHistoryGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */; }; + 913100000000000000000021 /* VaultHistoryEventLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000121 /* VaultHistoryEventLogTests.swift */; }; + 913100000000000000000003 /* VaultHistoryEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000103 /* VaultHistoryEventRow.swift */; }; + 913100000000000000000004 /* VaultHistoryGroup+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000104 /* VaultHistoryGroup+Presentation.swift */; }; + 913100000000000000000005 /* VaultHistoryGroupHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000105 /* VaultHistoryGroupHeader.swift */; }; + 913100000000000000000006 /* VaultHistoryGroupKey+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000106 /* VaultHistoryGroupKey+Presentation.swift */; }; 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */; }; - C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */; }; + 913100000000000000000007 /* VaultHistoryRecordingPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000107 /* VaultHistoryRecordingPhase.swift */; }; + 913100000000000000000022 /* VaultHistorySessionEventProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */; }; 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */; }; + 913100000000000000000008 /* VaultHistoryStoreLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000108 /* VaultHistoryStoreLocation.swift */; }; 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */; }; 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */; }; + 913100000000000000000009 /* VaultHistoryWorkspaceCreationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000109 /* VaultHistoryWorkspaceCreationContext.swift */; }; F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76550060000000000000002 /* VaultObservedAgentProcess.swift */; }; + 91310000000000000000000A /* VaultPaneTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91310000000000000000010A /* VaultPaneTab.swift */; }; + 91310000000000000000000B /* VaultPaneTabButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91310000000000000000010B /* VaultPaneTabButton.swift */; }; 437E021ADABFA29ADC68547C /* VaultPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */; }; F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */; }; C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */; }; @@ -5099,18 +5108,25 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000D /* VaultAgentRegistry+Campfire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentRegistry+Campfire.swift"; sourceTree = ""; }; 858200000000000000000003 /* VaultAgentRegistry+Kimi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultAgentRegistry+Kimi.swift"; sourceTree = ""; }; B35750000000000000000001 /* VaultAgentRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultAgentRegistry.swift; sourceTree = ""; }; - A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEvent.swift; sourceTree = ""; }; + 913100000000000000000101 /* VaultHistoryDateBucket+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultHistoryDateBucket+Presentation.swift"; sourceTree = ""; }; + 913100000000000000000102 /* VaultHistoryEventKind+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultHistoryEventKind+Presentation.swift"; sourceTree = ""; }; BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventLog.swift; sourceTree = ""; }; - EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventStore.swift; sourceTree = ""; }; - EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventStoreTests.swift; sourceTree = ""; }; - 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGrouping.swift; sourceTree = ""; }; - FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGroupingTests.swift; sourceTree = ""; }; + 913100000000000000000121 /* VaultHistoryEventLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventLogTests.swift; sourceTree = ""; }; + 913100000000000000000103 /* VaultHistoryEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventRow.swift; sourceTree = ""; }; + 913100000000000000000104 /* VaultHistoryGroup+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultHistoryGroup+Presentation.swift"; sourceTree = ""; }; + 913100000000000000000105 /* VaultHistoryGroupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGroupHeader.swift; sourceTree = ""; }; + 913100000000000000000106 /* VaultHistoryGroupKey+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultHistoryGroupKey+Presentation.swift"; sourceTree = ""; }; 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryLifecycleRecording.swift; sourceTree = ""; }; - 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryRetentionPolicy.swift; sourceTree = ""; }; + 913100000000000000000107 /* VaultHistoryRecordingPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryRecordingPhase.swift; sourceTree = ""; }; + 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistorySessionEventProjectionTests.swift; sourceTree = ""; }; C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistorySessionEvents.swift; sourceTree = ""; }; + 913100000000000000000108 /* VaultHistoryStoreLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryStoreLocation.swift; sourceTree = ""; }; EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryTimelineModel.swift; sourceTree = ""; }; 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryView.swift; sourceTree = ""; }; + 913100000000000000000109 /* VaultHistoryWorkspaceCreationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryWorkspaceCreationContext.swift; sourceTree = ""; }; F76550060000000000000002 /* VaultObservedAgentProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultObservedAgentProcess.swift; sourceTree = ""; }; + 91310000000000000000010A /* VaultPaneTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaneTab.swift; sourceTree = ""; }; + 91310000000000000000010B /* VaultPaneTabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaneTabButton.swift; sourceTree = ""; }; BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultPaneView.swift; sourceTree = ""; }; F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+EmptyAreasAndFooter.swift"; sourceTree = ""; }; C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+WorkspaceGroups.swift"; sourceTree = ""; }; @@ -5385,6 +5401,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CFF12E0000000000000000A3 /* CmuxTestSupport in Frameworks */, CDFEED0300000000CDFEED03 /* CmuxUpdater in Frameworks */, CDFEED0600000000CDFEED06 /* CmuxUpdaterUI in Frameworks */, + 9131000000000000000000A3 /* CmuxVaultHistory in Frameworks */, 5E55200000000000000000B3 /* CmuxWindowing in Frameworks */, E3B7A3000000000000000103 /* CmuxWorkspaces in Frameworks */, A5001006 /* GhosttyKit.xcframework in Frameworks */, @@ -5451,6 +5468,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C51A700000000000000000A4 /* CmuxSimulator in Frameworks */, C51A700000000000000000B4 /* CmuxSimulatorUI in Frameworks */, C51A700000000000000000C4 /* CmuxSimulatorWorker in Frameworks */, + 9131000000000000000000A4 /* CmuxVaultHistory in Frameworks */, 730600040000000000000001 /* CmuxWindowing in Frameworks */, 5E2701040000000000000003 /* Sentry in Frameworks */, ); @@ -7188,16 +7206,23 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 850000000000000000000016 /* SessionIndexTableView.swift */, 850000000000000000000017 /* SessionIndexSnapshotLoader.swift */, 850000000000000000000018 /* SessionIndexJSONLReader.swift */, - A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */, - 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */, - EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */, + 913100000000000000000101 /* VaultHistoryDateBucket+Presentation.swift */, + 913100000000000000000102 /* VaultHistoryEventKind+Presentation.swift */, BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */, - 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */, + 913100000000000000000103 /* VaultHistoryEventRow.swift */, + 913100000000000000000104 /* VaultHistoryGroup+Presentation.swift */, + 913100000000000000000105 /* VaultHistoryGroupHeader.swift */, + 913100000000000000000106 /* VaultHistoryGroupKey+Presentation.swift */, + 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */, + 913100000000000000000107 /* VaultHistoryRecordingPhase.swift */, C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */, + 913100000000000000000108 /* VaultHistoryStoreLocation.swift */, EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */, 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */, + 913100000000000000000109 /* VaultHistoryWorkspaceCreationContext.swift */, + 91310000000000000000010A /* VaultPaneTab.swift */, + 91310000000000000000010B /* VaultPaneTabButton.swift */, BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */, - 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */, 850000000000000000000019 /* SessionIndexJSONLReadMetrics.swift */, 85000000000000000000001A /* SessionIndexEntryProjection.swift */, 85000000000000000000001B /* SessionIndexTableEnvironmentSnapshot.swift */, @@ -7885,8 +7910,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C06552030000000000000002 /* TabManagerTitleUpdateStalenessTests.swift */, C06552020000000000000002 /* TabManagerTitleUpdateTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, - EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */, - FECFD8A3422AB0455887B40D /* VaultHistoryGroupingTests.swift */, + 913100000000000000000121 /* VaultHistoryEventLogTests.swift */, + 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */, 9492CAFE0000000000000001 /* LastTerminalChildExitRecoveryTests.swift */, C9D9924AC89D7C3408DBC56A /* WorkspaceRecoveryTests.swift */, 939AA681FBA244B994634EE0 /* WorkspaceRecoveryReviewRegressionTests.swift */, @@ -8205,6 +8230,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C617000000000000000000A2 /* CmuxGit */, 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */, C5B6A10000000000000000A2 /* CmuxSidebarGit */, + 9131000000000000000000A2 /* CmuxVaultHistory */, E3B7A30000000000000000B2 /* CmuxSidebar */, E3B7A30000000000000000C2 /* CmuxBrowser */, E3B7A30000000000000000D2 /* CmuxNotifications */, @@ -8326,6 +8352,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D7032A060000000000000002 /* CmuxBrowser */, 5E55500000000000000000E2 /* CmuxCommandPalette */, C0DE4A000000000000000004 /* CmuxSidebarProviderKit */, + 9131000000000000000000A2 /* CmuxVaultHistory */, 5E55200000000000000000B2 /* CmuxWindowing */, CC0DE00000000000000000A2 /* CmuxCore */, A5001262 /* Bonsplit */, @@ -8401,6 +8428,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, + 9131000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxVaultHistory" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, E3B7A30000000000000000D1 /* XCLocalSwiftPackageReference "CmuxNotifications" */, @@ -10171,16 +10199,23 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */, 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */, B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */, - 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */, + 913100000000000000000001 /* VaultHistoryDateBucket+Presentation.swift in Sources */, + 913100000000000000000002 /* VaultHistoryEventKind+Presentation.swift in Sources */, 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */, - A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */, - 54D2FC0F493B36ACF14EB2CA /* VaultHistoryGrouping.swift in Sources */, + 913100000000000000000003 /* VaultHistoryEventRow.swift in Sources */, + 913100000000000000000004 /* VaultHistoryGroup+Presentation.swift in Sources */, + 913100000000000000000005 /* VaultHistoryGroupHeader.swift in Sources */, + 913100000000000000000006 /* VaultHistoryGroupKey+Presentation.swift in Sources */, 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */, - C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */, + 913100000000000000000007 /* VaultHistoryRecordingPhase.swift in Sources */, 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */, + 913100000000000000000008 /* VaultHistoryStoreLocation.swift in Sources */, 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */, 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */, + 913100000000000000000009 /* VaultHistoryWorkspaceCreationContext.swift in Sources */, F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */, + 91310000000000000000000A /* VaultPaneTab.swift in Sources */, + 91310000000000000000000B /* VaultPaneTabButton.swift in Sources */, 437E021ADABFA29ADC68547C /* VaultPaneView.swift in Sources */, F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */, C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */, @@ -11166,8 +11201,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D3284001A1B2C3D4E5F60718 /* TraditionalChineseIMENumpadRegressionTests.swift in Sources */, 468100000000000000000003 /* TypingHotPathRegressionTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, - 59E6509DA68F5DB3550040DC /* VaultHistoryEventStoreTests.swift in Sources */, - 6C5464BB6BC11C869723318F /* VaultHistoryGroupingTests.swift in Sources */, + 913100000000000000000021 /* VaultHistoryEventLogTests.swift in Sources */, + 913100000000000000000022 /* VaultHistorySessionEventProjectionTests.swift in Sources */, A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */, C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */, @@ -11732,6 +11767,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxSidebarGit; }; + 9131000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxVaultHistory" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxVaultHistory; + }; E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxSidebar; @@ -11963,6 +12002,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */; productName = CmuxSidebarGit; }; + 9131000000000000000000A2 /* CmuxVaultHistory */ = { + isa = XCSwiftPackageProductDependency; + package = 9131000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxVaultHistory" */; + productName = CmuxVaultHistory; + }; E3B7A30000000000000000B2 /* CmuxSidebar */ = { isa = XCSwiftPackageProductDependency; package = E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 0ea6f8aa7ec9..c3716ecc2631 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -210,6 +210,9 @@ + + diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift new file mode 100644 index 000000000000..68358ab38c2c --- /dev/null +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -0,0 +1,95 @@ +import CmuxVaultHistory +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct VaultHistoryEventLogTests { + @Test func lifecyclePhasesOnlyAcceptActiveEvents() async { + let store = VaultHistoryEventStore(fileURL: nil) + let log = VaultHistoryEventLog(store: store) + + log.record(event(id: "launch")) + log.transition(to: .active) + log.record(event(id: "active")) + await log.flushPendingRecords() + log.transition(to: .restoring) + log.record(event(id: "restore")) + log.transition(to: .terminating) + log.record(event(id: "terminate")) + await log.flushPendingRecords() + + #expect(log.revision == 1) + #expect(await log.recentEvents().map(\.id) == ["active"]) + } + + @Test func revisionChangesOnlyAfterAcceptedPersistence() async { + let acceptedStore = VaultHistoryEventStore(fileURL: nil) + let acceptedLog = VaultHistoryEventLog(store: acceptedStore, phase: .active) + + acceptedLog.record(event(id: "accepted")) + #expect(acceptedLog.revision == 0) + await acceptedLog.flushPendingRecords() + #expect(acceptedLog.revision == 1) + #expect(await acceptedLog.recentEvents().map(\.id) == ["accepted"]) + + let rejectedStore = VaultHistoryEventStore( + fileURL: URL(fileURLWithPath: "/dev/null/vault-history.jsonl") + ) + let rejectedLog = VaultHistoryEventLog(store: rejectedStore, phase: .active) + rejectedLog.record(event(id: "rejected")) + await rejectedLog.flushPendingRecords() + + #expect(rejectedLog.revision == 0) + #expect(await rejectedLog.recentEvents().isEmpty) + } + + @Test func workspaceRecordingDistinguishesSemanticCreationFromBootstrapAndRestore() async { + let store = VaultHistoryEventStore(fileURL: nil) + let log = VaultHistoryEventLog(store: store, phase: .active) + let windowId = UUID() + let manager = TabManager( + autoWelcomeIfNeeded: false, + windowId: windowId, + vaultHistoryEventLog: log, + initialWorkspaceHistoryContext: .bootstrap + ) + + _ = manager.addWorkspace( + select: false, + autoWelcomeIfNeeded: false, + vaultHistoryContext: .restoration + ) + _ = manager.addWorkspace( + select: false, + autoWelcomeIfNeeded: false, + vaultHistoryContext: .structuralReplacement + ) + let created = manager.addWorkspace( + select: false, + autoWelcomeIfNeeded: false, + vaultHistoryContext: .semanticCreation + ) + await log.flushPendingRecords() + + let events = await log.recentEvents() + #expect(events.count == 1) + #expect(events.first?.kind == .workspaceCreated) + #expect(events.first?.subject.workspaceId == created.id) + #expect(events.first?.subject.windowId == windowId) + } + + private func event(id: String) -> VaultHistoryEvent { + VaultHistoryEvent( + id: id, + timestamp: Date(timeIntervalSince1970: 1_800_000_000), + kind: .workspaceCreated, + title: id + ) + } +} diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift deleted file mode 100644 index d39605846cea..000000000000 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Foundation -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@Suite struct VaultHistoryGroupingTests { - private static func utcCalendar() -> Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC")! - calendar.firstWeekday = 2 - return calendar - } - - /// 2026-06-18 12:00:00 UTC — a Thursday, mid-month, so every date - /// bucket below it is reachable. - private static let now = Date(timeIntervalSince1970: 1_781_784_000) - - private func event( - id: String, - secondsAgo: TimeInterval, - kind: VaultHistoryEventKind = .workspaceCreated, - title: String = "", - workspaceId: UUID? = nil, - windowId: UUID? = nil, - agent: String? = nil - ) -> VaultHistoryEvent { - VaultHistoryEvent( - id: id, - timestamp: Self.now.addingTimeInterval(-secondsAgo), - kind: kind, - title: title, - subject: VaultHistorySubject( - workspaceId: workspaceId, - windowId: windowId, - sessionId: agent == nil ? nil : "session-\(id)", - agent: agent - ) - ) - } - - // MARK: - Date bucketing - - @Test func dateBucketBoundariesAroundLast24Hours() { - let calendar = Self.utcCalendar() - let now = Self.now - - let justInside = now.addingTimeInterval(-24 * 3600 + 60) - #expect(VaultHistoryDateBucket.bucket(for: justInside, now: now, calendar: calendar) == .last24Hours) - - // 24h01m ago is 11:59 the previous day: outside the rolling window, - // inside calendar-yesterday. - let justOutside = now.addingTimeInterval(-24 * 3600 - 60) - #expect(VaultHistoryDateBucket.bucket(for: justOutside, now: now, calendar: calendar) == .yesterday) - - #expect(VaultHistoryDateBucket.bucket(for: now, now: now, calendar: calendar) == .last24Hours) - } - - @Test func dateBucketCalendarBuckets() { - let calendar = Self.utcCalendar() - let now = Self.now - - // Tuesday 2026-06-16 08:00 UTC: same Monday-start week as Thursday - // the 18th, older than calendar-yesterday. - let sameWeek = Date(timeIntervalSince1970: 1_781_596_800) - #expect(VaultHistoryDateBucket.bucket(for: sameWeek, now: now, calendar: calendar) == .thisWeek) - - // 2026-06-06: same month, previous week. - let sameMonth = Date(timeIntervalSince1970: 1_780_704_000) - #expect(VaultHistoryDateBucket.bucket(for: sameMonth, now: now, calendar: calendar) == .thisMonth) - - // 2026-05-18: previous month. - let older = Date(timeIntervalSince1970: 1_779_105_600) - #expect(VaultHistoryDateBucket.bucket(for: older, now: now, calendar: calendar) == .older) - } - - @Test func dateGroupingOrdersBucketsNewestFirstAndSkipsEmptyBuckets() { - let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) - let events = [ - event(id: "older", secondsAgo: 40 * 24 * 3600), - event(id: "recent", secondsAgo: 600), - event(id: "yesterday", secondsAgo: 25 * 3600), - ] - let groups = grouper.groups(events: events, by: .date, now: Self.now) - - #expect(groups.map(\.id) == [ - "date:\(VaultHistoryDateBucket.last24Hours.rawValue)", - "date:\(VaultHistoryDateBucket.yesterday.rawValue)", - "date:\(VaultHistoryDateBucket.older.rawValue)", - ]) - #expect(groups[0].events.map(\.id) == ["recent"]) - #expect(groups[1].events.map(\.id) == ["yesterday"]) - #expect(groups[2].events.map(\.id) == ["older"]) - } - - // MARK: - Group by workspace - - @Test func workspaceGroupingClustersByIdAndOrdersByRecency() { - let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) - let workspaceA = UUID() - let workspaceB = UUID() - let events = [ - event(id: "a-old", secondsAgo: 500, kind: .workspaceCreated, title: "Old name", workspaceId: workspaceA), - event(id: "b-only", secondsAgo: 100, kind: .workspaceClosed, title: "Beta", workspaceId: workspaceB), - event(id: "a-new", secondsAgo: 50, kind: .workspaceRenamed, title: "New name", workspaceId: workspaceA), - event(id: "no-workspace", secondsAgo: 10, kind: .windowOpened), - ] - let groups = grouper.groups(events: events, by: .workspace, now: Self.now) - - #expect(groups.count == 3) - // Groups ordered by their newest event; "Other" always trails. - #expect(groups[0].id == "workspace:\(workspaceA.uuidString)") - #expect(groups[1].id == "workspace:\(workspaceB.uuidString)") - #expect(groups[2].id == VaultHistoryGrouper.otherGroupID) - // Events inside a group stay newest-first. - #expect(groups[0].events.map(\.id) == ["a-new", "a-old"]) - // Group title follows the newest event (the rename's new name). - #expect(groups[0].title == "New name") - #expect(groups[2].events.map(\.id) == ["no-workspace"]) - } - - @Test func windowGroupingClustersByWindowId() { - let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) - let window = UUID() - let events = [ - event(id: "w-open", secondsAgo: 200, kind: .windowOpened, windowId: window), - event(id: "w-close", secondsAgo: 20, kind: .windowClosed, title: "Main", windowId: window), - event(id: "session", secondsAgo: 10, kind: .sessionActivity, title: "Fix bug", agent: "claude"), - ] - let groups = grouper.groups(events: events, by: .window, now: Self.now) - - #expect(groups.map(\.id) == ["window:\(window.uuidString)", VaultHistoryGrouper.otherGroupID]) - #expect(groups[0].events.map(\.id) == ["w-close", "w-open"]) - } - - // MARK: - Group by agent and kind - - @Test func agentGroupingSeparatesAgentsFromAppEvents() { - let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) - let events = [ - event(id: "claude-1", secondsAgo: 30, kind: .sessionActivity, title: "s1", agent: "claude"), - event(id: "codex-1", secondsAgo: 20, kind: .sessionActivity, title: "s2", agent: "codex"), - event(id: "ws", secondsAgo: 10, kind: .workspaceCreated, title: "ws"), - event(id: "claude-2", secondsAgo: 5, kind: .sessionActivity, title: "s3", agent: "claude"), - ] - let groups = grouper.groups(events: events, by: .agent, now: Self.now) - - #expect(groups.map(\.id) == ["agent:claude", "agent:codex", VaultHistoryGrouper.otherGroupID]) - #expect(groups[0].events.map(\.id) == ["claude-2", "claude-1"]) - #expect(groups[2].events.map(\.id) == ["ws"]) - } - - @Test func kindGroupingUsesEventKindOrderedByRecency() { - let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) - let events = [ - event(id: "c1", secondsAgo: 300, kind: .workspaceCreated), - event(id: "x1", secondsAgo: 200, kind: .workspaceClosed), - event(id: "c2", secondsAgo: 100, kind: .workspaceCreated), - ] - let groups = grouper.groups(events: events, by: .kind, now: Self.now) - - #expect(groups.map(\.id) == ["kind:workspaceCreated", "kind:workspaceClosed"]) - #expect(groups[0].events.map(\.id) == ["c2", "c1"]) - } - - // MARK: - Session projection - - @Test func sessionProjectionProducesStableDerivedEvents() throws { - let entry = SessionEntry( - id: "abc123", - agent: .claude, - sessionId: "abc123", - title: "Fix the tests", - cwd: "/tmp/repo", - gitBranch: nil, - pullRequest: nil, - modified: Date(timeIntervalSince1970: 1_700_000_000), - fileURL: nil, - specifics: .claude(model: nil, permissionMode: nil, configDirectoryForResume: nil) - ) - let projection = VaultHistorySessionEventProjection() - - let first = projection.events(from: [entry]) - let second = projection.events(from: [entry]) - #expect(first == second) - - let event = try #require(first.first) - #expect(event.kind == .sessionActivity) - #expect(event.timestamp == Date(timeIntervalSince1970: 1_700_000_000)) - #expect(event.title == "Fix the tests") - #expect(event.subject.agent == "claude") - #expect(event.subject.sessionId == "abc123") - #expect(event.subject.directory == "/tmp/repo") - } -} diff --git a/cmuxTests/VaultHistorySessionEventProjectionTests.swift b/cmuxTests/VaultHistorySessionEventProjectionTests.swift new file mode 100644 index 000000000000..67e6655e08e1 --- /dev/null +++ b/cmuxTests/VaultHistorySessionEventProjectionTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct VaultHistorySessionEventProjectionTests { + @Test func projectionProducesStableDerivedEvents() throws { + let entry = SessionEntry( + id: "abc123", + agent: .claude, + sessionId: "abc123", + title: "Fix the tests", + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: 1_700_000_000), + fileURL: nil, + specifics: .claude( + model: nil, + permissionMode: nil, + configDirectoryForResume: nil + ) + ) + let projection = VaultHistorySessionEventProjection() + + let first = projection.events(from: [entry]) + let second = projection.events(from: [entry]) + #expect(first == second) + + let event = try #require(first.first) + #expect(event.id == "session:claude:abc123") + #expect(event.kind == .sessionActivity) + #expect(event.timestamp == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(event.title == "Fix the tests") + #expect(event.subject.agent == "claude") + #expect(event.subject.sessionId == "abc123") + #expect(event.subject.directory == "/tmp/repo") + } +} From b70ed9e08032575176414ec76756c425b2fd7e15 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:41:37 -0700 Subject: [PATCH 06/76] Flush Vault History before app termination --- Sources/AppDelegate.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index dff5c6c915a6..a8ead40d85dd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1939,12 +1939,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func deferTerminateForOwnedCleanup(reason: String) -> Bool { let markedForKill = remoteTmuxController.windowsMarkedForKillOnClose() let simulatorCleanupTasks = SimulatorPanel.beginApplicationTerminationCleanup() - guard !markedForKill.isEmpty || !simulatorCleanupTasks.isEmpty else { return false } + let historyEventLog = vaultHistoryEventLog + guard historyEventLog != nil || !markedForKill.isEmpty || !simulatorCleanupTasks.isEmpty else { + return false + } if !isAwaitingTerminateCleanup { isAwaitingTerminateCleanup = true StartupBreadcrumbLog.append( "appDelegate.shouldTerminate.cleanupLater", fields: [ + "history": historyEventLog == nil ? "0" : "1", "windows": String(markedForKill.count), "simulatorPanels": String(simulatorCleanupTasks.count), "reason": reason, @@ -1952,6 +1956,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) let cleanupTask = Task { @MainActor [weak self] in guard let self else { return } + await historyEventLog?.flushPendingRecords() + guard !Task.isCancelled else { return } if !markedForKill.isEmpty { await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() } From 18b3ca86b362a4a4152eea693b1b1548456935ee Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 8 Aug 2026 18:46:38 -0700 Subject: [PATCH 07/76] Defer termination only for pending History writes --- Sources/AppDelegate.swift | 11 ++++++---- Sources/VaultHistoryEventLog.swift | 25 ++++++++++++++++++----- cmuxTests/VaultHistoryEventLogTests.swift | 4 ++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a8ead40d85dd..bc491d379f27 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1940,7 +1940,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let markedForKill = remoteTmuxController.windowsMarkedForKillOnClose() let simulatorCleanupTasks = SimulatorPanel.beginApplicationTerminationCleanup() let historyEventLog = vaultHistoryEventLog - guard historyEventLog != nil || !markedForKill.isEmpty || !simulatorCleanupTasks.isEmpty else { + let hasPendingHistoryRecords = historyEventLog?.hasPendingRecords == true + guard hasPendingHistoryRecords || !markedForKill.isEmpty || !simulatorCleanupTasks.isEmpty else { return false } if !isAwaitingTerminateCleanup { @@ -1948,7 +1949,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent StartupBreadcrumbLog.append( "appDelegate.shouldTerminate.cleanupLater", fields: [ - "history": historyEventLog == nil ? "0" : "1", + "history": hasPendingHistoryRecords ? "1" : "0", "windows": String(markedForKill.count), "simulatorPanels": String(simulatorCleanupTasks.count), "reason": reason, @@ -1956,8 +1957,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) let cleanupTask = Task { @MainActor [weak self] in guard let self else { return } - await historyEventLog?.flushPendingRecords() - guard !Task.isCancelled else { return } + if hasPendingHistoryRecords { + await historyEventLog?.flushPendingRecords() + guard !Task.isCancelled else { return } + } if !markedForKill.isEmpty { await self.remoteTmuxController.killMarkedSessionsBeforeTerminate() } diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index 974843c18a4c..76d69ab4d338 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -16,6 +16,12 @@ final class VaultHistoryEventLog { private let store: any VaultHistoryEventStoring private var pendingRecordTask: Task? + private var pendingRecordCount = 0 + + /// Whether accepted records are still queued or being persisted. + var hasPendingRecords: Bool { + pendingRecordCount > 0 + } init( store: any VaultHistoryEventStoring, @@ -33,14 +39,23 @@ final class VaultHistoryEventLog { guard phase == .active else { return } let store = store let previous = pendingRecordTask + pendingRecordCount += 1 pendingRecordTask = Task(priority: .utility) { [weak self] in await previous?.value - guard !Task.isCancelled, - await store.append(event), - let self else { - return + let didAppend: Bool + if Task.isCancelled { + didAppend = false + } else { + didAppend = await store.append(event) + } + guard let self else { return } + if didAppend { + self.revision &+= 1 + } + self.pendingRecordCount -= 1 + if self.pendingRecordCount == 0 { + self.pendingRecordTask = nil } - self.revision &+= 1 } } diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 68358ab38c2c..249449fc2848 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -33,8 +33,10 @@ import Testing let acceptedLog = VaultHistoryEventLog(store: acceptedStore, phase: .active) acceptedLog.record(event(id: "accepted")) + #expect(acceptedLog.hasPendingRecords) #expect(acceptedLog.revision == 0) await acceptedLog.flushPendingRecords() + #expect(!acceptedLog.hasPendingRecords) #expect(acceptedLog.revision == 1) #expect(await acceptedLog.recentEvents().map(\.id) == ["accepted"]) @@ -43,8 +45,10 @@ import Testing ) let rejectedLog = VaultHistoryEventLog(store: rejectedStore, phase: .active) rejectedLog.record(event(id: "rejected")) + #expect(rejectedLog.hasPendingRecords) await rejectedLog.flushPendingRecords() + #expect(!rejectedLog.hasPendingRecords) #expect(rejectedLog.revision == 0) #expect(await rejectedLog.recentEvents().isEmpty) } From 170519a30231b0d70cff457fa96603480dcd515e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 01:17:15 -0700 Subject: [PATCH 08/76] Fix inherited Freestyle SDK test drift --- web/tests/vm-devbox-image.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 6e50eefe36d1..aecfa1b13965 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -369,7 +369,7 @@ describe("devbox image template", () => { const packageJson = JSON.parse( readFileSync(path.join(import.meta.dirname, "../package.json"), "utf8"), ) as { dependencies: Record }; - expect(packageJson.dependencies.freestyle).toBe("0.2.9"); + expect(packageJson.dependencies.freestyle).toBe("0.2.10"); expect(packageJson.dependencies["freestyle-beta"]).toBeUndefined(); }); From 5c92ff9b60d7cc61abd62207d48e5272375d6c2b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 01:38:42 -0700 Subject: [PATCH 09/76] Address current-main CI and Vault History review --- .../CmuxVaultHistory/VaultHistoryEvent.swift | 32 +++++++ .../VaultHistoryEventStore.swift | 96 +++++++------------ .../VaultHistoryEventStoring.swift | 5 +- .../VaultHistoryEventStoreTests.swift | 33 +++++++ .../VaultHistoryGrouperTests.swift | 17 ++++ Sources/RightSidebarPanelView.swift | 3 +- Sources/RightSidebarToolPanel.swift | 3 +- Sources/SessionIndexStore.swift | 34 ++++++- Sources/TabManager+WorkspaceCustomTitle.swift | 15 +-- Sources/VaultHistoryEventRow.swift | 4 +- Sources/VaultHistorySessionEvents.swift | 27 +++--- Sources/VaultHistoryTimelineModel.swift | 15 ++- Sources/VaultHistoryView.swift | 40 +++++--- Sources/VaultPaneView.swift | 10 +- .../SessionIndexSnapshotLoaderTests.swift | 36 +++++++ cmuxTests/VaultHistoryEventLogTests.swift | 34 +++++++ 16 files changed, 292 insertions(+), 112 deletions(-) diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift index d05557898e55..faf60d6e2f7e 100644 --- a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEvent.swift @@ -57,4 +57,36 @@ public struct VaultHistoryEvent: Identifiable, Hashable, Codable, Sendable { } return lhs.id > rhs.id } + + /// Linearly merges two newest-first snapshots while preserving their order. + /// + /// - Parameters: + /// - lhs: First snapshot, ordered by ``newestFirst(_:_:)``. + /// - rhs: Second snapshot, ordered by ``newestFirst(_:_:)``. + /// - limit: Maximum number of merged events to return. + /// - Returns: At most `limit` events in deterministic newest-first order. + public static func mergeNewestFirst( + _ lhs: [Self], + _ rhs: [Self], + limit: Int = .max + ) -> [Self] { + let maximumCount = max(0, limit) + var merged: [Self] = [] + merged.reserveCapacity(min(maximumCount, lhs.count + rhs.count)) + var lhsIndex = lhs.startIndex + var rhsIndex = rhs.startIndex + + while merged.count < maximumCount + && (lhsIndex < lhs.endIndex || rhsIndex < rhs.endIndex) { + if rhsIndex >= rhs.endIndex + || (lhsIndex < lhs.endIndex && !newestFirst(rhs[rhsIndex], lhs[lhsIndex])) { + merged.append(lhs[lhsIndex]) + lhs.formIndex(after: &lhsIndex) + } else { + merged.append(rhs[rhsIndex]) + rhs.formIndex(after: &rhsIndex) + } + } + return merged + } } diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift index 0685dc3ea32e..6f6be657f5cf 100644 --- a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift @@ -6,9 +6,7 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { private let retention: VaultHistoryRetentionPolicy private let fileManager: FileManager private var didLoad = false - /// Append order (oldest to newest), matching the on-disk JSONL layout. - private var events: [VaultHistoryEvent] = [] - /// Read order, updated incrementally so refreshes never re-sort the store. + /// Retention and read order, updated incrementally so refreshes never re-sort. private var newestFirstEvents: [VaultHistoryEvent] = [] private var fileBytes = 0 @@ -28,27 +26,27 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { self.fileManager = fileManager } - /// Persists one event and adds it to the in-memory snapshot only after acceptance. + /// Processes one event through persistence and timestamp-based retention. /// /// - Parameter event: Immutable event to append. - /// - Returns: `true` when the event was accepted by memory-only storage or persisted. + /// - Returns: `true` when storage accepted the mutation. An event older + /// than the retained timestamp window can be evicted immediately. public func append(_ event: VaultHistoryEvent) async -> Bool { loadIfNeeded() guard let line = encodedLine(for: event), line.count <= retention.maxFileBytes else { return false } if let fileURL { - if fileBytes + line.count > retention.maxFileBytes { + let preservesChronologicalFileOrder = newestFirstEvents.first.map { + VaultHistoryEvent.newestFirst(event, $0) + } ?? true + if fileBytes + line.count > retention.maxFileBytes + || !preservesChronologicalFileOrder { let snapshot = compactedSnapshot(including: event) - let orderedSnapshot = newestFirstSnapshot( - including: event, - retaining: snapshot.events - ) guard writeCompactedData(snapshot.data, to: fileURL) else { return false } - events = snapshot.events - newestFirstEvents = orderedSnapshot + newestFirstEvents = snapshot.events fileBytes = snapshot.data.count return true } else { @@ -59,13 +57,8 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { } } - events.append(event) insertNewestFirst(event) - for removedEvent in trimMemoryIfNeeded() { - if let index = newestFirstEvents.firstIndex(of: removedEvent) { - newestFirstEvents.remove(at: index) - } - } + trimMemoryIfNeeded() return true } @@ -131,11 +124,11 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { } loadedEvents.append(event) } - if loadedEvents.count > retention.maxStoredEvents { - loadedEvents.removeFirst(loadedEvents.count - retention.maxStoredEvents) - } - events = loadedEvents - newestFirstEvents = loadedEvents.sorted(by: VaultHistoryEvent.newestFirst) + newestFirstEvents = Array( + loadedEvents + .sorted(by: VaultHistoryEvent.newestFirst) + .prefix(retention.maxStoredEvents) + ) } private func encodedLine(for event: VaultHistoryEvent) -> Data? { @@ -175,16 +168,19 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { private func compactedSnapshot( including event: VaultHistoryEvent ) -> (events: [VaultHistoryEvent], data: Data) { - var candidates = events - candidates.append(event) + var candidates = newestFirstEvents + let insertionIndex = candidates.firstIndex { + VaultHistoryEvent.newestFirst(event, $0) + } ?? candidates.endIndex + candidates.insert(event, at: insertionIndex) if candidates.count > retention.maxStoredEvents { - candidates.removeFirst(candidates.count - retention.maxStoredEvents) + candidates.removeLast(candidates.count - retention.maxStoredEvents) } - var retainedNewestFirst: [VaultHistoryEvent] = [] - var retainedLinesNewestFirst: [Data] = [] + var retainedEvents: [VaultHistoryEvent] = [] + var retainedLines: [Data] = [] var retainedBytes = 0 - for candidate in candidates.reversed() { + for candidate in candidates { guard let line = encodedLine(for: candidate), line.count <= retention.maxFileBytes else { continue @@ -192,16 +188,18 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { guard retainedBytes + line.count <= retention.maxFileBytes else { break } - retainedNewestFirst.append(candidate) - retainedLinesNewestFirst.append(line) + retainedEvents.append(candidate) + retainedLines.append(line) retainedBytes += line.count } var data = Data(capacity: retainedBytes) - for line in retainedLinesNewestFirst.reversed() { + // JSONL stays chronological so bounded tail reads always contain the + // newest retained records, even after an out-of-order append. + for line in retainedLines.reversed() { data.append(line) } - return (Array(retainedNewestFirst.reversed()), data) + return (retainedEvents, data) } private func writeCompactedData(_ data: Data, to fileURL: URL) -> Bool { @@ -224,34 +222,10 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { newestFirstEvents.insert(event, at: index) } - private func newestFirstSnapshot( - including event: VaultHistoryEvent, - retaining retainedEvents: [VaultHistoryEvent] - ) -> [VaultHistoryEvent] { - var ordered = newestFirstEvents - let insertionIndex = ordered.firstIndex { - VaultHistoryEvent.newestFirst(event, $0) - } ?? ordered.endIndex - ordered.insert(event, at: insertionIndex) - - var remaining = Dictionary( - retainedEvents.map { ($0, 1) }, - uniquingKeysWith: + + private func trimMemoryIfNeeded() { + guard newestFirstEvents.count > retention.maxStoredEvents else { return } + newestFirstEvents.removeLast( + newestFirstEvents.count - retention.maxStoredEvents ) - return ordered.filter { candidate in - guard let count = remaining[candidate], count > 0 else { - return false - } - remaining[candidate] = count - 1 - return true - } - } - - private func trimMemoryIfNeeded() -> [VaultHistoryEvent] { - guard events.count > retention.maxStoredEvents else { return [] } - let removalCount = events.count - retention.maxStoredEvents - let removedEvents = Array(events.prefix(removalCount)) - events.removeFirst(removalCount) - return removedEvents } } diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift index fef2818cce94..ffe51a765718 100644 --- a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStoring.swift @@ -2,10 +2,11 @@ import Foundation /// Persistence boundary consumed by the app-owned History coordinator. public protocol VaultHistoryEventStoring: Sendable { - /// Persists one event before making it visible to readers. + /// Processes one event through persistence and bounded retention. /// /// - Parameter event: Immutable event to append. - /// - Returns: `true` when the event was accepted and is now readable. + /// - Returns: `true` when storage accepted the mutation. An event older + /// than the retained timestamp window can be evicted immediately. func append(_ event: VaultHistoryEvent) async -> Bool /// Returns the newest persisted events in deterministic order. diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift index 199d10dc2e4b..52638949b732 100644 --- a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift @@ -118,6 +118,39 @@ import Testing #expect(await reloaded.recentEvents().map(\.id) == expectedIDs) } + @Test func retentionKeepsNewestTimestampsAfterOutOfOrderAppends() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let retention = VaultHistoryRetentionPolicy( + maxStoredEvents: 2, + maxFileBytes: 1_024, + maxLoadBytes: 4_096 + ) + let stores = [ + VaultHistoryEventStore(fileURL: nil, retention: retention), + VaultHistoryEventStore(fileURL: fileURL, retention: retention), + ] + let appendedEvents = [100, 200, 1].map { timestamp in + VaultHistoryEvent( + id: "t\(timestamp)", + timestamp: Date(timeIntervalSince1970: TimeInterval(timestamp)), + kind: .workspaceCreated, + title: "workspace-\(timestamp)" + ) + } + + for store in stores { + for event in appendedEvents { + #expect(await store.append(event)) + } + #expect(await store.recentEvents().map(\.id) == ["t200", "t100"]) + } + + let reloaded = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + #expect(await reloaded.recentEvents().map(\.id) == ["t200", "t100"]) + } + @Test func oversizedEventIsRejectedWithoutDisplacingReadableHistory() async throws { let fileURL = try makeTempFileURL() defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift index de9454d54b8b..139b9ab5ea30 100644 --- a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryGrouperTests.swift @@ -154,4 +154,21 @@ import Testing == grouper.groups(events: events, by: .date, now: Self.now) ) } + + @Test func newestFirstMergeIsLinearOrderedAndBounded() { + let lhs = [ + event(id: "tie-a", secondsAgo: 10), + event(id: "old", secondsAgo: 60), + ].sorted(by: VaultHistoryEvent.newestFirst) + let rhs = [ + event(id: "tie-z", secondsAgo: 10), + event(id: "middle", secondsAgo: 30), + ].sorted(by: VaultHistoryEvent.newestFirst) + + #expect( + VaultHistoryEvent.mergeNewestFirst(lhs, rhs, limit: 3).map(\.id) + == ["tie-z", "tie-a", "middle"] + ) + #expect(VaultHistoryEvent.mergeNewestFirst(lhs, rhs, limit: 0).isEmpty) + } } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 3179cf35bc14..cc436ad1f2c2 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -435,7 +435,8 @@ struct RightSidebarPanelView: View { onFocus: { entry in _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) }, - historyLog: historyLog + historyLog: historyLog, + chromeBackgroundColor: windowAppearance.resolvedChromeBackgroundColor ) } else { SessionIndexView( diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index ed577159ad94..56869e919e84 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -301,7 +301,8 @@ struct RightSidebarToolPanelView: View { onFocus: { entry in _ = SessionEntryResumeCoordinator.focusIfActive(entry, tabManager: tabManager) }, - historyLog: historyLog + historyLog: historyLog, + chromeBackgroundColor: resolvedChromeBackgroundColor ) } else { SessionIndexView( diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 946c697d5864..a5225f83cbe5 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -661,22 +661,52 @@ final class SessionIndexStore: ObservableObject { } private var loadTask: Task? + private var loadGeneration = 0 func reload() { + _ = beginReload() + } + + /// Reloads the durable agent indexes and returns only after the newest + /// requested reload has published its authoritative entry snapshot. + func reloadAndWaitForFreshEntries() async -> [SessionEntry] { + var pending = beginReload() + while true { + await pending.task.value + guard pending.generation != loadGeneration else { + return entries + } + guard let currentTask = loadTask else { + return entries + } + pending = (loadGeneration, currentTask) + } + } + + private func beginReload() -> (generation: Int, task: Task) { loadTask?.cancel() isLoading = true + loadGeneration &+= 1 + let generation = loadGeneration directorySnapshotGeneration += 1 invalidateDirectorySnapshots() let snapshotLoader = self.snapshotLoader - loadTask = Task { @MainActor [weak self] in + let task = Task { @MainActor [weak self] in let scanned = await snapshotLoader.load() - guard let self, !Task.isCancelled else { return } + guard let self, + !Task.isCancelled, + self.loadGeneration == generation else { + return + } self.entries = scanned self.isLoading = false + self.loadTask = nil self.backfillAgentOrderFromEntries() self.backfillDirectoryOrderFromEntries() self.refreshLiveSessionKeys() } + loadTask = task + return (generation, task) } #if DEBUG diff --git a/Sources/TabManager+WorkspaceCustomTitle.swift b/Sources/TabManager+WorkspaceCustomTitle.swift index 9526506bdc35..7d1e92930f37 100644 --- a/Sources/TabManager+WorkspaceCustomTitle.swift +++ b/Sources/TabManager+WorkspaceCustomTitle.swift @@ -29,16 +29,17 @@ extension TabManager { propagateToRemoteTmux: Bool = true ) -> Bool { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return false } - let previousDisplayTitle = resolvedWorkspaceDisplayTitle(for: tabs[index]) + let workspace = tabs[index] + let previousDisplayTitle = resolvedWorkspaceDisplayTitle(for: workspace) .trimmingCharacters(in: .whitespacesAndNewlines) - let applied = tabs[index].setCustomTitle(title, source: source) + let applied = workspace.setCustomTitle(title, source: source) if applied { - recordWorkspaceCustomTitle(tabs[index], source: source) + recordWorkspaceCustomTitle(workspace, source: source) } if applied, selectedTabId == tabId { - updateWindowTitle(for: tabs[index]) + updateWindowTitle(for: workspace) } - let currentDisplayTitle = resolvedWorkspaceDisplayTitle(for: tabs[index]) + let currentDisplayTitle = resolvedWorkspaceDisplayTitle(for: workspace) .trimmingCharacters(in: .whitespacesAndNewlines) if applied, currentDisplayTitle != previousDisplayTitle { NotificationCenter.default.post( @@ -48,7 +49,7 @@ extension TabManager { ) if source == .user { recordVaultHistoryWorkspaceRenamed( - tabs[index], + workspace, previousTitle: previousDisplayTitle, currentTitle: currentDisplayTitle ) @@ -57,7 +58,7 @@ extension TabManager { // A remote tmux mirror workspace rename propagates to `rename-session`, // but only when the write landed (an `.auto` write rejected over a // user-set title must not desync the remote session name). - if applied, propagateToRemoteTmux, tabs[index].isRemoteTmuxMirror { + if applied, propagateToRemoteTmux, workspace.isRemoteTmuxMirror { AppDelegate.shared?.remoteTmuxController.handleMirrorWorkspaceRenamed( workspaceId: tabId, title: title diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index 5a5c556c7b30..cdee7990af19 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -47,9 +47,9 @@ struct VaultHistoryEventRow: View, Equatable { guard trimmed.isEmpty else { return trimmed } switch event.kind { case .windowOpened, .windowClosed: - String(localized: "vaultHistory.window", defaultValue: "Window") + return String(localized: "vaultHistory.window", defaultValue: "Window") default: - String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + return String(localized: "vaultHistory.untitled", defaultValue: "Untitled") } } diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift index e86fe62245b1..014b0e71fdcd 100644 --- a/Sources/VaultHistorySessionEvents.swift +++ b/Sources/VaultHistorySessionEvents.swift @@ -7,19 +7,22 @@ import Foundation /// on-disk stores, so the timeline derives them instead of double-writing: /// whatever the session index covers automatically appears in History. struct VaultHistorySessionEventProjection: Sendable { + /// Returns projected session events in deterministic newest-first order. func events(from entries: [SessionEntry]) -> [VaultHistoryEvent] { - entries.map { entry in - VaultHistoryEvent( - id: "session:\(entry.agent.rawValue):\(entry.id)", - timestamp: entry.modified, - kind: .sessionActivity, - title: entry.title, - subject: VaultHistorySubject( - sessionId: entry.sessionId, - agent: entry.agent.rawValue, - directory: entry.cwd + entries + .map { entry in + VaultHistoryEvent( + id: "session:\(entry.agent.rawValue):\(entry.id)", + timestamp: entry.modified, + kind: .sessionActivity, + title: entry.title, + subject: VaultHistorySubject( + sessionId: entry.sessionId, + agent: entry.agent.rawValue, + directory: entry.cwd + ) ) - ) - } + } + .sorted(by: VaultHistoryEvent.newestFirst) } } diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 6ff650156d8a..c97818f39a36 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -61,15 +61,12 @@ final class VaultHistoryTimelineModel { refreshTask = Task { [weak self] in let recorded = await log.recentEvents() guard !Task.isCancelled, let self else { return } - var merged = recorded - merged.append(contentsOf: self.projection.events(from: sessionEntries)) - // Cache one bounded ordered snapshot per refresh. Group changes - // reuse it without another O(n log n) pass. - merged.sort(by: VaultHistoryEvent.newestFirst) - if merged.count > Self.maxTimelineEvents { - merged.removeLast(merged.count - Self.maxTimelineEvents) - } - self.mergedEvents = merged + let projectedSessions = self.projection.events(from: sessionEntries) + self.mergedEvents = VaultHistoryEvent.mergeNewestFirst( + recorded, + projectedSessions, + limit: Self.maxTimelineEvents + ) self.isLoading = false self.didLoad = true self.regroup() diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index c7bcef54ee12..d9ab23f77f26 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxVaultHistory import CmuxFoundation import SwiftUI @@ -6,16 +7,27 @@ import SwiftUI struct VaultHistoryView: View { @ObservedObject var sessionStore: SessionIndexStore let log: VaultHistoryEventLog + let chromeBackgroundColor: NSColor @State private var model: VaultHistoryTimelineModel - init(sessionStore: SessionIndexStore, log: VaultHistoryEventLog) { + init( + sessionStore: SessionIndexStore, + log: VaultHistoryEventLog, + chromeBackgroundColor: NSColor + ) { self.sessionStore = sessionStore self.log = log + self.chromeBackgroundColor = chromeBackgroundColor _model = State(initialValue: VaultHistoryTimelineModel(log: log)) } var body: some View { - VaultHistoryContentView(sessionStore: sessionStore, log: log, model: model) + VaultHistoryContentView( + sessionStore: sessionStore, + log: log, + model: model, + chromeBackgroundColor: chromeBackgroundColor + ) } } @@ -23,6 +35,9 @@ private struct VaultHistoryContentView: View { @ObservedObject var sessionStore: SessionIndexStore let log: VaultHistoryEventLog let model: VaultHistoryTimelineModel + let chromeBackgroundColor: NSColor + @State private var sessionReloadGeneration = 0 + @State private var hasFreshSessionSnapshot = false var body: some View { VStack(spacing: 0) { @@ -35,19 +50,18 @@ private struct VaultHistoryContentView: View { timelineList } } - .onAppear { - // History projects session activity from the durable agent indexes. - // Refresh on every mount so a warm in-memory cache cannot hide - // sessions written while this tab was not visible. - if !sessionStore.isLoading { - sessionStore.reload() - } - model.refresh(sessionEntries: sessionStore.entries) + .task(id: sessionReloadGeneration) { + let entries = await sessionStore.reloadAndWaitForFreshEntries() + guard !Task.isCancelled else { return } + hasFreshSessionSnapshot = true + model.refresh(sessionEntries: entries) } .onChange(of: sessionStore.entries) { _, entries in + guard hasFreshSessionSnapshot else { return } model.refresh(sessionEntries: entries) } .onChange(of: log.revision) { _, _ in + guard hasFreshSessionSnapshot else { return } model.refresh(sessionEntries: sessionStore.entries) } } @@ -92,8 +106,8 @@ private struct VaultHistoryContentView: View { Spacer(minLength: 4) Button { - sessionStore.reload() - model.refresh(sessionEntries: sessionStore.entries) + hasFreshSessionSnapshot = false + sessionReloadGeneration &+= 1 } label: { Image(systemName: "arrow.clockwise") .cmuxFont(size: 10, weight: .medium) @@ -105,7 +119,7 @@ private struct VaultHistoryContentView: View { .titlebarInteractiveControl() } .rightSidebarChromeBar() - .rightSidebarChromeBottomBorder() + .rightSidebarChromeBottomBorder(backgroundColor: chromeBackgroundColor) } private var reloadLabel: String { diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 237f613e7169..32b3eb66278f 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxFoundation import SwiftUI @@ -11,6 +12,7 @@ struct VaultPaneView: View { let activeSessionKeys: Set let onFocus: ((SessionEntry) -> Void)? let historyLog: VaultHistoryEventLog + let chromeBackgroundColor: NSColor @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.sessions.rawValue private var selectedTab: VaultPaneTab { @@ -30,7 +32,11 @@ struct VaultPaneView: View { onFocus: onFocus ) case .history: - VaultHistoryView(sessionStore: store, log: historyLog) + VaultHistoryView( + sessionStore: store, + log: historyLog, + chromeBackgroundColor: chromeBackgroundColor + ) } } } @@ -45,6 +51,6 @@ struct VaultPaneView: View { Spacer(minLength: 0) } .rightSidebarChromeBar() - .rightSidebarChromeBottomBorder() + .rightSidebarChromeBottomBorder(backgroundColor: chromeBackgroundColor) } } diff --git a/cmuxTests/SessionIndexSnapshotLoaderTests.swift b/cmuxTests/SessionIndexSnapshotLoaderTests.swift index 0c4791b149e7..c7e9ca2a8563 100644 --- a/cmuxTests/SessionIndexSnapshotLoaderTests.swift +++ b/cmuxTests/SessionIndexSnapshotLoaderTests.swift @@ -9,6 +9,23 @@ import Testing @Suite struct SessionIndexSnapshotLoaderTests { + @MainActor + @Test + func reloadAndWaitReplacesAWarmCacheWithTheDurableSnapshot() async { + let stale = entry(id: "stale", modified: 100) + let fresh = entry(id: "fresh", modified: 200) + let store = SessionIndexStore( + snapshotLoader: SessionIndexSnapshotLoader { [fresh] in [fresh] } + ) + store.replaceEntriesForTesting([stale]) + + let entries = await store.reloadAndWaitForFreshEntries() + + #expect(entries.map(\.id) == ["fresh"]) + #expect(store.entries.map(\.id) == ["fresh"]) + #expect(!store.isLoading) + } + @MainActor @Test func twoThousandTranscriptCorpusScansOffMainActor() async throws { @@ -25,4 +42,23 @@ struct SessionIndexSnapshotLoaderTests { #expect(entries.count == 2_000) #expect(entries.allSatisfy { $0.title == "off-main" }) } + + private func entry(id: String, modified: TimeInterval) -> SessionEntry { + SessionEntry( + id: id, + agent: .claude, + sessionId: id, + title: id, + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Date(timeIntervalSince1970: modified), + fileURL: nil, + specifics: .claude( + model: nil, + permissionMode: nil, + configDirectoryForResume: nil + ) + ) + } } diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 249449fc2848..c60a2392d82f 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -88,6 +88,40 @@ import Testing #expect(events.first?.subject.windowId == windowId) } + @Test func renameRecordingSurvivesSynchronousWorkspaceRemovalObserver() async throws { + let store = VaultHistoryEventStore(fileURL: nil) + let log = VaultHistoryEventLog(store: store, phase: .active) + let manager = TabManager( + autoWelcomeIfNeeded: false, + vaultHistoryEventLog: log, + initialWorkspaceHistoryContext: .bootstrap + ) + let renamedWorkspace = try #require(manager.selectedWorkspace) + _ = try #require(manager.addWorkspaceIfActive( + select: false, + autoWelcomeIfNeeded: false, + vaultHistoryContext: .structuralReplacement + )) + + let observer = NotificationCenter.default.addObserver( + forName: .workspaceTitleDidChange, + object: manager, + queue: nil + ) { _ in + manager.closeWorkspace(renamedWorkspace, recordHistory: false) + } + defer { NotificationCenter.default.removeObserver(observer) } + + #expect(manager.setCustomTitle(tabId: renamedWorkspace.id, title: "Renamed")) + await log.flushPendingRecords() + + let renameEvent = try #require( + await log.recentEvents().first(where: { $0.kind == .workspaceRenamed }) + ) + #expect(renameEvent.subject.workspaceId == renamedWorkspace.id) + #expect(renameEvent.title == "Renamed") + } + private func event(id: String) -> VaultHistoryEvent { VaultHistoryEvent( id: id, From 5d735504b572c3722e97a8f89bf636e510c46e83 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 02:07:29 -0700 Subject: [PATCH 10/76] Repair current-main macOS CI drift --- Sources/Cloud/VMClient.swift | 17 +++++------------ Sources/SessionIndexTableController.swift | 6 ++++-- .../WorkspaceCreateReviewRegressionTests.swift | 3 ++- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 08ff6c746ae0..6b1bbab6379a 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -2218,23 +2218,16 @@ actor MachineUsageClient { return nil } - private nonisolated static let iso8601WithFractions: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - private nonisolated static let iso8601: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter - }() + private nonisolated static let iso8601WithFractions = Date.ISO8601FormatStyle( + includingFractionalSeconds: true + ) + private nonisolated static let iso8601 = Date.ISO8601FormatStyle() /// `null`/absent is nil; an unparseable string is nil too, since the date /// only labels the readout and must never fail the whole payload. private nonisolated static func dateValue(_ raw: Any?) -> Date? { guard let text = raw as? String, !text.isEmpty else { return nil } - return iso8601WithFractions.date(from: text) ?? iso8601.date(from: text) + return (try? iso8601WithFractions.parse(text)) ?? (try? iso8601.parse(text)) } private func request( diff --git a/Sources/SessionIndexTableController.swift b/Sources/SessionIndexTableController.swift index 28e438fa326e..7abd2e223d09 100644 --- a/Sources/SessionIndexTableController.swift +++ b/Sources/SessionIndexTableController.swift @@ -109,8 +109,10 @@ final class SessionIndexTableController: NSObject, NSTableViewDataSource, NSTabl object: scrollView.contentView, queue: .main ) { [weak self, weak table] _ in - guard let self, let table, !self.isApplyingRows else { return } - self.reconcilePresentation(in: table) + MainActor.assumeIsolated { + guard let self, let table, !self.isApplyingRows else { return } + self.reconcilePresentation(in: table) + } } table.frame = scrollView.contentView.bounds table.autoresizingMask = [.width] diff --git a/cmuxTests/WorkspaceCreateReviewRegressionTests.swift b/cmuxTests/WorkspaceCreateReviewRegressionTests.swift index b77eacbebe55..20b16c4c5c2c 100644 --- a/cmuxTests/WorkspaceCreateReviewRegressionTests.swift +++ b/cmuxTests/WorkspaceCreateReviewRegressionTests.swift @@ -289,7 +289,8 @@ private final class RejectingWorkspaceCreationTabManager: TabManager { autoRefreshMetadata: Bool, normalizeWorkspaceGroupsAfterInsert: Bool, applyCreationTitleAsCustomTitle: Bool, - allowTextBoxFocusDefault: Bool + allowTextBoxFocusDefault: Bool, + vaultHistoryContext: VaultHistoryWorkspaceCreationContext ) -> Workspace? { nil } From 259406d77f99ff0e4757bd73ac9c67d59f300f5d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 02:36:12 -0700 Subject: [PATCH 11/76] Clear inherited Swift warning drift --- Sources/AppDelegate+PaneMemoryGuardrail.swift | 4 ++-- Sources/Cloud/MachineCreateCoordinator.swift | 4 ++-- Sources/Cloud/MachinesPanelView.swift | 13 +++++++------ Sources/Cloud/MachinesPanelViewModel.swift | 3 ++- Sources/Cloud/NewMachineSheetPresenter.swift | 3 ++- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Sources/AppDelegate+PaneMemoryGuardrail.swift b/Sources/AppDelegate+PaneMemoryGuardrail.swift index 793e632af5a7..59db8045f63c 100644 --- a/Sources/AppDelegate+PaneMemoryGuardrail.swift +++ b/Sources/AppDelegate+PaneMemoryGuardrail.swift @@ -73,8 +73,8 @@ extension AppDelegate { guard let notificationStore else { return } let managers = paneMemoryGuardrailTabManagers() guard let tabId = tabManager?.selectedTabId - ?? managers.lazy.compactMap { $0.selectedTabId }.first - ?? managers.lazy.compactMap { $0.tabs.first?.id }.first + ?? managers.lazy.compactMap({ $0.selectedTabId }).first + ?? managers.lazy.compactMap({ $0.tabs.first?.id }).first else { return } notificationStore.addNotification( diff --git a/Sources/Cloud/MachineCreateCoordinator.swift b/Sources/Cloud/MachineCreateCoordinator.swift index 4d03989e72b6..222dd3df142d 100644 --- a/Sources/Cloud/MachineCreateCoordinator.swift +++ b/Sources/Cloud/MachineCreateCoordinator.swift @@ -68,8 +68,8 @@ final class MachineCreateCoordinator { /// Posted on the default center with `object` = the coordinator after any /// change to ``operations``. `userInfo[finishedUserInfoKey]` carries the /// ``Finished`` value when the change is a completion. - static let didChangeNotification = Notification.Name("cmux.machineCreate.didChange") - static let finishedUserInfoKey = "finished" + nonisolated static let didChangeNotification = Notification.Name("cmux.machineCreate.didChange") + nonisolated static let finishedUserInfoKey = "finished" private(set) var operations: [MachineCreateOperation] = [] /// The most recent completion, for observers that arrive late (tests, diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 5cc9351e0d53..00b703ce9793 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -839,13 +839,14 @@ struct MachineRowActions { arguments: arguments, successTitle: successTitle, presentOutputOnSuccess: presentOutputOnSuccess, - onCancellationReady: onCancellationReady - ) { completion in - if completion.terminationStatus == 0 { - onSuccess?() + onCancellationReady: onCancellationReady, + onCompletion: { completion in + if completion.terminationStatus == 0 { + onSuccess?() + } + onDidMutate() } - onDidMutate() - } + ) } @MainActor diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index 9a08e9d0ec1f..c299cfb52c2f 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -445,7 +445,8 @@ final class MachinesPanelViewModel: ObservableObject { private var treeTask: Task? private static let statsInterval: Duration = .seconds(20) - init(createCoordinator: MachineCreateCoordinator = .shared) { + init(createCoordinator: MachineCreateCoordinator? = nil) { + let createCoordinator = createCoordinator ?? .shared self.createCoordinator = createCoordinator pendingCreates = createCoordinator.operations createChangeObserver = NotificationCenter.default.addObserver( diff --git a/Sources/Cloud/NewMachineSheetPresenter.swift b/Sources/Cloud/NewMachineSheetPresenter.swift index 0588851a10b7..604b3ecbe48c 100644 --- a/Sources/Cloud/NewMachineSheetPresenter.swift +++ b/Sources/Cloud/NewMachineSheetPresenter.swift @@ -67,8 +67,9 @@ final class NewMachineSheetPresenter { plan: MachinePlanSnapshot?, imageKinds: [VMImageKindOption], preferredWindow: NSWindow?, - coordinator: MachineCreateCoordinator = .shared + coordinator: MachineCreateCoordinator? = nil ) { + let coordinator = coordinator ?? .shared if let plan, plan.isAtLimit, !plan.isPaidPlan { ProUpgradePresenter.present() return From 30155a009197189b47163f321b09cefc2e6af436 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 13:05:12 -0700 Subject: [PATCH 12/76] test: cover narrow Vault History row layout --- cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/VaultHistoryRowLayoutTests.swift | 49 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 cmuxTests/VaultHistoryRowLayoutTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 24d5728f5f00..2c79f81324e0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2899,6 +2899,7 @@ 913100000000000000000006 /* VaultHistoryGroupKey+Presentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000106 /* VaultHistoryGroupKey+Presentation.swift */; }; 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */; }; 913100000000000000000007 /* VaultHistoryRecordingPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000107 /* VaultHistoryRecordingPhase.swift */; }; + 913100000000000000000023 /* VaultHistoryRowLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000123 /* VaultHistoryRowLayoutTests.swift */; }; 913100000000000000000022 /* VaultHistorySessionEventProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */; }; 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */; }; 913100000000000000000008 /* VaultHistoryStoreLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913100000000000000000108 /* VaultHistoryStoreLocation.swift */; }; @@ -6022,6 +6023,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 913100000000000000000106 /* VaultHistoryGroupKey+Presentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VaultHistoryGroupKey+Presentation.swift"; sourceTree = ""; }; 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryLifecycleRecording.swift; sourceTree = ""; }; 913100000000000000000107 /* VaultHistoryRecordingPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryRecordingPhase.swift; sourceTree = ""; }; + 913100000000000000000123 /* VaultHistoryRowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryRowLayoutTests.swift; sourceTree = ""; }; 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistorySessionEventProjectionTests.swift; sourceTree = ""; }; C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistorySessionEvents.swift; sourceTree = ""; }; 913100000000000000000108 /* VaultHistoryStoreLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryStoreLocation.swift; sourceTree = ""; }; @@ -9284,6 +9286,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, 913100000000000000000121 /* VaultHistoryEventLogTests.swift */, 913100000000000000000122 /* VaultHistorySessionEventProjectionTests.swift */, + 913100000000000000000123 /* VaultHistoryRowLayoutTests.swift */, C0A710120000000000000001 /* ComputerUseUXTests.swift */, 9492CAFE0000000000000001 /* LastTerminalChildExitRecoveryTests.swift */, C9D9924AC89D7C3408DBC56A /* WorkspaceRecoveryTests.swift */, @@ -13078,6 +13081,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A36C009DF2E44501AA774136 /* VaultCheckpointForkTests.swift in Sources */, C2A04B60729A4FA6B38C8BC1 /* VaultCheckpointHarnessTests.swift in Sources */, 913100000000000000000021 /* VaultHistoryEventLogTests.swift in Sources */, + 913100000000000000000023 /* VaultHistoryRowLayoutTests.swift in Sources */, 913100000000000000000022 /* VaultHistorySessionEventProjectionTests.swift in Sources */, 469200054692000146920001 /* VaultNativeDragSourceTests.swift in Sources */, 469200014692000146920001 /* VaultPaneDropRoutingTests.swift in Sources */, diff --git a/cmuxTests/VaultHistoryRowLayoutTests.swift b/cmuxTests/VaultHistoryRowLayoutTests.swift new file mode 100644 index 000000000000..54c23c17d2e0 --- /dev/null +++ b/cmuxTests/VaultHistoryRowLayoutTests.swift @@ -0,0 +1,49 @@ +import AppKit +import CmuxFoundation +import CmuxVaultHistory +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct VaultHistoryRowLayoutTests { + @Test(arguments: [240.0, 320.0, 420.0], ["en", "de", "ja"]) + func longTitlesDoNotIncreaseRowHeight(width: Double, locale: String) { + let short = rowSize(title: "Fix layout", width: width, locale: locale) + let long = rowSize( + title: "You are working directly in the full clone /Users/austinwang/manaflow/term/cmux123. " + + String(repeating: "Restore the compact Vault session styling. ", count: 8), + width: width, + locale: locale + ) + + #expect(short.height > 20) + #expect(long.height <= short.height + 1) + #expect(long.height < 60) + #expect(abs(long.width - width) < 1) + } + + private func rowSize(title: String, width: Double, locale: String) -> NSSize { + let event = VaultHistoryEvent( + timestamp: Date().addingTimeInterval(-46 * 60), + kind: .sessionActivity, + title: title, + subject: VaultHistorySubject( + agent: "codex", + directory: "/Users/austinwang/manaflow/term/cmux123" + ) + ) + let host = NSHostingView( + rootView: VaultHistoryEventRow(event: event) + .environment(\.locale, Locale(identifier: locale)) + .environment(\.cmuxGlobalFontMagnificationPercent, 100) + .frame(width: width) + ) + return host.fittingSize + } +} From fa4ecaff853261853c73c4228c36c94ab417d2a5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 4 Sep 2026 13:05:39 -0700 Subject: [PATCH 13/76] fix: restore compact Vault styling in History --- Sources/VaultHistoryEventRow.swift | 94 +++++++++++++++++++++------ Sources/VaultHistoryGroupHeader.swift | 61 +++++++++++++++-- Sources/VaultHistoryView.swift | 79 ++++++++++++++++++---- Sources/VaultPaneTabButton.swift | 11 ++-- 4 files changed, 201 insertions(+), 44 deletions(-) diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index cdee7990af19..ad3dcc8ae0ad 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -6,42 +6,96 @@ import SwiftUI /// Value-only row rendered below the History lazy-list boundary. struct VaultHistoryEventRow: View, Equatable { let event: VaultHistoryEvent + @State private var isHovered = false nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { lhs.event == rhs.event } var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: event.kind.symbolName) - .cmuxFont(size: 10, weight: .regular) - .foregroundColor(.secondary) - .frame(width: 14) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + ZStack { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.primary.opacity(0.06)) + eventIcon + } + .frame(width: 20, height: 20) + Text(displayTitle) - .cmuxFont(size: 11.5) - .foregroundColor(.primary.opacity(0.85)) + .cmuxFont(size: 13) + .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) - Text(subtitle) - .cmuxFont(size: 10) - .foregroundColor(.secondary) + .truncationMode(.tail) + + Spacer(minLength: 8) + + Text(min(event.timestamp, Date()), format: .relative( + presentation: .numeric, + unitsStyle: .abbreviated + )) + .cmuxFont(size: 12, monospacedDigit: true) + .foregroundColor(.secondary.opacity(0.65)) + // Reserve the time's intrinsic width so long titles + // truncate without wrapping the timestamp vertically. .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .help(event.timestamp.formatted(date: .numeric, time: .shortened)) } - Spacer(minLength: 4) - Text(event.timestamp, format: .relative( - presentation: .numeric, - unitsStyle: .abbreviated - )) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.7)) - .help(event.timestamp.formatted(date: .numeric, time: .shortened)) + + Text(subtitle) + .cmuxFont(size: 11) + .foregroundColor(.secondary.opacity(0.75)) + .lineLimit(1) + .truncationMode(.middle) + .padding(.leading, 26) } - .padding(.horizontal, 10) + .padding(.leading, 32) + .padding(.trailing, 12) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background(rowBackground) + .onHover { isHovered = $0 } + .help(displayTitle) .accessibilityElement(children: .combine) } + @ViewBuilder + private var eventIcon: some View { + if event.kind == .sessionActivity, + let rawValue = event.subject.agent, + let agent = SessionAgent(rawValue: rawValue) { + SessionIndexAgentIconImage(agent: agent, size: 12) + } else { + CmuxSystemSymbolImage( + magnified: event.kind.symbolName, + pointSize: 11, + weight: .regular + ) + .foregroundColor(iconColor) + } + } + + private var iconColor: Color { + switch event.kind { + case .workspaceCreated, .windowOpened: + return .green + case .workspaceRenamed: + return .orange + case .sessionActivity: + return .accentColor + case .workspaceClosed, .windowClosed: + return .secondary + } + } + + private var rowBackground: some View { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .padding(.horizontal, 4) + } + private var displayTitle: String { let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.isEmpty else { return trimmed } diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift index f4c433e9d114..2002b7982b43 100644 --- a/Sources/VaultHistoryGroupHeader.swift +++ b/Sources/VaultHistoryGroupHeader.swift @@ -1,25 +1,72 @@ +import AppKit import CmuxFoundation +import CmuxVaultHistory import SwiftUI /// Value-only pinned header rendered below the History lazy-list boundary. struct VaultHistoryGroupHeader: View, Equatable { let title: String let count: Int + let key: VaultHistoryGroupKey + /// Stable value snapshot of the host chrome color. Keeping a string here + /// avoids carrying an AppKit reference below the lazy-list boundary. + let backgroundHex: String? + + init( + title: String, + count: Int, + key: VaultHistoryGroupKey = .date, + backgroundHex: String? = nil + ) { + self.title = title + self.count = count + self.key = key + self.backgroundHex = backgroundHex + } + + nonisolated static func == ( + lhs: VaultHistoryGroupHeader, + rhs: VaultHistoryGroupHeader + ) -> Bool { + lhs.title == rhs.title + && lhs.count == rhs.count + && lhs.key == rhs.key + && lhs.backgroundHex == rhs.backgroundHex + } var body: some View { - HStack(spacing: 4) { + HStack(spacing: 8) { + CmuxSystemSymbolImage( + magnified: key.symbolName, + pointSize: 14, + weight: .regular + ) + .foregroundColor(.secondary) Text(title) - .cmuxFont(size: 11, weight: .semibold) + .cmuxFont(size: 12, weight: .semibold) .foregroundColor(.secondary) .lineLimit(1) + .truncationMode(.middle) Text(count, format: .number) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.6)) + .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) + .foregroundStyle(.tertiary) + .fixedSize() Spacer(minLength: 0) } - .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.horizontal, 12) + .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) + // Pinned headers need an opaque backing. Use the same resolved chrome + // color as the host so the header does not introduce a light system + // bar over a custom terminal theme. + .background { + Rectangle() + .fill(headerBackground) + } + .clipped() + } + + private var headerBackground: Color { + Color(nsColor: NSColor(hex: backgroundHex ?? "") ?? .windowBackgroundColor) } } diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index d9ab23f77f26..e8eacd34a507 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -38,6 +38,8 @@ private struct VaultHistoryContentView: View { let chromeBackgroundColor: NSColor @State private var sessionReloadGeneration = 0 @State private var hasFreshSessionSnapshot = false + @State private var isGroupPickerHovered = false + @State private var isReloadHovered = false var body: some View { VStack(spacing: 0) { @@ -67,7 +69,7 @@ private struct VaultHistoryContentView: View { } private var controlBar: some View { - HStack(spacing: 6) { + HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { Menu { ForEach(VaultHistoryGroupKey.allCases) { key in Button { @@ -82,17 +84,22 @@ private struct VaultHistoryContentView: View { } } label: { HStack(spacing: 3) { - Image(systemName: model.groupKey.symbolName) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) + CmuxSystemSymbolImage( + magnified: model.groupKey.symbolName, + pointSize: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) Text(model.groupKey.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight ) } + .rightSidebarChromePill( + isSelected: false, + isHovered: isGroupPickerHovered, + geometryKeyPrefix: "VaultHistoryGroupPickerControl" + ) } .menuStyle(.borderlessButton) .fixedSize() @@ -102,6 +109,7 @@ private struct VaultHistoryContentView: View { )) .accessibilityIdentifier("VaultHistoryGroupPicker") .titlebarInteractiveControl() + .onHover { isGroupPickerHovered = $0 } Spacer(minLength: 4) @@ -109,16 +117,40 @@ private struct VaultHistoryContentView: View { hasFreshSessionSnapshot = false sessionReloadGeneration &+= 1 } label: { - Image(systemName: "arrow.clockwise") - .cmuxFont(size: 10, weight: .medium) + HeaderChromeIconStyle.symbol("arrow.clockwise") + .foregroundStyle( + HeaderChromeIconStyle.foregroundColor.opacity( + isReloadHovered + ? HeaderChromeIconStyle.hoveredOpacity + : HeaderChromeIconStyle.opacity + ) + ) + .frame( + width: RightSidebarChromeMetrics.headerControlSize, + height: RightSidebarChromeMetrics.headerControlSize + ) + .background { + if isReloadHovered { + RoundedRectangle( + cornerRadius: RightSidebarChromeMetrics.headerControlCornerRadius, + style: .continuous + ) + .fill(Color.primary.opacity(0.07)) + } + } } .buttonStyle(.borderless) .help(reloadLabel) .accessibilityLabel(reloadLabel) .disabled(model.isLoading || sessionStore.isLoading) .titlebarInteractiveControl() + .onHover { isReloadHovered = $0 } } - .rightSidebarChromeBar() + .rightSidebarChromeBar( + leadingPadding: 4, + trailingPadding: 6, + height: RightSidebarChromeMetrics.secondaryBarHeight + ) .rightSidebarChromeBottomBorder(backgroundColor: chromeBackgroundColor) } @@ -131,6 +163,12 @@ private struct VaultHistoryContentView: View { private var loadingView: some View { VStack(spacing: 6) { + CmuxSystemSymbolImage( + magnified: "clock.arrow.circlepath", + pointSize: 22, + weight: .regular + ) + .foregroundColor(.secondary.opacity(0.65)) ProgressView().controlSize(.small) Text(String( localized: "vaultHistory.loading", @@ -143,7 +181,13 @@ private struct VaultHistoryContentView: View { } private var emptyView: some View { - VStack(spacing: 4) { + VStack(spacing: 7) { + CmuxSystemSymbolImage( + magnified: "clock.arrow.circlepath", + pointSize: 28, + weight: .light + ) + .foregroundColor(.secondary.opacity(0.55)) Text(String( localized: "vaultHistory.empty.title", defaultValue: "No history yet" @@ -159,23 +203,34 @@ private struct VaultHistoryContentView: View { .multilineTextAlignment(.center) .padding(.horizontal, 16) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } private var timelineList: some View { let groups = model.groups + let backgroundHex = chromeBackgroundColor.hexString() return ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { ForEach(groups) { group in Section { ForEach(group.events) { event in VaultHistoryEventRow(event: event) + .equatable() } } header: { - VaultHistoryGroupHeader(title: group.title, count: group.events.count) + VaultHistoryGroupHeader( + title: group.title, + count: group.events.count, + key: group.key, + backgroundHex: backgroundHex + ) + .equatable() } + .padding(.bottom, 4) } } + .padding(.top, 4) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } diff --git a/Sources/VaultPaneTabButton.swift b/Sources/VaultPaneTabButton.swift index 791a6272381f..2d806accf056 100644 --- a/Sources/VaultPaneTabButton.swift +++ b/Sources/VaultPaneTabButton.swift @@ -11,16 +11,17 @@ struct VaultPaneTabButton: View { var body: some View { Button(action: action) { HStack(spacing: 3) { - Image(systemName: tab.symbolName) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) + CmuxSystemSymbolImage( + magnified: tab.symbolName, + pointSize: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) Text(tab.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight ) + .lineLimit(1) } .rightSidebarChromePill(isSelected: isSelected, isHovered: isHovered) } From 778affc9b57ed19c70c07f08199e3012ba7f1d1f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 13:30:01 -0700 Subject: [PATCH 14/76] test: align cloud state coverage with current catalog API --- cmuxTests/SurfaceCatalogTests.swift | 304 +++++----------------------- 1 file changed, 56 insertions(+), 248 deletions(-) diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index c1e0404a9d78..3a99973f8749 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1176,231 +1176,37 @@ struct SurfaceCatalogTests { #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } - @Test func `Cloud workspace rename updates every projection and rejects stale snapshots`() throws { + @Test func `Cloud state replacement updates every workspace projection`() throws { let machine = SurfaceMachineID.cloud("vivid-newt") let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - - let original = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let other = SurfaceRemoteWorkspace(id: "ws_other", name: "other", index: 1, focused: false) - var terminal = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_main"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: original, - port: nil, - url: nil - ) - terminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - var unrelated = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term_other"), - title: "other", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: other, - port: nil, - url: nil - ) - unrelated.remoteViews = [SurfaceRemoteView(tabID: "tab_other", workspace: other)] - let initialInfo = SurfaceMachineInfo( - id: machine, - name: "vivid-newt", - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [original, other] - ) - let generation = "daemon-generation" - #expect(catalog.replaceCloudResources( - [terminal, unrelated], - on: machine, - info: initialInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: original.id, - name: "Renamed" - ) - let optimistic = catalog.snapshot - #expect(optimistic.machines.first?.remoteWorkspaces?.first { $0.id == original.id }?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - #expect(optimistic.resources.first { $0.id == terminal.id }?.remoteViews?.first?.workspace.name == "Renamed") - #expect(optimistic.resources.first { $0.id == unrelated.id }?.remoteWorkspace?.name == "other") - - let staleInfo = initialInfo - var staleTerminal = terminal - staleTerminal.remoteWorkspace = original - staleTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: original)] - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 9) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: generation, revision: 11) - ) - var confirmedInfo = initialInfo - let confirmed = SurfaceRemoteWorkspace(id: original.id, name: "Renamed", index: 0, focused: true) - confirmedInfo.remoteWorkspaces = [confirmed, other] - var confirmedTerminal = terminal - confirmedTerminal.remoteWorkspace = confirmed - confirmedTerminal.remoteViews = [SurfaceRemoteView(tabID: "tab_main", workspace: confirmed)] - #expect(catalog.replaceCloudResources( - [confirmedTerminal, unrelated], - on: machine, - info: confirmedInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: original.id) == nil) - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 10) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - // An equal-cursor payload must also be identical; a delayed response - // with the old name cannot overwrite the confirmed rename. - #expect(!catalog.replaceCloudResources( - [staleTerminal, unrelated], - on: machine, - info: staleInfo, - cursor: CloudVMCursor(generation: generation, revision: 11) - )) - #expect(catalog.snapshot.resources.first { $0.id == terminal.id }?.remoteWorkspace?.name == "Renamed") - } - - @Test func `A cursorless machine update cannot restore a confirmed cloud workspace name`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - let provider = FakeProvider(machine: machine) - catalog.register(provider) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - diskUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - - // A provider summary/status write has no cursor and may still carry its - // pre-rename cached workspace value. It must not overwrite the accepted graph. - catalog.updateMachine(beforeInfo, from: provider) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `A stale equal-cursor snapshot after a rename receipt cannot poison reconciliation`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let before = SurfaceRemoteWorkspace(id: "ws_main", name: "before", index: 0, focused: true) - let after = SurfaceRemoteWorkspace(id: before.id, name: "after", index: 0, focused: true) - var beforeResource = terminal(machine, "term_main") - beforeResource.remoteWorkspace = before - var afterResource = beforeResource - afterResource.remoteWorkspace = after - let beforeInfo = SurfaceMachineInfo( - id: machine, - name: machine.rawValue, - status: "running", - image: nil, - hasDesktop: false, - memoryMb: nil, - diskMb: nil, - linkState: .connected, - linkError: nil, - cpuPercent: nil, - memoryUsedMb: nil, - remoteWorkspaces: [before] - ) - var afterInfo = beforeInfo - afterInfo.remoteWorkspaces = [after] - #expect(catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let token = try catalog.beginCloudWorkspaceRename( - machine: machine, - workspaceID: before.id, - name: "after" - ) - catalog.commitCloudWorkspaceRename( - token, - receipt: CloudVMCursor(generation: "g", revision: 2) - ) - - // The first read at the receipt cursor is stale, but the optimistic overlay - // keeps the UI correct. A later canonical read at that same cursor must still - // be accepted and retire the intent. - #expect(!catalog.replaceCloudResources( - [beforeResource], - on: machine, - info: beforeInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.replaceCloudResources( - [afterResource], - on: machine, - info: afterInfo, - cursor: CloudVMCursor(generation: "g", revision: 2) - )) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: before.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "after") - } - - @Test func `An older cloud rename completion cannot roll back a newer intent`() throws { - let machine = SurfaceMachineID.cloud("vivid-newt") - let catalog = SurfaceCatalog() - catalog.register(FakeProvider(machine: machine)) - let workspace = SurfaceRemoteWorkspace(id: "ws_main", name: "main", index: 0, focused: true) - let info = SurfaceMachineInfo( + let snapshot: [String: Any] = [ + "cursor": ["generation": "g", "revision": "1"], + "workspaces": [ + ["id": "ws_main", "name": "main", "focused": true], + ["id": "ws_other", "name": "other", "focused": false], + ], + "screens": [ + ["id": "screen_main", "workspace_id": "ws_main"], + ["id": "screen_other", "workspace_id": "ws_other"], + ], + "panes": [ + ["id": "pane_main", "screen_id": "screen_main"], + ["id": "pane_other", "screen_id": "screen_other"], + ], + "tabs": [ + ["id": "tab_main", "pane_id": "pane_main", "content_kind": "terminal", "content_id": "term_main"], + ["id": "tab_other", "pane_id": "pane_other", "content_kind": "terminal", "content_id": "term_other"], + ], + "terminals": [ + ["id": "term_main", "tab_id": "tab_main", "tab_ids": ["tab_main"], "title": "shell", "lifecycle": "running"], + ["id": "term_other", "tab_id": "tab_other", "tab_ids": ["tab_other"], "title": "other", "lifecycle": "running"], + ], + "browsers": [], + "agents": [], + ] + let state = try #require(CmuxTuiSnapshotParser.state(fromSnapshot: snapshot, machine: machine)) + let resources = CmuxTuiSnapshotParser.resources(from: state) + var info = SurfaceMachineInfo( id: machine, name: "vivid-newt", status: "running", @@ -1413,31 +1219,33 @@ struct SurfaceCatalogTests { cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, - remoteWorkspaces: [workspace] - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term"), - title: "shell", - detail: nil, - lifecycle: .running, - agent: nil, - remoteWorkspace: workspace, - port: nil, - url: nil + remoteWorkspaces: state.workspaces.map { + SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) + } ) - #expect(catalog.replaceCloudResources( - [resource], - on: machine, - info: info, - cursor: CloudVMCursor(generation: "g", revision: 1) - )) - let first = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "first") - let second = try catalog.beginCloudWorkspaceRename(machine: machine, workspaceID: workspace.id, name: "second") - catalog.rollbackCloudWorkspaceRename(first) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == "second") - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "second") - catalog.rollbackCloudWorkspaceRename(second) - #expect(catalog.pendingCloudWorkspaceRenameName(machine: machine, workspaceID: workspace.id) == nil) - #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "main") + catalog.replaceCloudState(state, resources: resources, info: info) + + #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.map(\.name) == ["main", "other"]) + #expect(catalog.snapshot.resources(on: machine).count == 2) + #expect(catalog.snapshot.resources(on: machine).allSatisfy { $0.remoteWorkspace != nil }) + + let renamed = SurfaceRemoteWorkspace(id: "ws_main", name: "renamed", index: 0, focused: true) + info.remoteWorkspaces = [renamed, SurfaceRemoteWorkspace(id: "ws_other", name: "other", index: 1, focused: false)] + var renamedState = state + renamedState.workspaces[0].name = "renamed" + var renamedResources = resources + for index in renamedResources.indices { + if renamedResources[index].remoteWorkspace?.id == renamed.id { + renamedResources[index].remoteWorkspace = renamed + renamedResources[index].remoteViews = renamedResources[index].remoteViews?.map { view in + var updated = view + updated.workspace = renamed + return updated + } + } + } + catalog.replaceCloudState(renamedState, resources: renamedResources, info: info) + #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first?.name == "renamed") + #expect(catalog.snapshot.resources(on: machine).first { $0.id.key == "term_main" }?.remoteWorkspace?.name == "renamed") } } From d9b9b8d7fba66744f84a266662a559e35198e8f4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 13:43:46 -0700 Subject: [PATCH 15/76] fix: return available sidebar modes --- Sources/RightSidebarPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 0e25b18bb052..a524fac1a7e5 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -169,7 +169,7 @@ struct RightSidebarPanelView: View { private var featureAvailableModes: [RightSidebarMode] { _ = managedPolicyRevision - RightSidebarMode.availableModes( + return RightSidebarMode.availableModes( feedEnabled: feedEnabled, dockEnabled: dockEnabled, machinesEnabled: CloudMachinesFeature.isEnabled From 3c8976540807271803e8b79c11c7d62c5e119244 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 14:02:06 -0700 Subject: [PATCH 16/76] fix: call cloud policy error from instance context --- Sources/Surfaces/SurfaceSocketCommands.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Surfaces/SurfaceSocketCommands.swift b/Sources/Surfaces/SurfaceSocketCommands.swift index c277a3b1c404..d092616d5070 100644 --- a/Sources/Surfaces/SurfaceSocketCommands.swift +++ b/Sources/Surfaces/SurfaceSocketCommands.swift @@ -14,7 +14,7 @@ import Foundation // Focus policy: `focus` defaults to true for explicit opens (the caller asked for a pane) // and false for desktop/port opens; the catalog never activates the app either way. extension TerminalController { - private nonisolated static func cloudDisabledSocketError(id: Any?) -> String? { + private nonisolated func cloudDisabledSocketError(id: Any?) -> String? { guard ManagedDevicePolicy().isEnforced(.disableCloud) else { return nil } return v2Error( id: id, @@ -27,7 +27,7 @@ extension TerminalController { switch method { case "surface.catalog": let machine = Self.surfaceMachineFilter(params["machine"]) - if let machine, machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if let machine, machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let refresh = Self.surfaceBool(params["refresh"]) ?? false return v2VmCall(id: id, timeoutSeconds: 120) { if refresh { @@ -45,7 +45,7 @@ extension TerminalController { guard let raw = Self.surfaceString(params["resource"]), let resource = SurfaceResourceID(rawValue: raw) else { return v2Error(id: id, code: "invalid_params", message: "surface.project requires `resource` (an id from `cmux surface ls --json`, e.g. vivid-newt/terminal/term_…).") } - if resource.machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if resource.machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let focus = Self.surfaceBool(params["focus"]) ?? true let reuse = Self.surfaceBool(params["reuse"]) ?? true let remoteTabID = Self.surfaceString(params["remote_tab_id"]) @@ -76,7 +76,7 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: "surface.new_terminal requires `machine` (\"local\" or a cloud machine id).") } let machine = SurfaceMachineID(rawValue: machineRaw) - if machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let command = Self.surfaceStringArray(params["command"]) let cwd = Self.surfaceString(params["cwd"]) let name = Self.surfaceString(params["name"]) From b5f37df8f568b56141ad0c90f8dda07e846f63a7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 6 Sep 2026 14:32:30 -0700 Subject: [PATCH 17/76] fix: silence inherited browser payload warning --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString, From a11521319e9db730d1fd5c814ac321e86555df4c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 17:55:11 -0700 Subject: [PATCH 18/76] fix: compile local tmux helpers in the CLI regression target --- CLI/CLIError.swift | 46 ++++++++++++++++++++++++++++++++++ CLI/cmux.swift | 46 ---------------------------------- cmux.xcodeproj/project.pbxproj | 16 ++++++++++++ 3 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 CLI/CLIError.swift diff --git a/CLI/CLIError.swift b/CLI/CLIError.swift new file mode 100644 index 000000000000..4811ebcfac24 --- /dev/null +++ b/CLI/CLIError.swift @@ -0,0 +1,46 @@ +/// A CLI failure with display text, exit status, and optional structured context. +struct CLIError: Error, CustomStringConvertible { + enum SocketFailureKind: Equatable { + case pathMissing + case pathInspectionFailed + case pathTypeConflict + case pathOwnershipConflict + case startupTimeout + } + + let message: String + let exitCode: Int32 + /// Structured v2 protocol error code when the failure came from a v2 error response. + let v2Code: String? + /// Whether this error was decoded directly from a socket v2 error response. + /// Local CLI sentinels may reuse ``v2Code`` for their own exit-status contract. + let isStructuredProtocolResponse: Bool + /// Cloud VM backend error code (e.g. "vm_create_failed") passed through the + /// v2 error's data payload, so callers can make idempotency decisions + /// structurally instead of parsing display text. + let vmBackendCode: String? + /// HTTP status from the Cloud VM backend, when the app forwarded an HTTP + /// failure through the local v2 socket. + let vmBackendHTTPStatus: Int? + let socketFailureKind: SocketFailureKind? + + init( + message: String, + exitCode: Int32 = 1, + v2Code: String? = nil, + isStructuredProtocolResponse: Bool = false, + vmBackendCode: String? = nil, + vmBackendHTTPStatus: Int? = nil, + socketFailureKind: SocketFailureKind? = nil + ) { + self.message = message + self.exitCode = exitCode + self.v2Code = v2Code + self.isStructuredProtocolResponse = isStructuredProtocolResponse + self.vmBackendCode = vmBackendCode + self.vmBackendHTTPStatus = vmBackendHTTPStatus + self.socketFailureKind = socketFailureKind + } + + var description: String { message } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e560525f6509..43d20c848951 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14,52 +14,6 @@ import LocalAuthentication import Security #endif -struct CLIError: Error, CustomStringConvertible { - enum SocketFailureKind: Equatable { - case pathMissing - case pathInspectionFailed - case pathTypeConflict - case pathOwnershipConflict - case startupTimeout - } - - let message: String - let exitCode: Int32 - /// Structured v2 protocol error code when the failure came from a v2 error response. - let v2Code: String? - /// Whether this error was decoded directly from a socket v2 error response. - /// Local CLI sentinels may reuse ``v2Code`` for their own exit-status contract. - let isStructuredProtocolResponse: Bool - /// Cloud VM backend error code (e.g. "vm_create_failed") passed through the - /// v2 error's data payload, so callers can make idempotency decisions - /// structurally instead of parsing display text. - let vmBackendCode: String? - /// HTTP status from the Cloud VM backend, when the app forwarded an HTTP - /// failure through the local v2 socket. - let vmBackendHTTPStatus: Int? - let socketFailureKind: SocketFailureKind? - - init( - message: String, - exitCode: Int32 = 1, - v2Code: String? = nil, - isStructuredProtocolResponse: Bool = false, - vmBackendCode: String? = nil, - vmBackendHTTPStatus: Int? = nil, - socketFailureKind: SocketFailureKind? = nil - ) { - self.message = message - self.exitCode = exitCode - self.v2Code = v2Code - self.isStructuredProtocolResponse = isStructuredProtocolResponse - self.vmBackendCode = vmBackendCode - self.vmBackendHTTPStatus = vmBackendHTTPStatus - self.socketFailureKind = socketFailureKind - } - - var description: String { message } -} - struct WindowInfo { let index: Int let id: String diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ae0a8c047ea3..ff80df7d569a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -596,6 +596,8 @@ 918100000000000000000002 /* CLICodexResumeNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 918100000000000000000001 /* CLICodexResumeNotificationTests.swift */; }; C6711A010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6711B010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift */; }; C0D3F1F20000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F20000000000000104 /* CLICodexYoloResumePersistenceTests.swift */; }; + C09131010000000000000002 /* CLIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09131010000000000000001 /* CLIError.swift */; }; + C09131010000000000000003 /* CLIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09131010000000000000001 /* CLIError.swift */; }; E295EA3753206FE3FFCA6C0A /* CLIExplicitSurfaceRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */; }; C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */; }; C46790000000000000000003 /* CLIForwardingLaunchRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */; }; @@ -1593,12 +1595,17 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; 8F3CFE9C065FF1BD53D150CF /* LocalSurfaceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDD243C7C87A2AB78B12BAD /* LocalSurfaceProvider.swift */; }; 540A9F26DCDFD0C16B257D6F /* LocalSurfaceProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C68B378B6071B213B4AA57 /* LocalSurfaceProviderTests.swift */; }; + C09131010000000000000004 /* LocalTmuxCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000012 /* LocalTmuxCommandBuilder.swift */; }; C0DE10651000000000000002 /* LocalTmuxCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000012 /* LocalTmuxCommandBuilder.swift */; }; C0DE10651000000000000004 /* LocalTmuxInvocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000014 /* LocalTmuxInvocation.swift */; }; + C09131010000000000000005 /* LocalTmuxProcessRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000013 /* LocalTmuxProcessRunner.swift */; }; C0DE10651000000000000003 /* LocalTmuxProcessRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000013 /* LocalTmuxProcessRunner.swift */; }; + C09131010000000000000006 /* LocalTmuxSessionBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1065100000000000001B /* LocalTmuxSessionBinding.swift */; }; C0DE1065100000000000000B /* LocalTmuxSessionBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1065100000000000001B /* LocalTmuxSessionBinding.swift */; }; + C09131010000000000000007 /* LocalTmuxSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000018 /* LocalTmuxSessionIdentity.swift */; }; C0DE10651000000000000008 /* LocalTmuxSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000018 /* LocalTmuxSessionIdentity.swift */; }; C0DE10651000000000000009 /* LocalTmuxSessionIdentityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000019 /* LocalTmuxSessionIdentityResolver.swift */; }; + C09131010000000000000008 /* LocalTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1065100000000000001A /* LocalTmuxSessionListParser.swift */; }; C0DE1065100000000000000A /* LocalTmuxSessionListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1065100000000000001A /* LocalTmuxSessionListParser.swift */; }; C0DE1065100000000000000C /* LocalTmuxSessionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1065100000000000001C /* LocalTmuxSessionReconciler.swift */; }; C0DE10651000000000000001 /* LocalTmuxSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10651000000000000011 /* LocalTmuxSessionRegistry.swift */; }; @@ -3973,6 +3980,7 @@ 918100000000000000000001 /* CLICodexResumeNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexResumeNotificationTests.swift; sourceTree = ""; }; C6711B010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexWeakEnvironmentRestoreBindingTests.swift; sourceTree = ""; }; C0D3F1F20000000000000104 /* CLICodexYoloResumePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexYoloResumePersistenceTests.swift; sourceTree = ""; }; + C09131010000000000000001 /* CLIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIError.swift; sourceTree = ""; }; 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIExplicitSurfaceRoutingTests.swift; sourceTree = ""; }; C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIForwardingLaunchArgumentTests.swift; sourceTree = ""; }; C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CLIForwardingLaunchRouter.swift; sourceTree = ""; }; @@ -8977,6 +8985,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B9000003A1B2C3D4E5F60719 /* CLI */ = { isa = PBXGroup; children = ( + C09131010000000000000001 /* CLIError.swift */, 805500000000000000000003 /* BrowserValueTextFormatter.swift */, B9000001A1B2C3D4E5F60719 /* cmux.swift */, REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */, @@ -12723,6 +12732,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 805500000000000000000004 /* BrowserValueTextFormatter.swift in Sources */, 89930005AABBCCDDEEFF0001 /* ClaudeHookSessionStore+SupersededCleanup.swift in Sources */, 89930004AABBCCDDEEFF0001 /* ClaudeHookSessionStoreFile.swift in Sources */, + C09131010000000000000002 /* CLIError.swift in Sources */, B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */, C12985000000000000000002 /* CLISocketSentryTelemetry.swift in Sources */, B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */, @@ -13119,6 +13129,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 918100000000000000000002 /* CLICodexResumeNotificationTests.swift in Sources */, C6711A010000000000000001 /* CLICodexWeakEnvironmentRestoreBindingTests.swift in Sources */, C0D3F1F20000000000000103 /* CLICodexYoloResumePersistenceTests.swift in Sources */, + C09131010000000000000003 /* CLIError.swift in Sources */, E295EA3753206FE3FFCA6C0A /* CLIExplicitSurfaceRoutingTests.swift in Sources */, C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */, A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */, @@ -13357,6 +13368,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 87C609D63F03597AC12C8B0A /* LastSurfaceClosePreferenceTests.swift in Sources */, 9492CAFE0000000000000002 /* LastTerminalChildExitRecoveryTests.swift in Sources */, 540A9F26DCDFD0C16B257D6F /* LocalSurfaceProviderTests.swift in Sources */, + C09131010000000000000004 /* LocalTmuxCommandBuilder.swift in Sources */, + C09131010000000000000005 /* LocalTmuxProcessRunner.swift in Sources */, + C09131010000000000000006 /* LocalTmuxSessionBinding.swift in Sources */, + C09131010000000000000007 /* LocalTmuxSessionIdentity.swift in Sources */, + C09131010000000000000008 /* LocalTmuxSessionListParser.swift in Sources */, 484A89A4B4AA08EF63E0DB51 /* MachineCreateCoordinatorTests.swift in Sources */, 7690848956F4C55873F767F6 /* MachinesPanelModelTests.swift in Sources */, 98E6802001D61AC24F2ABB09 /* MachinesPanelUncappedPlanTests.swift in Sources */, From c2ff759fda74b96d8160f88b0bdb46cb182d5f22 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 17:55:58 -0700 Subject: [PATCH 19/76] test: expose temporary new-window workspaces in Vault History --- Sources/AppDelegate.swift | 6 ++ cmuxTests/VaultHistoryEventLogTests.swift | 76 ++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 08d5a05d8b3e..508663096c60 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1307,6 +1307,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + /// Allows lifecycle consumers to supply an isolated History store before any window is created. + convenience init(vaultHistoryEventLog: VaultHistoryEventLog) { + self.init() + self.vaultHistoryEventLog = vaultHistoryEventLog + } + override init() { let fileManager = FileManager.default let hangDirectory = fileManager.urls( diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index c60a2392d82f..fe9005904a7e 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxVaultHistory import Foundation import Testing @@ -9,7 +10,7 @@ import Testing #endif @MainActor -@Suite struct VaultHistoryEventLogTests { +@Suite(.serialized) struct VaultHistoryEventLogTests { @Test func lifecyclePhasesOnlyAcceptActiveEvents() async { let store = VaultHistoryEventStore(fileURL: nil) let log = VaultHistoryEventLog(store: store) @@ -122,6 +123,79 @@ import Testing #expect(renameEvent.title == "Renamed") } + @Test func movingWorkspaceToNewWindowDoesNotRecordItsTemporaryWorkspace() async throws { + try await withWindowHistory { app, log in + let sourceWindowId = app.createMainWindow(shouldActivate: false) + let source = try #require(app.tabManagerFor(windowId: sourceWindowId)) + let workspace = try #require(source.selectedWorkspace) + + let destinationId = try #require(app.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: false)) + let destination = try #require(app.tabManagerFor(windowId: destinationId)) + #expect(destination.tabs.map(\.id) == [workspace.id]) + await log.flushPendingRecords() + + let creations = await log.recentEvents().filter { $0.kind == .workspaceCreated } + #expect(creations.map(\.subject.workspaceId) == [workspace.id]) + } + } + + @Test func browserWorkspaceWithNoWindowsRecordsOnlyTheBrowserWorkspace() async throws { + try await withWindowHistory { app, log in + #expect(app.performNewBrowserWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let workspace = try #require(context.tabManager.selectedWorkspace) + #expect(context.tabManager.tabs.count == 1) + #expect(workspace.panels.values.contains { $0 is BrowserPanel }) + await log.flushPendingRecords() + + let creations = await log.recentEvents().filter { $0.kind == .workspaceCreated } + #expect(creations.map(\.subject.workspaceId) == [workspace.id]) + } + } + + @Test func ordinaryNewWindowStillRecordsItsInitialWorkspace() async throws { + try await withWindowHistory { app, log in + let windowId = app.createMainWindow(shouldActivate: false) + let workspace = try #require(app.tabManagerFor(windowId: windowId)?.selectedWorkspace) + await log.flushPendingRecords() + + let creations = await log.recentEvents().filter { $0.kind == .workspaceCreated } + #expect(creations.map(\.subject.workspaceId) == [workspace.id]) + #expect(creations.first?.subject.windowId == windowId) + } + } + + @Test func terminalWorkspaceWithNoWindowsRecordsItsRetainedInitialWorkspace() async throws { + try await withWindowHistory { app, log in + #expect(app.performNewWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let workspace = try #require(context.tabManager.selectedWorkspace) + #expect(context.tabManager.tabs.count == 1) + await log.flushPendingRecords() + + let creations = await log.recentEvents().filter { $0.kind == .workspaceCreated } + #expect(creations.map(\.subject.workspaceId) == [workspace.id]) + } + } + + private func withWindowHistory( + _ body: (AppDelegate, VaultHistoryEventLog) async throws -> Void + ) async rethrows { + _ = NSApplication.shared + let previousDelegate = AppDelegate.shared + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: .active) + let app = AppDelegate(vaultHistoryEventLog: log) + defer { + for windowId in app.mainWindowContexts.values.map(\.windowId) { + _ = app.closeMainWindow(windowId: windowId, recordHistory: false) + } + TerminalController.shared.setActiveTabManager(previousManager) + AppDelegate.shared = previousDelegate + } + try await body(app, log) + } + private func event(id: String) -> VaultHistoryEvent { VaultHistoryEvent( id: id, From 80623071b924164eecd785ae97648c13cf43981c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 17:57:52 -0700 Subject: [PATCH 20/76] fix: exclude temporary new-window workspaces from Vault History --- Sources/AppDelegate.swift | 21 ++++++++++++++++----- Sources/RemoteTmuxController+Attach.swift | 5 ++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 508663096c60..91cf51fc921e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5808,7 +5808,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { - let windowId = createMainWindow() + let windowId = createMainWindow(initialWorkspaceHistoryContext: .bootstrap) guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } let bootstrapWorkspaceId = destinationManager.tabs.first?.id @@ -8648,16 +8648,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chosenContext: nil ) #endif - let windowId = createMainWindow() + let windowId = createMainWindow(initialWorkspaceHistoryContext: .bootstrap) if let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { let initialWorkspace = context.tabManager.selectedWorkspace switch initialSurface { case .terminal: - _ = executeConfiguredNewWorkspaceActionIfAvailable( + let didExecute = executeConfiguredNewWorkspaceActionIfAvailable( in: context, debugSource: debugSource, replacingInitialWorkspace: initialWorkspace ) + if !didExecute, let initialWorkspace { + context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) + } case .browser: // The fresh window boots with a terminal workspace; add the // browser workspace and close that initial one so the @@ -9358,13 +9361,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent workspaceId: completion.succeeded ? completion.workspaceId : nil ) } - return executeConfiguredCmuxAction( + let didExecute = executeConfiguredCmuxAction( action, context: context, preferredWindow: window, onExecuted: onExecuted, onCloudVMCompletion: onCloudVMCompletion ) + // Actions that operate inside the initial workspace keep it as their + // user-visible result. Workspace-producing actions retire it instead, + // possibly asynchronously, so their placeholder must stay unrecorded. + if didExecute, !actionCreatesWorkspace, let initialWorkspace { + context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) + } + return didExecute } private func workspaceGroupNewWorkspaceTarget(in context: MainWindowContext) -> WorkspaceGroupNewWorkspaceTarget? { @@ -10163,6 +10173,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialWorkspaceTitle: String? = nil, initialWorkingDirectory: String? = nil, initialTerminalInput: String? = nil, + initialWorkspaceHistoryContext: VaultHistoryWorkspaceCreationContext = .semanticCreation, sessionWindowSnapshot: SessionWindowSnapshot? = nil, preferredWindowId: UUID? = nil, shouldActivate: Bool = true, @@ -10198,7 +10209,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowId: windowId, vaultHistoryEventLog: vaultHistoryEventLog, initialWorkspaceHistoryContext: sessionWindowSnapshot == nil - ? .semanticCreation + ? initialWorkspaceHistoryContext : .restoration ) tabManager.windowId = windowId diff --git a/Sources/RemoteTmuxController+Attach.swift b/Sources/RemoteTmuxController+Attach.swift index 75f556747f34..b6b132a77c1f 100644 --- a/Sources/RemoteTmuxController+Attach.swift +++ b/Sources/RemoteTmuxController+Attach.swift @@ -56,7 +56,10 @@ extension RemoteTmuxController { let targetManager: TabManager let bootstrapWorkspaceId: UUID? if windowTarget == .dedicatedNewWindow { - resolvedWindowId = appDelegate.createMainWindow(shouldActivate: false) + resolvedWindowId = appDelegate.createMainWindow( + initialWorkspaceHistoryContext: .bootstrap, + shouldActivate: false + ) guard let newWindowManager = appDelegate.tabManagerFor(windowId: resolvedWindowId) else { appDelegate.discardMainWindowWithoutClosedHistory(windowId: resolvedWindowId) cleanUpTransportAfterFailedMirror(host: host) From c0e45ba1adac8dc513868301b0e643728710e82c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 18:06:32 -0700 Subject: [PATCH 21/76] test: cover suppressed temporary-window History cleanup --- cmuxTests/VaultHistoryEventLogTests.swift | 56 ++++++++++++++++++----- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index fe9005904a7e..7d06fcd7e493 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -178,22 +178,56 @@ import Testing } } + @Test func suppressedWindowCloseDoesNotRecordWorkspaceClosures() async throws { + try await withWindowHistory { app, log in + let windowId = app.createMainWindow( + initialWorkspaceHistoryContext: .bootstrap, + shouldActivate: false + ) + #expect(app.closeMainWindow(windowId: windowId, recordHistory: false)) + await log.flushPendingRecords() + + let events = await log.recentEvents() + #expect(!events.contains { $0.kind == .workspaceCreated || $0.kind == .workspaceClosed }) + #expect(!events.contains { $0.kind == .windowClosed }) + } + } + + @Test func ordinaryWindowCloseRecordsEveryWorkspace() async throws { + try await withWindowHistory { app, log in + let windowId = app.createMainWindow(shouldActivate: false) + let manager = try #require(app.tabManagerFor(windowId: windowId)) + let initial = try #require(manager.selectedWorkspace) + let additional = manager.addWorkspace(select: false, autoWelcomeIfNeeded: false) + #expect(app.closeMainWindow(windowId: windowId)) + await log.flushPendingRecords() + + let events = await log.recentEvents() + let closedIds = events.filter { $0.kind == .workspaceClosed }.compactMap(\.subject.workspaceId) + #expect(Set(closedIds) == Set([initial.id, additional.id])) + #expect(closedIds.count == 2) + #expect(events.filter { $0.kind == .windowClosed }.count == 1) + } + } + private func withWindowHistory( _ body: (AppDelegate, VaultHistoryEventLog) async throws -> Void ) async rethrows { - _ = NSApplication.shared - let previousDelegate = AppDelegate.shared - let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() - let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: .active) - let app = AppDelegate(vaultHistoryEventLog: log) - defer { - for windowId in app.mainWindowContexts.values.map(\.windowId) { - _ = app.closeMainWindow(windowId: windowId, recordHistory: false) + try await AppContextSerialGate.withExclusiveAppContext { + _ = NSApplication.shared + let previousDelegate = AppDelegate.shared + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: .active) + let app = AppDelegate(vaultHistoryEventLog: log) + defer { + for windowId in app.mainWindowContexts.values.map(\.windowId) { + _ = app.closeMainWindow(windowId: windowId, recordHistory: false) + } + TerminalController.shared.setActiveTabManager(previousManager) + AppDelegate.shared = previousDelegate } - TerminalController.shared.setActiveTabManager(previousManager) - AppDelegate.shared = previousDelegate + try await body(app, log) } - try await body(app, log) } private func event(id: String) -> VaultHistoryEvent { From d772acc00b6c30ebe4f885033ebcd3f75e8272ab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 18:06:53 -0700 Subject: [PATCH 22/76] fix: honor History suppression when finalizing window workspaces --- Sources/AppDelegate.swift | 10 ++++++++-- Sources/TabManager.swift | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 91cf51fc921e..7b31fa36f12b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5494,7 +5494,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func finalizeRejectedMainWindowRegistrationIfUnowned(_ tabManager: TabManager) { guard !ownsMainWindowTabManager(tabManager) else { return } - tabManager.finalizeAllWorkspacesForWindowClose() + tabManager.finalizeAllWorkspacesForWindowClose(recordVaultHistory: false) } /// Register a terminal window with the AppDelegate so menu commands and socket control @@ -18531,6 +18531,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent closingWindowIsCrashDiagnostic = false } + // Capture the same suppression decision before closed-window History + // consumes its marker, and apply it to the contained workspace events. + let recordWorkspaceHistory = !closingWindowIsCrashDiagnostic + && !closedWindowHistorySuppressedWindowIds.contains(windowId) + && !isTerminatingApp + && !isApplyingSessionRestore if !closingWindowIsCrashDiagnostic { if let context { recordClosedWindowHistoryIfNeeded(for: context) @@ -18582,7 +18588,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } remoteTmuxController.handleWindowWorkspacesClosed(workspaceIds: closingWorkspaceIds) - closingTabManager.finalizeAllWorkspacesForWindowClose() + closingTabManager.finalizeAllWorkspacesForWindowClose(recordVaultHistory: recordWorkspaceHistory) closingTabManager.window = nil windowConfigFrames.removeValue(forKey: windowId) publishCmuxWindowLifecycle(name: "window.closed", windowId: windowId, origin: "appkit_close") diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 9888037f1d04..6adb7367200e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2509,7 +2509,7 @@ class TabManager: ObservableObject { /// Finalizes every workspace owned by a closing window without creating a /// replacement workspace or recording per-workspace closed-item history. - func finalizeAllWorkspacesForWindowClose() { + func finalizeAllWorkspacesForWindowClose(recordVaultHistory: Bool = true) { guard !isFinalizedForWindowClose else { return } isFinalizedForWindowClose = true let closingWorkspaces = Array(tabs) @@ -2517,7 +2517,9 @@ class TabManager: ObservableObject { sidebarGitMetadataService.resetAllWorkspaceGitProbeTracking() for workspace in closingWorkspaces { - recordVaultHistoryWorkspaceClosed(workspace) + if recordVaultHistory { + recordVaultHistoryWorkspaceClosed(workspace) + } finalizeWorkspaceForRemoval(workspace, clearsWorkspaceGitProbes: false) } From 8f23af29f9c345c4d8fd98bf8a7327571043e697 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 18:51:33 -0700 Subject: [PATCH 23/76] fix: align Bonsplit with the current main integration --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 874313e924ad..0501fed692b2 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 874313e924ad0ee0fc8a28c698fd2b2b62e259a2 +Subproject commit 0501fed692b23bf793b60b046caccc08332b3d0b From a5c309e63b934b132449ead28fb6c77d12154af6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 20:41:49 -0700 Subject: [PATCH 24/76] test: measure the 1000-session History projection --- ...ltHistorySessionEventProjectionTests.swift | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cmuxTests/VaultHistorySessionEventProjectionTests.swift b/cmuxTests/VaultHistorySessionEventProjectionTests.swift index 67e6655e08e1..a499d283cf75 100644 --- a/cmuxTests/VaultHistorySessionEventProjectionTests.swift +++ b/cmuxTests/VaultHistorySessionEventProjectionTests.swift @@ -40,4 +40,65 @@ import Testing #expect(event.subject.sessionId == "abc123") #expect(event.subject.directory == "/tmp/repo") } + + /// Reports the cost of the production projection for the review's + /// 1,000-session case without making correctness depend on CI hardware. + @Test func projectionMeasuresThousandSessionSnapshot() { + let entries = (0..<1000).map { index in + SessionEntry( + id: "\(index)", + agent: .claude, + sessionId: "\(index)", + title: "Review a deliberately long session title for workspace \(index)", + cwd: "/tmp/vault-history-benchmark/project-\(index % 20)", + gitBranch: nil, + pullRequest: nil, + // A deterministic permutation exercises an unsorted snapshot. + modified: Date(timeIntervalSince1970: Double((index * 37) % 1000)), + fileURL: nil, + specifics: .claude( + model: nil, + permissionMode: nil, + configDirectoryForResume: nil + ) + ) + } + let projection = VaultHistorySessionEventProjection() + let expected = projection.events(from: entries) + #expect(expected.count == 1000) + #expect(Set(expected.map(\.id)).count == 1000) + #expect(expected.first?.id == "session:claude:27") + #expect(expected.last?.id == "session:claude:0") + #expect(zip(expected, expected.dropFirst()).allSatisfy { pair in + pair.0.timestamp > pair.1.timestamp + }) + + for _ in 0..<5 { + #expect(projection.events(from: entries) == expected) + } + + let clock = ContinuousClock() + var milliseconds: [Double] = [] + for _ in 0..<30 { + var projected = expected + let duration = clock.measure { + projected = projection.events(from: entries) + } + // Observe every output field outside the timed section, both to + // check repeatability and to keep the measured work observable. + #expect(projected == expected) + let components = duration.components + milliseconds.append( + Double(components.seconds) * 1000 + + Double(components.attoseconds) / 1_000_000_000_000_000 + ) + } + milliseconds.sort() + let median = milliseconds[milliseconds.count / 2] + let p95 = milliseconds[(milliseconds.count * 95 - 1) / 100] + print( + "VAULT_HISTORY_PROJECTION_BENCHMARK entries=1000 samples=30 " + + "input=permuted median_ms=\(median) p95_ms=\(p95)" + ) + } } From 084659d07721f2d03098e39d7b92fb27ba78cfd7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 21:35:26 -0700 Subject: [PATCH 25/76] refactor: inject configured window action dependencies --- Sources/AppDelegate.swift | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7b31fa36f12b..e214e24ced69 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -543,6 +543,17 @@ final class CmuxMainThreadTurnProfiler { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation, NSMenuDelegate, CmuxConfigStoreReloadEnvironment { + typealias ConfiguredActionExecutor = ( + CmuxResolvedConfigAction, + MainWindowContext, + NSWindow?, + (() -> Void)?, + ((CloudVMActionLauncher.Completion) -> Void)? + ) -> Bool + + private var configuredActionExecutor: ConfiguredActionExecutor? + private var windowConfigStoreFactory: () -> CmuxConfigStore = { CmuxConfigStore() } + nonisolated(unsafe) static var shared: AppDelegate? /// Stateless control-socket syscall layer (CmuxControlSocket); composition-root owned. nonisolated let socketTransport = SocketTransport() @@ -1308,9 +1319,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif /// Allows lifecycle consumers to supply an isolated History store before any window is created. - convenience init(vaultHistoryEventLog: VaultHistoryEventLog) { + convenience init( + vaultHistoryEventLog: VaultHistoryEventLog, + windowConfigStoreFactory: @escaping () -> CmuxConfigStore = { CmuxConfigStore() }, + configuredActionExecutor: ConfiguredActionExecutor? = nil + ) { self.init() self.vaultHistoryEventLog = vaultHistoryEventLog + self.windowConfigStoreFactory = windowConfigStoreFactory + self.configuredActionExecutor = configuredActionExecutor } override init() { @@ -10267,7 +10284,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.syncWorkspaceTabBarLeadingInset(initialTabBarLeadingInset) let notificationStore = TerminalNotificationStore.shared - let cmuxConfigStore = CmuxConfigStore() + let cmuxConfigStore = windowConfigStoreFactory() cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) cmuxConfigStore.loadAll() @@ -17610,6 +17627,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent onExecuted: (() -> Void)? = nil, onCloudVMCompletion: ((CloudVMActionLauncher.Completion) -> Void)? = nil ) -> Bool { + if let configuredActionExecutor { + return configuredActionExecutor(action, context, preferredWindow, onExecuted, onCloudVMCompletion) + } switch action.action { case .builtIn(let builtIn): switch builtIn { From fd75bb04b7d131ec3b9801e8b30fcbade9126302 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 21:37:01 -0700 Subject: [PATCH 26/76] test: expose uncommitted window and Cloud VM History events --- cmuxTests/VaultHistoryEventLogTests.swift | 127 +++++++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 7d06fcd7e493..b0dbce865d50 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -136,6 +136,114 @@ import Testing let creations = await log.recentEvents().filter { $0.kind == .workspaceCreated } #expect(creations.map(\.subject.workspaceId) == [workspace.id]) + let openings = await log.recentEvents().filter { $0.kind == .windowOpened } + #expect(Set(openings.compactMap(\.subject.windowId)) == Set([sourceWindowId, destinationId])) + #expect(openings.count == 2) + } + } + + @Test func failedWorkspaceMoveDoesNotRecordItsDiscardedWindow() async throws { + try await withWindowHistory { app, log in + #expect(app.moveWorkspaceToNewWindow(workspaceId: UUID(), focus: false) == nil) + #expect(app.mainWindowContexts.isEmpty) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + } + } + + @Test func discardedBootstrapWindowDoesNotPublishAnyPendingLifecycleEvents() async throws { + try await withWindowHistory { app, log in + let windowId = app.createMainWindow( + initialWorkspaceHistoryContext: .bootstrap, + shouldActivate: false + ) + let manager = try #require(app.tabManagerFor(windowId: windowId)) + let workspace = try #require(manager.addWorkspaceIfActive(select: true, autoWelcomeIfNeeded: false)) + #expect(manager.setCustomTitle(tabId: workspace.id, title: "Uncommitted")) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + + app.discardMainWindowWithoutClosedHistory(windowId: windowId) + #expect(app.mainWindowContexts.isEmpty) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + } + } + + @Test(arguments: ["cmux.cloudVM", "cmux.newWorkspace", "cmux.newAgentChat"]) + func configuredWorkspaceActionRecordsOnlyItsRetainedWorkspace(actionId: String) async throws { + var createdId: UUID? + try await withWindowHistory( + configuredActionId: actionId, + configuredActionExecutor: { _, context, _, onExecuted, _ in + guard let workspace = context.tabManager.addWorkspaceIfActive( + initialSurface: .cloudVMLoading, + select: true, + autoWelcomeIfNeeded: false + ) else { return false } + createdId = workspace.id + onExecuted?() + return true + } + ) { app, log in + #expect(app.performNewWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let retainedId = try #require(createdId) + #expect(context.tabManager.tabs.map(\.id) == [retainedId]) + await log.flushPendingRecords() + let events = await log.recentEvents() + #expect(events.filter { $0.kind == .workspaceCreated }.map(\.subject.workspaceId) == [createdId]) + #expect(events.filter { $0.kind == .windowOpened }.count == 1) + #expect(!events.contains { $0.kind == .workspaceClosed }) + } + } + + @Test(arguments: [true, false]) + func delayedCloudVMCompletionRecordsTheActualRetainedWorkspace(succeeded: Bool) async throws { + var complete: ((CloudVMActionLauncher.Completion) -> Void)? + try await withWindowHistory( + configuredActionId: "cmux.cloudVM", + configuredActionExecutor: { _, _, _, onExecuted, onCompletion in + complete = onCompletion + onExecuted?() + return true + } + ) { app, log in + #expect(app.performNewWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let initialWorkspace = try #require(context.tabManager.selectedWorkspace) + await log.flushPendingRecords() + #expect(await log.recentEvents().filter { $0.kind == .workspaceCreated }.isEmpty) + + let result = try #require(succeeded ? context.tabManager.addWorkspaceIfActive( + initialSurface: .cloudVMLoading, + select: true, + autoWelcomeIfNeeded: false + ) : initialWorkspace) + let completion = try #require(complete) + completion(.init(terminationStatus: succeeded ? 0 : 1, output: "", workspaceId: succeeded ? result.id : nil)) + #expect(context.tabManager.tabs.map(\.id) == [result.id]) + await log.flushPendingRecords() + let events = await log.recentEvents() + #expect(events.filter { $0.kind == .workspaceCreated }.map(\.subject.workspaceId) == [result.id]) + #expect(events.filter { $0.kind == .windowOpened }.count == 1) + } + } + + @Test func configuredInWorkspaceActionRecordsItsRetainedInitialWorkspace() async throws { + try await withWindowHistory( + configuredActionId: "cmux.newTerminal", + configuredActionExecutor: { _, _, _, onExecuted, _ in + onExecuted?() + return true + } + ) { app, log in + #expect(app.performNewWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let workspace = try #require(context.tabManager.selectedWorkspace) + await log.flushPendingRecords() + let events = await log.recentEvents() + #expect(events.filter { $0.kind == .workspaceCreated }.map(\.subject.workspaceId) == [workspace.id]) } } @@ -188,8 +296,7 @@ import Testing await log.flushPendingRecords() let events = await log.recentEvents() - #expect(!events.contains { $0.kind == .workspaceCreated || $0.kind == .workspaceClosed }) - #expect(!events.contains { $0.kind == .windowClosed }) + #expect(events.isEmpty) } } @@ -211,14 +318,26 @@ import Testing } private func withWindowHistory( + configuredActionId: String? = nil, + configuredActionExecutor: AppDelegate.ConfiguredActionExecutor? = nil, _ body: (AppDelegate, VaultHistoryEventLog) async throws -> Void - ) async rethrows { + ) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("vault-history-actions-\(UUID())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let configURL = root.appendingPathComponent("cmux.json") + let config = configuredActionId.map { "{\"ui\":{\"newWorkspace\":{\"action\":\"\($0)\"}}}" } ?? "{}" + try config.write(to: configURL, atomically: true, encoding: .utf8) try await AppContextSerialGate.withExclusiveAppContext { _ = NSApplication.shared let previousDelegate = AppDelegate.shared let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: .active) - let app = AppDelegate(vaultHistoryEventLog: log) + let app = AppDelegate( + vaultHistoryEventLog: log, + windowConfigStoreFactory: { CmuxConfigStore(globalConfigPath: configURL.path) }, + configuredActionExecutor: configuredActionExecutor + ) defer { for windowId in app.mainWindowContexts.values.map(\.windowId) { _ = app.closeMainWindow(windowId: windowId, recordHistory: false) From d41d75c352e9546402dffa860e1d8ad48e373949 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 21:39:48 -0700 Subject: [PATCH 27/76] fix: commit only retained window and configured workspace History --- Sources/AppDelegate.swift | 79 +++++++++++++++-------- Sources/RemoteTmuxController+Attach.swift | 4 ++ Sources/VaultHistoryEventLog.swift | 23 +++++++ 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e214e24ced69..2b83a73f8175 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5826,7 +5826,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { let windowId = createMainWindow(initialWorkspaceHistoryContext: .bootstrap) - guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + guard let destinationManager = tabManagerFor(windowId: windowId) else { + discardMainWindowWithoutClosedHistory(windowId: windowId) + return nil + } let bootstrapWorkspaceId = destinationManager.tabs.first?.id guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { @@ -5841,6 +5844,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent destinationManager.tabs.count > 1 { destinationManager.closeWorkspace(bootstrapWorkspace, recordHistory: false) } + vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) return windowId } @@ -6798,6 +6802,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func discardMainWindowWithoutClosedHistory(windowId: UUID) { + defer { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) } guard let window = mainWindowForClose(windowId: windowId) else { return } closedWindowHistorySuppressedWindowIds.insert(windowId) if !closeMainWindowWithoutInteractiveVeto(window) { @@ -8666,6 +8671,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #endif let windowId = createMainWindow(initialWorkspaceHistoryContext: .bootstrap) + var retainedWindow = false + defer { + if retainedWindow { + vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) + } else { + discardMainWindowWithoutClosedHistory(windowId: windowId) + } + } if let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { let initialWorkspace = context.tabManager.selectedWorkspace switch initialSurface { @@ -8708,8 +8721,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) context.tabManager.setPinned(workspace, pinned: true) } + retainedWindow = !context.tabManager.isFinalizedForWindowClose } - return true + return retainedWindow } let context = livePreferredContext @@ -9334,10 +9348,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let beforeIds = workspaceGroupTarget.map { _ in Set(context.tabManager.tabs.map(\.id)) } var asyncObserverId: UUID? - // Named workspace commands and inline workspace actions both create a - // workspace, so both must retire the throwaway initial workspace. - let actionCreatesWorkspace = action.workspaceCommandName != nil || action.action.inlineWorkspace != nil || action.action == .builtIn(.newAgentChat) - let onExecuted: (() -> Void)? = (!actionCreatesWorkspace && workspaceGroupTarget == nil) ? nil : { [weak self, weak context] in + var didFinishInitialWorkspace = false + // Inspect the action's actual result instead of maintaining a second list + // of workspace-producing actions. Cloud VM may complete after onExecuted. + let finishInitialWorkspace: (Bool) -> Void = { [weak self, weak context] mayRetainInitial in + guard !didFinishInitialWorkspace, + let self, + let context, + let initialWorkspaceId, + !context.tabManager.isFinalizedForWindowClose, + let initial = context.tabManager.tabs.first(where: { $0.id == initialWorkspaceId }) else { return } + guard mayRetainInitial || context.tabManager.tabs.count > 1 else { return } + self.closeInitialWorkspaceIfNeeded(initialWorkspaceId: initialWorkspaceId, in: context) + if context.tabManager.tabs.contains(where: { $0.id == initialWorkspaceId }) { + context.tabManager.recordVaultHistoryWorkspaceCreated(initial) + } + didFinishInitialWorkspace = true + } + let onExecuted: () -> Void = { [weak context] in if let context, let workspaceGroupTarget, let beforeIds { @@ -9363,35 +9391,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } } - if actionCreatesWorkspace { - self?.closeInitialWorkspaceIfNeeded( - initialWorkspaceId: initialWorkspaceId, - in: context + finishInitialWorkspace(action.action != .builtIn(.cloudVM)) + } + let onCloudVMCompletion: (CloudVMActionLauncher.Completion) -> Void = { [weak context] completion in + if let context, let asyncObserverId { + ConfiguredGroupActionAsyncWorkspaceObserver.finishPending( + tabManager: context.tabManager, + observerId: asyncObserverId, + workspaceId: completion.succeeded ? completion.workspaceId : nil ) } + finishInitialWorkspace(true) } - let onCloudVMCompletion: ((CloudVMActionLauncher.Completion) -> Void)? = workspaceGroupTarget == nil ? nil : { [weak context] completion in - guard let context, let asyncObserverId else { return } - ConfiguredGroupActionAsyncWorkspaceObserver.finishPending( - tabManager: context.tabManager, - observerId: asyncObserverId, - workspaceId: completion.succeeded ? completion.workspaceId : nil - ) - } - let didExecute = executeConfiguredCmuxAction( + return executeConfiguredCmuxAction( action, context: context, preferredWindow: window, onExecuted: onExecuted, onCloudVMCompletion: onCloudVMCompletion ) - // Actions that operate inside the initial workspace keep it as their - // user-visible result. Workspace-producing actions retire it instead, - // possibly asynchronously, so their placeholder must stay unrecorded. - if didExecute, !actionCreatesWorkspace, let initialWorkspace { - context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) - } - return didExecute } private func workspaceGroupNewWorkspaceTarget(in context: MainWindowContext) -> WorkspaceGroupNewWorkspaceTarget? { @@ -10213,6 +10231,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent reserveInitialSocketPathIfNeeded() let requestedWindowId = preferredWindowId ?? sessionWindowSnapshot?.windowId let windowId = availableWindowIdForNewMainWindow(preferredWindowId: requestedWindowId) ?? UUID() + vaultHistoryEventLog?.beginWindowCreation(windowId: windowId) + defer { + if tabManagerFor(windowId: windowId) == nil { + vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) + } else if initialWorkspaceHistoryContext != .bootstrap || sessionWindowSnapshot != nil { + vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) + } + } let tabManager = TabManager( initialWorkspaceTitle: initialWorkspaceTitle, initialWorkingDirectory: initialWorkingDirectory, @@ -18609,6 +18635,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } remoteTmuxController.handleWindowWorkspacesClosed(workspaceIds: closingWorkspaceIds) closingTabManager.finalizeAllWorkspacesForWindowClose(recordVaultHistory: recordWorkspaceHistory) + vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) closingTabManager.window = nil windowConfigFrames.removeValue(forKey: windowId) publishCmuxWindowLifecycle(name: "window.closed", windowId: windowId, origin: "appkit_close") diff --git a/Sources/RemoteTmuxController+Attach.swift b/Sources/RemoteTmuxController+Attach.swift index b6b132a77c1f..409671834938 100644 --- a/Sources/RemoteTmuxController+Attach.swift +++ b/Sources/RemoteTmuxController+Attach.swift @@ -109,6 +109,10 @@ extension RemoteTmuxController { targetManager.closeWorkspace(bootstrap, recordHistory: false) } + if windowTarget == .dedicatedNewWindow { + appDelegate.vaultHistoryEventLog?.commitWindowCreation(windowId: resolvedWindowId) + } + if activate { selectFirstMirrorWorkspace(for: host, in: targetManager) _ = appDelegate.focusMainWindow(windowId: resolvedWindowId) diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index 76d69ab4d338..f788720d6f93 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -17,6 +17,8 @@ final class VaultHistoryEventLog { private let store: any VaultHistoryEventStoring private var pendingRecordTask: Task? private var pendingRecordCount = 0 + /// Uncommitted window operations cannot publish an open or any child event. + private var pendingWindowEvents: [UUID: [VaultHistoryEvent]] = [:] /// Whether accepted records are still queued or being persisted. var hasPendingRecords: Bool { @@ -37,6 +39,10 @@ final class VaultHistoryEventLog { func record(_ event: VaultHistoryEvent) { guard phase == .active else { return } + if let windowId = event.subject.windowId, pendingWindowEvents[windowId] != nil { + pendingWindowEvents[windowId, default: []].append(event) + return + } let store = store let previous = pendingRecordTask pendingRecordCount += 1 @@ -59,6 +65,23 @@ final class VaultHistoryEventLog { } } + func beginWindowCreation(windowId: UUID) { + pendingWindowEvents[windowId] = [] + } + + /// Publishes the original event snapshots exactly once after the window is retained. + func commitWindowCreation(windowId: UUID) { + guard let events = pendingWindowEvents.removeValue(forKey: windowId) else { return } + for event in events { + record(event) + } + } + + /// Failed bootstrap operations never enter the append-only store. + func discardWindowCreation(windowId: UUID) { + pendingWindowEvents.removeValue(forKey: windowId) + } + func recentEvents(limit: Int = Int.max) async -> [VaultHistoryEvent] { await store.recentEvents(limit: limit) } From e13a1158a848f19f1d1ac05740f7513e54c6c8ee Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 21:48:02 -0700 Subject: [PATCH 28/76] fix: isolate configured window dependencies to the main actor --- Sources/AppDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2b83a73f8175..a74a1e8d8d85 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -543,7 +543,7 @@ final class CmuxMainThreadTurnProfiler { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation, NSMenuDelegate, CmuxConfigStoreReloadEnvironment { - typealias ConfiguredActionExecutor = ( + typealias ConfiguredActionExecutor = @MainActor ( CmuxResolvedConfigAction, MainWindowContext, NSWindow?, @@ -552,7 +552,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) -> Bool private var configuredActionExecutor: ConfiguredActionExecutor? - private var windowConfigStoreFactory: () -> CmuxConfigStore = { CmuxConfigStore() } + private var windowConfigStoreFactory: @MainActor () -> CmuxConfigStore = { CmuxConfigStore() } nonisolated(unsafe) static var shared: AppDelegate? /// Stateless control-socket syscall layer (CmuxControlSocket); composition-root owned. @@ -1321,7 +1321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Allows lifecycle consumers to supply an isolated History store before any window is created. convenience init( vaultHistoryEventLog: VaultHistoryEventLog, - windowConfigStoreFactory: @escaping () -> CmuxConfigStore = { CmuxConfigStore() }, + windowConfigStoreFactory: @escaping @MainActor () -> CmuxConfigStore = { CmuxConfigStore() }, configuredActionExecutor: ConfiguredActionExecutor? = nil ) { self.init() From f34b28db2b8b6505a1897ceacc97da38c2d85fdf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 22:07:30 -0700 Subject: [PATCH 29/76] test: exercise Vault tabs grouping and standalone pane in the UI --- .../RightSidebarChromeHeightUITests.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 59fc0f81d693..60f9cc60bc35 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -4,6 +4,83 @@ import CoreGraphics import ImageIO final class RightSidebarChromeHeightUITests: XCTestCase { + func testVaultTabsGroupingAndPopoutStayInteractive() throws { + let app = XCUIApplication.cmuxTestApplication() + let dataPath = "/tmp/cmux-ui-test-vault-interaction-\(UUID()).json" + defer { try? FileManager.default.removeItem(atPath: dataPath) } + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_SHOW_RIGHT_SIDEBAR"] = "1" + app.launchArguments += ["-workspacePresentationMode", "minimal", "-vaultPane.tab", "sessions"] + app.launch() + defer { app.terminate() } + app.activate() + let ready = try XCTUnwrap(waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: 25)) + XCTAssertTrue((ready["setupError"] ?? "").isEmpty, "Setup failed: \(ready)") + + let vault = app.buttons["RightSidebarModeButton.sessions"] + XCTAssertTrue(vault.waitForExistence(timeout: 10)) + vault.click() + let sessions = app.buttons["VaultPaneTabButton.sessions"].firstMatch + let history = app.buttons["VaultPaneTabButton.history"].firstMatch + XCTAssertTrue(sessions.waitForExistence(timeout: 10)) + XCTAssertTrue(history.waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 10)) + addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") + + history.click() + let picker = app.descendants(matching: .any)["VaultHistoryGroupPicker"].firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { + picker.click() + let item = app.menuItems[grouping].firstMatch + XCTAssertTrue(item.waitForExistence(timeout: 5), "Missing History grouping: \(grouping)") + item.click() + let selected = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + picker.label.contains(grouping) || picker.staticTexts[grouping].exists + }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [selected], timeout: 5), .completed) + } + addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-history-after-menu-interaction") + + sessions.click() + XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 5)) + history.click() + XCTAssertTrue(picker.waitForExistence(timeout: 5)) + + let popout = app.buttons["RightSidebar.openAsPaneButton"] + XCTAssertTrue(popout.waitForExistence(timeout: 5)) + popout.click() + let mountedTwice = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 2 + }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [mountedTwice], timeout: 10), .completed) + + // Hide the sidebar so subsequent clicks can only target the independent pane. + let closeSidebar = app.buttons["RightSidebar.closeButton"] + closeSidebar.click() + let paneOnly = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 1 + && !closeSidebar.exists + }, + object: nil + ) + XCTAssertEqual(XCTWaiter.wait(for: [paneOnly], timeout: 10), .completed) + app.buttons["VaultPaneTabButton.sessions"].click() + XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 5)) + app.buttons["VaultPaneTabButton.history"].click() + XCTAssertTrue(picker.waitForExistence(timeout: 5)) + addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-history-standalone-pane") + } + func testSecondaryBarMatchesModeBarAndPaneTabs() { let app = XCUIApplication.cmuxTestApplication() let dataPath = "/tmp/cmux-ui-test-right-sidebar-chrome-\(UUID().uuidString).json" From 421e53ffe152adbd88be01827ffca051ef64fd59 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 22:14:34 -0700 Subject: [PATCH 30/76] test: use the built Vault accessibility labels for UI interaction --- .../RightSidebarChromeHeightUITests.swift | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 60f9cc60bc35..eaebbd8f905e 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -12,7 +12,10 @@ final class RightSidebarChromeHeightUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_SHOW_RIGHT_SIDEBAR"] = "1" - app.launchArguments += ["-workspacePresentationMode", "minimal", "-vaultPane.tab", "sessions"] + app.launchArguments += [ + "-workspacePresentationMode", "minimal", "-vaultPane.tab", "sessions", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + ] app.launch() defer { app.terminate() } app.activate() @@ -22,15 +25,19 @@ final class RightSidebarChromeHeightUITests: XCTestCase { let vault = app.buttons["RightSidebarModeButton.sessions"] XCTAssertTrue(vault.waitForExistence(timeout: 10)) vault.click() - let sessions = app.buttons["VaultPaneTabButton.sessions"].firstMatch - let history = app.buttons["VaultPaneTabButton.history"].firstMatch + // SwiftUI propagates the sidebar container's accessibility identifier + // over these children; their localized labels remain exposed in AX. + let sessions = app.buttons["Sessions"].firstMatch + let history = app.buttons["History"].firstMatch XCTAssertTrue(sessions.waitForExistence(timeout: 10)) XCTAssertTrue(history.waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Folder"].waitForExistence(timeout: 10)) addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") history.click() - let picker = app.descendants(matching: .any)["VaultHistoryGroupPicker"].firstMatch + let picker = app.menuButtons.matching( + NSPredicate(format: "label IN %@", ["Date", "Workspace", "Window", "Agent", "Type"]) + ).firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 10)) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() @@ -48,7 +55,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-history-after-menu-interaction") sessions.click() - XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Folder"].waitForExistence(timeout: 5)) history.click() XCTAssertTrue(picker.waitForExistence(timeout: 5)) @@ -57,7 +64,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { popout.click() let mountedTwice = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 2 + app.buttons.matching(NSPredicate(format: "label == %@", "History")).count == 2 }, object: nil ) @@ -68,15 +75,15 @@ final class RightSidebarChromeHeightUITests: XCTestCase { closeSidebar.click() let paneOnly = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 1 + app.buttons.matching(NSPredicate(format: "label == %@", "History")).count == 1 && !closeSidebar.exists }, object: nil ) XCTAssertEqual(XCTWaiter.wait(for: [paneOnly], timeout: 10), .completed) - app.buttons["VaultPaneTabButton.sessions"].click() - XCTAssertTrue(app.buttons["SessionGroupingButton.directory"].waitForExistence(timeout: 5)) - app.buttons["VaultPaneTabButton.history"].click() + app.buttons["Sessions"].click() + XCTAssertTrue(app.buttons["Folder"].waitForExistence(timeout: 5)) + app.buttons["History"].click() XCTAssertTrue(picker.waitForExistence(timeout: 5)) addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-history-standalone-pane") } From 92394063441b9b4e963b2c13e8cd1aa8811482b8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 22:42:56 -0700 Subject: [PATCH 31/76] refactor: inject the startup restore readiness boundary --- Sources/AppDelegate.swift | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a74a1e8d8d85..30cef331baba 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -550,9 +550,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent (() -> Void)?, ((CloudVMActionLauncher.Completion) -> Void)? ) -> Bool + typealias StartupSessionRestoreDeferral = @MainActor ( + _ resume: @escaping @MainActor @Sendable () -> Void + ) -> Bool private var configuredActionExecutor: ConfiguredActionExecutor? private var windowConfigStoreFactory: @MainActor () -> CmuxConfigStore = { CmuxConfigStore() } + /// The external readiness boundary can defer restore without blocking the UI. + private var startupSessionRestoreDeferral: StartupSessionRestoreDeferral = { resume in + guard !SurfaceResumeApprovalStore.signingSecretIsReady else { return false } + SurfaceResumeApprovalStore.whenSigningSecretReady { + Task { @MainActor in resume() } + } + return true + } nonisolated(unsafe) static var shared: AppDelegate? /// Stateless control-socket syscall layer (CmuxControlSocket); composition-root owned. @@ -1322,12 +1333,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent convenience init( vaultHistoryEventLog: VaultHistoryEventLog, windowConfigStoreFactory: @escaping @MainActor () -> CmuxConfigStore = { CmuxConfigStore() }, - configuredActionExecutor: ConfiguredActionExecutor? = nil + configuredActionExecutor: ConfiguredActionExecutor? = nil, + startupSessionRestoreDeferral: StartupSessionRestoreDeferral? = nil ) { self.init() self.vaultHistoryEventLog = vaultHistoryEventLog self.windowConfigStoreFactory = windowConfigStoreFactory self.configuredActionExecutor = configuredActionExecutor + if let startupSessionRestoreDeferral { + self.startupSessionRestoreDeferral = startupSessionRestoreDeferral + } } override init() { @@ -3972,13 +3987,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) -> Bool { guard !didAttemptStartupSessionRestore else { return false } - guard SurfaceResumeApprovalStore.signingSecretIsReady else { - SurfaceResumeApprovalStore.whenSigningSecretReady { [weak self, weak primaryWindow] in - DispatchQueue.main.async { - guard let self, let primaryWindow else { return } - self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: primaryWindow) - } - } + if startupSessionRestoreDeferral({ [weak self, weak primaryWindow] in + guard let self, let primaryWindow else { return } + self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: primaryWindow) + }) { return false } didAttemptStartupSessionRestore = true From f56173678d56430a0ec7679aa380991ddd3d5883 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 22:43:33 -0700 Subject: [PATCH 32/76] test: cover History while startup restore waits and year-crossing weeks --- .../VaultHistoryDateBucketTests.swift | 77 +++++++++++++++++++ cmuxTests/VaultHistoryEventLogTests.swift | 61 ++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryDateBucketTests.swift diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryDateBucketTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryDateBucketTests.swift new file mode 100644 index 000000000000..2da64182ace3 --- /dev/null +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryDateBucketTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing + +@testable import CmuxVaultHistory + +@Suite struct VaultHistoryDateBucketTests { + @Test(arguments: [Calendar.Identifier.gregorian, .iso8601], [ + "UTC", "America/Los_Angeles", "Asia/Tokyo", + ]) + func weekGroupingSpansNewYear( + identifier: Calendar.Identifier, + timeZoneIdentifier: String + ) throws { + var calendar = Calendar(identifier: identifier) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + + let firstWeekNow = try #require(calendar.date(from: + DateComponents(year: 2025, month: 1, day: 3, hour: 12) + )) + let firstWeekStart = try #require(calendar.date(from: + DateComponents(year: 2024, month: 12, day: 30, hour: 12) + )) + let precedingSunday = try #require(calendar.date(from: + DateComponents(year: 2024, month: 12, day: 29, hour: 12) + )) + let priorYearFirstWeek = try #require(calendar.date(from: + DateComponents(year: 2024, month: 1, day: 1, hour: 12) + )) + let lastWeekNow = try #require(calendar.date(from: + DateComponents(year: 2021, month: 1, day: 1, hour: 12) + )) + let lastWeekStart = try #require(calendar.date(from: + DateComponents(year: 2020, month: 12, day: 28, hour: 12) + )) + + // Both directions of calendar-year / week-numbering-year disagreement. + // These dates are outside the rolling 24 hours and yesterday buckets. + #expect(VaultHistoryDateBucket.bucket( + for: firstWeekStart, now: firstWeekNow, calendar: calendar + ) == .thisWeek) + #expect(VaultHistoryDateBucket.bucket( + for: lastWeekStart, now: lastWeekNow, calendar: calendar + ) == .thisWeek) + #expect(VaultHistoryDateBucket.bucket( + for: precedingSunday, now: firstWeekNow, calendar: calendar + ) == .older) + #expect(VaultHistoryDateBucket.bucket( + for: priorYearFirstWeek, now: firstWeekNow, calendar: calendar + ) == .older) + } + + @Test func weekGroupingHonorsSundayFirstCalendarAtNewYear() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + calendar.firstWeekday = 1 + calendar.minimumDaysInFirstWeek = 1 + + let now = try #require(calendar.date(from: + DateComponents(year: 2025, month: 1, day: 2, hour: 12) + )) + let sunday = try #require(calendar.date(from: + DateComponents(year: 2024, month: 12, day: 29, hour: 12) + )) + let precedingSaturday = try #require(calendar.date(from: + DateComponents(year: 2024, month: 12, day: 28, hour: 12) + )) + + #expect(VaultHistoryDateBucket.bucket( + for: sunday, now: now, calendar: calendar + ) == .thisWeek) + #expect(VaultHistoryDateBucket.bucket( + for: precedingSaturday, now: now, calendar: calendar + ) == .older) + } +} diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index b0dbce865d50..07e015e7e9df 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -54,6 +54,60 @@ import Testing #expect(await rejectedLog.recentEvents().isEmpty) } + @Test func interactiveHistoryDoesNotWaitForStartupSigningSecret() async throws { + var signingSecretReady = false + var resumeCallbacks: [@MainActor @Sendable () -> Void] = [] + try await withWindowHistory( + initialPhase: .launching, + startupSessionRestoreDeferral: { resume in + guard !signingSecretReady else { return false } + resumeCallbacks.append(resume) + return true + } + ) { app, log in + let firstWindowId = app.createMainWindow(shouldActivate: false) + let manager = try #require(app.tabManagerFor(windowId: firstWindowId)) + let startupWorkspace = try #require(manager.selectedWorkspace) + #expect(!app.didAttemptStartupSessionRestore) + #expect(log.phase == .active) + + let workspace = manager.addWorkspace(select: false, autoWelcomeIfNeeded: false) + #expect(manager.setCustomTitle(tabId: workspace.id, title: "While restore is waiting")) + manager.closeWorkspace(workspace) + let secondWindowId = app.createMainWindow(shouldActivate: false) + let secondWorkspace = try #require(app.tabManagerFor(windowId: secondWindowId)?.selectedWorkspace) + await log.flushPendingRecords() + + let events = await log.recentEvents() + #expect(events.count == 5) + #expect(events.filter { $0.kind == .windowOpened }.map(\.subject.windowId) == [secondWindowId]) + #expect(Set(events.filter { $0.kind == .workspaceCreated }.compactMap(\.subject.workspaceId)) + == Set([workspace.id, secondWorkspace.id])) + #expect(events.filter { $0.kind == .workspaceRenamed }.map(\.subject.workspaceId) == [workspace.id]) + #expect(events.filter { $0.kind == .workspaceClosed }.map(\.subject.workspaceId) == [workspace.id]) + #expect(!events.contains { $0.subject.workspaceId == startupWorkspace.id }) + #expect(!app.didAttemptStartupSessionRestore) + + // The external callback may be delayed indefinitely. When it does + // arrive, the real startup continuation must not add restore noise. + let resume = try #require(resumeCallbacks.first) + signingSecretReady = true + resume() + #expect(app.didAttemptStartupSessionRestore) + #expect(log.phase == .active) + await log.flushPendingRecords() + #expect(await log.recentEvents().map(\.id) == events.map(\.id)) + + app.isApplyingSessionRestore = true + #expect(log.phase == .restoring) + #expect(manager.setCustomTitle(tabId: startupWorkspace.id, title: "Restored title")) + app.isApplyingSessionRestore = false + #expect(log.phase == .active) + await log.flushPendingRecords() + #expect(await log.recentEvents().map(\.id) == events.map(\.id)) + } + } + @Test func workspaceRecordingDistinguishesSemanticCreationFromBootstrapAndRestore() async { let store = VaultHistoryEventStore(fileURL: nil) let log = VaultHistoryEventLog(store: store, phase: .active) @@ -320,6 +374,8 @@ import Testing private func withWindowHistory( configuredActionId: String? = nil, configuredActionExecutor: AppDelegate.ConfiguredActionExecutor? = nil, + initialPhase: VaultHistoryRecordingPhase = .active, + startupSessionRestoreDeferral: AppDelegate.StartupSessionRestoreDeferral? = nil, _ body: (AppDelegate, VaultHistoryEventLog) async throws -> Void ) async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("vault-history-actions-\(UUID())") @@ -332,11 +388,12 @@ import Testing _ = NSApplication.shared let previousDelegate = AppDelegate.shared let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() - let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: .active) + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: initialPhase) let app = AppDelegate( vaultHistoryEventLog: log, windowConfigStoreFactory: { CmuxConfigStore(globalConfigPath: configURL.path) }, - configuredActionExecutor: configuredActionExecutor + configuredActionExecutor: configuredActionExecutor, + startupSessionRestoreDeferral: startupSessionRestoreDeferral ) defer { for windowId in app.mainWindowContexts.values.map(\.windowId) { From ca160524b3a45c45adea5213677d99b236d23d7a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 22:43:52 -0700 Subject: [PATCH 33/76] fix: enable History when the first window is registered --- Sources/AppDelegate.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 30cef331baba..2dbd16195c72 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1169,6 +1169,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var previousSessionLaunchWasUnclean = false private var didCaptureSessionLaunchState = false private var didArmSessionLaunchSentinel = false + /// History may record user actions once a window exists, even while restore + /// is waiting for external readiness. Actual restoration has its own phase. + private var didRegisterInitialMainWindow = false var didAttemptStartupSessionRestore = false var isApplyingSessionRestore = false { didSet { @@ -3954,7 +3957,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent phase = .terminating } else if isApplyingSessionRestore { phase = .restoring - } else if didAttemptStartupSessionRestore { + } else if didRegisterInitialMainWindow || didAttemptStartupSessionRestore { phase = .active } else { phase = .launching @@ -5725,6 +5728,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) } + didRegisterInitialMainWindow = true attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: window) } From 9c41ffeae6df30a4fedcf63e30ed64a2d87e27ab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 23:04:36 -0700 Subject: [PATCH 34/76] test: select Vault tabs without pinning their preference --- cmuxUITests/RightSidebarChromeHeightUITests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index eaebbd8f905e..792431645651 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -5,6 +5,7 @@ import ImageIO final class RightSidebarChromeHeightUITests: XCTestCase { func testVaultTabsGroupingAndPopoutStayInteractive() throws { + continueAfterFailure = false let app = XCUIApplication.cmuxTestApplication() let dataPath = "/tmp/cmux-ui-test-vault-interaction-\(UUID()).json" defer { try? FileManager.default.removeItem(atPath: dataPath) } @@ -13,7 +14,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_SHOW_RIGHT_SIDEBAR"] = "1" app.launchArguments += [ - "-workspacePresentationMode", "minimal", "-vaultPane.tab", "sessions", + "-workspacePresentationMode", "minimal", "-AppleLanguages", "(en)", "-AppleLocale", "en_US", ] app.launch() @@ -31,6 +32,9 @@ final class RightSidebarChromeHeightUITests: XCTestCase { let history = app.buttons["History"].firstMatch XCTAssertTrue(sessions.waitForExistence(timeout: 10)) XCTAssertTrue(history.waitForExistence(timeout: 10)) + // A launch-argument override would pin this AppStorage value even + // after History is clicked. Establish the starting tab through the UI. + sessions.click() XCTAssertTrue(app.buttons["Folder"].waitForExistence(timeout: 10)) addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") From c97b482bf24a1f08c21fa029baa5faaa4f6906c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 23:41:46 -0700 Subject: [PATCH 35/76] test: query Vault grouping by its macOS accessibility title --- cmuxUITests/RightSidebarChromeHeightUITests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 792431645651..d233a9e44dfb 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -39,10 +39,14 @@ final class RightSidebarChromeHeightUITests: XCTestCase { addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") history.click() + // macOS exposes the Menu's visible text as its AXTitle, not its + // AXDescription (XCUIElement.label). Keep both attributes in the query + // so it follows the visible grouping across supported macOS versions. + let groupings = ["Date", "Workspace", "Window", "Agent", "Type"] let picker = app.menuButtons.matching( - NSPredicate(format: "label IN %@", ["Date", "Workspace", "Window", "Agent", "Type"]) + NSPredicate(format: "title IN %@ OR label IN %@", groupings, groupings) ).firstMatch - XCTAssertTrue(picker.waitForExistence(timeout: 10)) + XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() let item = app.menuItems[grouping].firstMatch @@ -50,7 +54,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { item.click() let selected = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - picker.label.contains(grouping) || picker.staticTexts[grouping].exists + picker.title == grouping || picker.label == grouping }, object: nil ) From fe4ab61c533bb47d70bb7d32318ace4b0f481262 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 23:43:20 -0700 Subject: [PATCH 36/76] test: expose discarded reopen events and retained History reload loss --- .../VaultHistoryEventStoreTests.swift | 36 +++++++++++++++ cmuxTests/VaultHistoryEventLogTests.swift | 44 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift index 52638949b732..50e6540e66cd 100644 --- a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift @@ -85,6 +85,42 @@ import Testing #expect(await store.recentEvents().isEmpty) } + @Test(arguments: [true, false]) + func retainedFileRoundTripsBeyondRequestedLoadBudget(useDefaultPolicy: Bool) async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + let retention = useDefaultPolicy ? VaultHistoryRetentionPolicy.default : VaultHistoryRetentionPolicy( + maxStoredEvents: 20, + maxFileBytes: 8_192, + maxLoadBytes: 4_096 + ) + let titleBytes = useDefaultPolicy ? 750_000 : 1_300 + let store = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + for index in 0..<4 { + #expect(await store.append(event( + id: "e\(index)", + secondsAgo: TimeInterval(10 - index), + title: String(repeating: "x", count: titleBytes) + ))) + } + let original = await store.recentEvents() + #expect(original.count == 4) + let bytes = try Data(contentsOf: fileURL).count + #expect(bytes > (useDefaultPolicy ? 2 * 1_024 * 1_024 : 4_096)) + #expect(bytes <= retention.maxFileBytes) + + let reloaded = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + #expect(await reloaded.recentEvents().map(\.id) == original.map(\.id)) + // An out-of-order append forces compaction; no retained older record + // may disappear just because it was outside the previous load budget. + let late = event(id: "late", secondsAgo: 8.5) + #expect(await reloaded.append(late)) + let expected = (original + [late]).sorted(by: VaultHistoryEvent.newestFirst) + #expect(await reloaded.recentEvents().map(\.id) == expected.map(\.id)) + let afterCompaction = VaultHistoryEventStore(fileURL: fileURL, retention: retention) + #expect(await afterCompaction.recentEvents().map(\.id) == expected.map(\.id)) + } + @Test func retentionCompactsFileAndKeepsNewestEvents() async throws { let fileURL = try makeTempFileURL() defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 07e015e7e9df..030631f26798 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -224,6 +224,50 @@ import Testing } } + @Test(arguments: [true, false]) + func closedWindowReopenRecordsOnlyUsableRestoredWindows(restoreSucceeds: Bool) async throws { + try await withWindowHistory { app, log in + let sourceId = app.createMainWindow(shouldActivate: false) + let manager = try #require(app.tabManagerFor(windowId: sourceId)) + let workspace = try #require(manager.selectedWorkspace) + var workspaceSnapshot = workspace.sessionSnapshot(includeScrollback: false) + if !restoreSucceeds { + var panel = try #require(workspaceSnapshot.panels.first) + // This panel advertises restorable content, but lacks the + // payload needed to recreate it. Restoration returns no map. + panel.type = .markdown + panel.terminal = nil + panel.markdown = nil + workspaceSnapshot.panels = [panel] + workspaceSnapshot.layout = .pane(SessionPaneLayoutSnapshot( + panelIds: [panel.id], selectedPanelId: panel.id + )) + } + let snapshot = SessionWindowSnapshot( + windowId: sourceId, + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: 0, workspaces: [workspaceSnapshot]), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + ) + #expect(snapshot.hasRestorablePanels) + let record = ClosedItemHistoryRecord(entry: .window(ClosedWindowHistoryEntry(snapshot: snapshot))) + ClosedItemHistoryStore.shared.push(record) + defer { _ = ClosedItemHistoryStore.shared.removeRecord(id: record.id) } + #expect(app.closeMainWindow(windowId: sourceId, recordHistory: false)) + await log.flushPendingRecords() + let priorIds = Set(await log.recentEvents().map(\.id)) + + // Exercise the actual reopen/validation/discard path, including + // the fallback workspace created when panel restoration fails. + #expect(app.reopenClosedHistoryItem(id: record.id, shouldActivate: false) == restoreSucceeds) + #expect(app.mainWindowContexts.count == (restoreSucceeds ? 1 : 0)) + await log.flushPendingRecords() + let reopenedEvents = await log.recentEvents().filter { !priorIds.contains($0.id) } + #expect(reopenedEvents.map(\.kind) == (restoreSucceeds ? [.windowOpened] : [])) + } + } + @Test(arguments: ["cmux.cloudVM", "cmux.newWorkspace", "cmux.newAgentChat"]) func configuredWorkspaceActionRecordsOnlyItsRetainedWorkspace(actionId: String) async throws { var createdId: UUID? From f5b3d2cf0a91e7900b43fb99f23d17c0315ebbb3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 7 Sep 2026 23:43:56 -0700 Subject: [PATCH 37/76] fix: commit usable reopened windows and reload the full retained History --- .../CmuxVaultHistory/VaultHistoryRetentionPolicy.swift | 7 ++++--- .../VaultHistoryEventStoreTests.swift | 3 ++- Sources/AppDelegate+ClosedItemHistory.swift | 2 ++ Sources/AppDelegate.swift | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift index efb824b367de..2b8a8387f021 100644 --- a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryRetentionPolicy.swift @@ -7,13 +7,14 @@ public struct VaultHistoryRetentionPolicy: Sendable { /// Maximum persisted JSONL size after an accepted append. public let maxFileBytes: Int /// Maximum number of tail bytes read while loading an existing log. + /// Always covers the complete file budget so accepted records survive reload. public let maxLoadBytes: Int /// Production retention limits for the macOS History timeline. public static let `default` = VaultHistoryRetentionPolicy( maxStoredEvents: 2_000, maxFileBytes: 4 * 1_024 * 1_024, - maxLoadBytes: 2 * 1_024 * 1_024 + maxLoadBytes: 4 * 1_024 * 1_024 ) /// Creates a policy while enforcing nonzero operational minima. @@ -21,10 +22,10 @@ public struct VaultHistoryRetentionPolicy: Sendable { /// - Parameters: /// - maxStoredEvents: Maximum number of retained events. /// - maxFileBytes: Maximum persisted JSONL size after an accepted append. - /// - maxLoadBytes: Tail-read byte budget used during initial load. + /// - maxLoadBytes: Requested tail-read budget, raised to at least the file budget. public init(maxStoredEvents: Int, maxFileBytes: Int, maxLoadBytes: Int) { self.maxStoredEvents = max(1, maxStoredEvents) self.maxFileBytes = max(1_024, maxFileBytes) - self.maxLoadBytes = max(1_024, maxLoadBytes) + self.maxLoadBytes = max(self.maxFileBytes, maxLoadBytes) } } diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift index 50e6540e66cd..6190b63d44ff 100644 --- a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift @@ -227,7 +227,8 @@ import Testing fileURL: fileURL, retention: VaultHistoryRetentionPolicy( maxStoredEvents: 1_000, - maxFileBytes: 64 * 1_024 * 1_024, + // An externally oversized log still uses a bounded tail read. + maxFileBytes: 4_096, maxLoadBytes: 4_096 ) ) diff --git a/Sources/AppDelegate+ClosedItemHistory.swift b/Sources/AppDelegate+ClosedItemHistory.swift index 6eafce861173..fd7d45628ff1 100644 --- a/Sources/AppDelegate+ClosedItemHistory.swift +++ b/Sources/AppDelegate+ClosedItemHistory.swift @@ -106,6 +106,7 @@ extension AppDelegate { let excludedStableIdentities = liveStableIdentitySet() let excludedWorkspaceIds = liveWorkspaceIdSet() let windowId = createMainWindow( + initialWorkspaceHistoryContext: .bootstrap, sessionWindowSnapshot: windowSnapshot, shouldActivate: shouldActivate, remapClosedPanelHistoryFromSessionSnapshot: false, @@ -134,6 +135,7 @@ extension AppDelegate { restoredPanelIdsByWorkspaceIndex: restoredPanelIdsByWorkspaceIndex, ambiguousOriginalWorkspaceIds: excludedWorkspaceIds ) + vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) return true } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2dbd16195c72..69e8b208d9c8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10251,7 +10251,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent defer { if tabManagerFor(windowId: windowId) == nil { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) - } else if initialWorkspaceHistoryContext != .bootstrap || sessionWindowSnapshot != nil { + } else if initialWorkspaceHistoryContext != .bootstrap { vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) } } From c8d1e144f6baf56964aa2927119d937613177fcf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 08:48:45 -0700 Subject: [PATCH 38/76] fix: reject unreadable History stores before rewriting --- .../VaultHistoryEventStore.swift | 65 +++++++++++++++---- .../VaultHistoryEventStoreTests.swift | 14 ++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift index 6f6be657f5cf..95f28096d0b7 100644 --- a/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift +++ b/Packages/macOS/CmuxVaultHistory/Sources/CmuxVaultHistory/VaultHistoryEventStore.swift @@ -2,10 +2,16 @@ public import Foundation /// Actor-isolated, retention-bounded JSONL persistence for recorded History events. public actor VaultHistoryEventStore: VaultHistoryEventStoring { + private enum LoadState { + case notLoaded + case loaded + case failed + } + private let fileURL: URL? private let retention: VaultHistoryRetentionPolicy private let fileManager: FileManager - private var didLoad = false + private var loadState: LoadState = .notLoaded /// Retention and read order, updated incrementally so refreshes never re-sort. private var newestFirstEvents: [VaultHistoryEvent] = [] private var fileBytes = 0 @@ -32,7 +38,12 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { /// - Returns: `true` when storage accepted the mutation. An event older /// than the retained timestamp window can be evicted immediately. public func append(_ event: VaultHistoryEvent) async -> Bool { - loadIfNeeded() + guard loadIfNeeded() else { + // A failed initial read must never be followed by a compacting + // rewrite: doing so could replace an existing log with a partial + // in-memory snapshot. + return false + } guard let line = encodedLine(for: event), line.count <= retention.maxFileBytes else { return false } @@ -67,7 +78,7 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { /// - Parameter limit: Maximum number of events returned. /// - Returns: Events ordered by timestamp and stable identifier, newest first. public func recentEvents(limit: Int = .max) async -> [VaultHistoryEvent] { - loadIfNeeded() + guard loadIfNeeded() else { return [] } guard limit < newestFirstEvents.count else { return newestFirstEvents } @@ -87,23 +98,49 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { return decoder } - private func loadIfNeeded() { - guard !didLoad else { return } - didLoad = true - guard let fileURL, - let handle = try? FileHandle(forReadingFrom: fileURL) else { - return + @discardableResult + private func loadIfNeeded() -> Bool { + switch loadState { + case .loaded: + return true + case .failed: + return false + case .notLoaded: + break + } + + guard let fileURL else { + loadState = .loaded + return true + } + guard fileManager.fileExists(atPath: fileURL.path) else { + loadState = .loaded + return true + } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { + loadState = .failed + return false } defer { try? handle.close() } - let fileSize = (try? handle.seekToEnd()).map(Int.init) ?? 0 + guard let fileSizeOffset = try? handle.seekToEnd() else { + loadState = .failed + return false + } + let fileSize = Int(fileSizeOffset) fileBytes = fileSize - guard fileSize > 0 else { return } + guard fileSize > 0 else { + loadState = .loaded + return true + } let readBytes = min(fileSize, retention.maxLoadBytes) let startOffset = UInt64(fileSize - readBytes) - try? handle.seek(toOffset: startOffset) - guard var data = try? handle.read(upToCount: readBytes) else { return } + guard (try? handle.seek(toOffset: startOffset)) != nil, + var data = try? handle.read(upToCount: readBytes) else { + loadState = .failed + return false + } if startOffset > 0, let firstNewline = data.firstIndex(of: 0x0a) { data = data[data.index(after: firstNewline)...] @@ -129,6 +166,8 @@ public actor VaultHistoryEventStore: VaultHistoryEventStoring { .sorted(by: VaultHistoryEvent.newestFirst) .prefix(retention.maxStoredEvents) ) + loadState = .loaded + return true } private func encodedLine(for event: VaultHistoryEvent) -> Data? { diff --git a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift index 6190b63d44ff..93818129e895 100644 --- a/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift +++ b/Packages/macOS/CmuxVaultHistory/Tests/CmuxVaultHistoryTests/VaultHistoryEventStoreTests.swift @@ -85,6 +85,20 @@ import Testing #expect(await store.recentEvents().isEmpty) } + @Test func failedInitialLoadNeverRewritesAnExistingUnreadablePath() async throws { + let fileURL = try makeTempFileURL() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + try FileManager.default.createDirectory(at: fileURL, withIntermediateDirectories: false) + + let store = VaultHistoryEventStore(fileURL: fileURL) + #expect(await store.recentEvents().isEmpty) + #expect(await store.append(event(id: "must-not-rewrite", secondsAgo: 0)) == false) + + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + } + @Test(arguments: [true, false]) func retainedFileRoundTripsBeyondRequestedLoadBudget(useDefaultPolicy: Bool) async throws { let fileURL = try makeTempFileURL() From b78763d8d21cf1ee60f795ed9cae15ab4e0c2995 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 09:17:04 -0700 Subject: [PATCH 39/76] fix: persist session snapshot on normal quit --- Sources/AppDelegate.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0c2c2bb9a74f..7ae2451e2435 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2152,6 +2152,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let hasPendingHistoryRecords = historyEventLog?.hasPendingRecords == true let hasOwnedRuntimeCleanup = !markedForKill.isEmpty || !simulatorCleanupTasks.isEmpty guard hasPendingHistoryRecords || hasOwnedRuntimeCleanup else { + // Confirmed termination marks the app as terminating before AppKit + // reaches applicationWillTerminate, so its synchronous backstop is + // intentionally skipped. Persist the current scrollback and the + // cached, validated process index here without deferring an + // otherwise unrelated quit. + _ = saveSessionSnapshotIncludingProcessDetectedIndexes( + includeScrollback: true, + removeWhenEmpty: false + ) return false } if !isAwaitingTerminateCleanup { From 062be7cc144316094a73a5a72956133bc80fff42 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 10:19:48 -0700 Subject: [PATCH 40/76] fix: pass explicit tint to Vault symbols --- Sources/VaultHistoryEventRow.swift | 4 ++-- Sources/VaultHistoryGroupHeader.swift | 4 ++-- Sources/VaultHistoryView.swift | 14 +++++++++----- Sources/VaultPaneTabButton.swift | 6 +++++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index ad3dcc8ae0ad..8c04d237db9c 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -71,9 +71,9 @@ struct VaultHistoryEventRow: View, Equatable { CmuxSystemSymbolImage( magnified: event.kind.symbolName, pointSize: 11, - weight: .regular + weight: .regular, + tint: iconColor ) - .foregroundColor(iconColor) } } diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift index 2002b7982b43..1e448aee159e 100644 --- a/Sources/VaultHistoryGroupHeader.swift +++ b/Sources/VaultHistoryGroupHeader.swift @@ -39,9 +39,9 @@ struct VaultHistoryGroupHeader: View, Equatable { CmuxSystemSymbolImage( magnified: key.symbolName, pointSize: 14, - weight: .regular + weight: .regular, + tint: .secondary ) - .foregroundColor(.secondary) Text(title) .cmuxFont(size: 12, weight: .semibold) .foregroundColor(.secondary) diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index e8eacd34a507..4bb12aa42e11 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -87,7 +87,11 @@ private struct VaultHistoryContentView: View { CmuxSystemSymbolImage( magnified: model.groupKey.symbolName, pointSize: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight + weight: RightSidebarChromeControlStyle.iconWeight, + tint: RightSidebarChromeControlStyle.pillForegroundColor( + isSelected: false, + isHovered: isGroupPickerHovered + ) ) Text(model.groupKey.label) .cmuxFont( @@ -166,9 +170,9 @@ private struct VaultHistoryContentView: View { CmuxSystemSymbolImage( magnified: "clock.arrow.circlepath", pointSize: 22, - weight: .regular + weight: .regular, + tint: .secondary.opacity(0.65) ) - .foregroundColor(.secondary.opacity(0.65)) ProgressView().controlSize(.small) Text(String( localized: "vaultHistory.loading", @@ -185,9 +189,9 @@ private struct VaultHistoryContentView: View { CmuxSystemSymbolImage( magnified: "clock.arrow.circlepath", pointSize: 28, - weight: .light + weight: .light, + tint: .secondary.opacity(0.55) ) - .foregroundColor(.secondary.opacity(0.55)) Text(String( localized: "vaultHistory.empty.title", defaultValue: "No history yet" diff --git a/Sources/VaultPaneTabButton.swift b/Sources/VaultPaneTabButton.swift index 2d806accf056..4500cc538081 100644 --- a/Sources/VaultPaneTabButton.swift +++ b/Sources/VaultPaneTabButton.swift @@ -14,7 +14,11 @@ struct VaultPaneTabButton: View { CmuxSystemSymbolImage( magnified: tab.symbolName, pointSize: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight + weight: RightSidebarChromeControlStyle.iconWeight, + tint: RightSidebarChromeControlStyle.pillForegroundColor( + isSelected: isSelected, + isHovered: isHovered + ) ) Text(tab.label) .cmuxFont( From d1a2d011cd38a7abefb77dc978693c327d2e4211 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 11:27:23 -0700 Subject: [PATCH 41/76] fix: clear new Swift warning budget entries --- Sources/Surfaces/CmuxTuiSnapshotParser.swift | 2 -- Sources/Surfaces/SurfaceCatalogModel.swift | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/Surfaces/CmuxTuiSnapshotParser.swift b/Sources/Surfaces/CmuxTuiSnapshotParser.swift index 4ea750fb71b6..2a6dfde94deb 100644 --- a/Sources/Surfaces/CmuxTuiSnapshotParser.swift +++ b/Sources/Surfaces/CmuxTuiSnapshotParser.swift @@ -556,8 +556,6 @@ struct CmuxTuiSnapshotParser: Sendable { resource.remoteViews = views resource.remoteWorkspace = views.first?.workspace resources.append(resource) - default: - continue } } return resources.sorted(by: resourceComesBefore) diff --git a/Sources/Surfaces/SurfaceCatalogModel.swift b/Sources/Surfaces/SurfaceCatalogModel.swift index 1af4374b4fb5..619e30ba314f 100644 --- a/Sources/Surfaces/SurfaceCatalogModel.swift +++ b/Sources/Surfaces/SurfaceCatalogModel.swift @@ -984,8 +984,7 @@ struct CloudVMStateDocument: Hashable, Codable, Sendable { guard uniqueMatches.count <= 1 else { return false } let rowID = uniqueMatches.first let existingObject = rowID.flatMap { collection.object(forRowID: $0) } - if let rowID, - let existingID = existingObject.flatMap({ Self.nonEmptyString($0["id"]) }), + if let existingID = existingObject.flatMap({ Self.nonEmptyString($0["id"]) }), let explicitID, existingID != explicitID { // An explicit identity cannot silently claim a different row found From 2b41a57e237ffeb40948b9971fdc62570c07a945 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 11:56:28 -0700 Subject: [PATCH 42/76] chore: normalize Xcode project ordering --- cmux.xcodeproj/project.pbxproj | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 32be4e8cf4be..0782a6fd13a0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -195,6 +195,7 @@ REE0CA0000000000000000E2 /* AIAccountsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000E1 /* AIAccountsClient.swift */; }; A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A115C0DE0000000000000001 /* AllShortcutsPopover.swift */; }; 9758A0000000000000000002 /* AmpVaultRegistrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758A0000000000000000001 /* AmpVaultRegistrationTests.swift */; }; + A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */; }; F4350A110000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; F4350A120000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; A5C01700000000000000000D /* AppDelegate+AccountSignInWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C01700000000000000000E /* AppDelegate+AccountSignInWorkspace.swift */; }; @@ -1254,6 +1255,7 @@ C0DE31410000000000000103 /* DebugEventLogSerializedAppendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31410000000000000104 /* DebugEventLogSerializedAppendTests.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; 917300000000000000000001 /* DeferredActionReplacementStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917300000000000000000002 /* DeferredActionReplacementStackTests.swift */; }; + A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */; }; D10786DB0000000000000001 /* DeferredBrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10786DB0000000000000002 /* DeferredBrowserPanel.swift */; }; D10786DB0000000000000003 /* DeferredBrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10786DB0000000000000004 /* DeferredBrowserPanelView.swift */; }; F87910140000000000000001 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910140000000000000002 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift */; }; @@ -2014,9 +2016,6 @@ 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */; }; 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */; }; B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */; }; - A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */; }; - A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; - A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */; }; 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */; }; B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */; }; D0C658660000000000000004 /* PortalSplitDividerCacheInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */; }; @@ -2250,6 +2249,7 @@ F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54100B1A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift */; }; C7C5FAF3145342F395DB4C40 /* RestoredAgentCompletedGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */; }; C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */; }; + A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */; }; AA1B2C3D4E5F60720 /* RightSidebarChromeHeightUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */; }; @@ -3683,6 +3683,7 @@ REE0CA0000000000000000E1 /* AIAccountsClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIAccountsClient.swift; sourceTree = ""; }; A115C0DE0000000000000001 /* AllShortcutsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllShortcutsPopover.swift; sourceTree = ""; }; 9758A0000000000000000001 /* AmpVaultRegistrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmpVaultRegistrationTests.swift; sourceTree = ""; }; + A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityHookSessionSnapshotRestoreTests.swift; sourceTree = ""; }; F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/AppBundleIconPersistencePolicy.swift; sourceTree = ""; }; A5C01700000000000000000E /* AppDelegate+AccountSignInWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AccountSignInWorkspace.swift"; sourceTree = ""; }; 80410000000000000000000A /* AppDelegate+AdjacentNavigationShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AdjacentNavigationShortcut.swift"; sourceTree = ""; }; @@ -4641,6 +4642,7 @@ C0DE31410000000000000104 /* DebugEventLogSerializedAppendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugEventLogSerializedAppendTests.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; 917300000000000000000002 /* DeferredActionReplacementStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredActionReplacementStackTests.swift; sourceTree = ""; }; + A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAgentResumeIndexFallbackTests.swift; sourceTree = ""; }; D10786DB0000000000000002 /* DeferredBrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DeferredBrowserPanel.swift; sourceTree = ""; }; D10786DB0000000000000004 /* DeferredBrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DeferredBrowserPanelView.swift; sourceTree = ""; }; F87910140000000000000002 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift; sourceTree = ""; }; @@ -5392,9 +5394,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedOwnershipTests.swift; sourceTree = ""; }; 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedV2CallResultBox.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; - A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityHookSessionSnapshotRestoreTests.swift; sourceTree = ""; }; - A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; - A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAgentResumeIndexFallbackTests.swift; sourceTree = ""; }; 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalDividerCursorOcclusionTests.swift; sourceTree = ""; }; 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalHitTestingPerformanceTests.swift; sourceTree = ""; }; D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalSplitDividerCacheInvalidator.swift; sourceTree = ""; }; @@ -5624,6 +5623,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F54100B1A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableCodexForkTagTests.swift; sourceTree = ""; }; 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentCompletedGeneration.swift; sourceTree = ""; }; 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = ""; }; + A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeGeometryReporting.swift; sourceTree = ""; }; AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeHeightUITests.swift; sourceTree = ""; }; @@ -13376,6 +13376,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */, A9E050000000000000000002 /* AgentSessionWebRendererTests.swift in Sources */, 9758A0000000000000000002 /* AmpVaultRegistrationTests.swift in Sources */, + A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */, 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */, A1B2C3D4E5F600000000CF01 /* AppDelegateDisplayConfigRestoreTests.swift in Sources */, E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */, @@ -13627,6 +13628,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, C0DE31410000000000000103 /* DebugEventLogSerializedAppendTests.swift in Sources */, 917300000000000000000001 /* DeferredActionReplacementStackTests.swift in Sources */, + A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */, C0DE75890000000000000003 /* DeflatedAssetTestSupport.swift in Sources */, B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */, DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */, @@ -13823,9 +13825,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */, 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */, B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */, - A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */, - A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, - A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */, 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */, B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */, D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, @@ -13908,6 +13907,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F5410006A1B2C3D4E5F60718 /* RestorableAgentSessionIndexTests.swift in Sources */, F54100A0A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift in Sources */, F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, + A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, C57570010000000000000002 /* RightSidebarPanelViewTestSupport.swift in Sources */, From 4ee9c7e2523073f693d78ca6ac87678d88a882f2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 12:21:10 -0700 Subject: [PATCH 43/76] test: target Vault history picker by accessibility id --- cmuxUITests/RightSidebarChromeHeightUITests.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 3dc472a7d564..145704343b77 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -43,9 +43,19 @@ final class RightSidebarChromeHeightUITests: XCTestCase { // AXDescription (XCUIElement.label). Keep both attributes in the query // so it follows the visible grouping across supported macOS versions. let groupings = ["Date", "Workspace", "Window", "Agent", "Type"] - let picker = app.menuButtons.matching( + // SwiftUI exposes Menu with different XCUI roles across supported + // macOS/Xcode combinations. Prefer the stable identifier from the + // production view, then retain the title/label fallback for older + // accessibility bridges. + let identifiedPicker = app.descendants(matching: .any)[ + "VaultHistoryGroupPicker" + ].firstMatch + let titledPicker = app.menuButtons.matching( NSPredicate(format: "title IN %@ OR label IN %@", groupings, groupings) ).firstMatch + let picker = identifiedPicker.waitForExistence(timeout: 3) + ? identifiedPicker + : titledPicker XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() From 614685712db8f67da94e6848700fcd9d583659f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 13:09:53 -0700 Subject: [PATCH 44/76] fix: preserve pending Vault history action outcomes --- Sources/AppDelegate+AgentChat.swift | 25 ++- ...AppDelegate+SimulatorShortcutRouting.swift | 5 +- Sources/AppDelegate.swift | 157 ++++++++++++++---- Sources/CmuxConfigExecutor.swift | 63 +++++-- Sources/VaultHistoryEventLog.swift | 2 +- cmuxTests/VaultHistoryEventLogTests.swift | 52 +++++- 6 files changed, 249 insertions(+), 55 deletions(-) diff --git a/Sources/AppDelegate+AgentChat.swift b/Sources/AppDelegate+AgentChat.swift index 6de9213ebdf9..094abc7acac5 100644 --- a/Sources/AppDelegate+AgentChat.swift +++ b/Sources/AppDelegate+AgentChat.swift @@ -78,7 +78,8 @@ extension AppDelegate { func performConfiguredNewAgentChatAction( context: MainWindowContext, preferredWindow: NSWindow?, - onExecuted: (() -> Void)? + onExecuted: (() -> Void)?, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { let cmuxConfigStore = context.cmuxConfigStore return performNewAgentChatAction( @@ -86,7 +87,8 @@ extension AppDelegate { agentChat: cmuxConfigStore?.agentChat ?? .default, globalConfigPath: cmuxConfigStore?.globalConfigPath, preferredWindow: resolvedWindow(for: context) ?? preferredWindow, - onExecuted: onExecuted + onExecuted: onExecuted, + onCompleted: onCompleted ) } @@ -113,37 +115,48 @@ extension AppDelegate { agentChat: CmuxAgentChatConfiguration, globalConfigPath: String?, preferredWindow: NSWindow?, - onExecuted: (() -> Void)? = nil + onExecuted: (() -> Void)? = nil, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { guard CmuxFeatureFlags.shared.isAgentChatUIEnabled else { NSSound.beep() + onCompleted?(false) return false } guard BrowserAvailabilitySettings.isEnabled() else { NSSound.beep() + onCompleted?(false) return false } AgentChatThemeSync.start() guard AgentChatActionInFlightGate.begin() else { NSSound.beep() + onCompleted?(false) return false } Task { @MainActor [weak self, weak tabManager] in defer { AgentChatActionInFlightGate.end() } - guard let self else { return } + guard let self else { + onCompleted?(false) + return + } let availability = await self.ensureAgentChatServerAvailable( agentChat, globalConfigPath: globalConfigPath, preferredWindow: preferredWindow ) AgentChatThemeSync.syncNow(agentChat: agentChat) - guard let tabManager else { return } + guard let tabManager else { + onCompleted?(false) + return + } guard let browserURL = availability.browserURL else { NSSound.beep() self.postAgentChatServerUnavailableNotification( workspace: nil, agentChat: agentChat ) + onCompleted?(false) return } guard let workspace = self.openAgentChatWorkspace( @@ -151,6 +164,7 @@ extension AppDelegate { url: browserURL ) else { NSSound.beep() + onCompleted?(false) return } if !availability.isReachable { @@ -160,6 +174,7 @@ extension AppDelegate { ) } onExecuted?() + onCompleted?(true) } return true } diff --git a/Sources/AppDelegate+SimulatorShortcutRouting.swift b/Sources/AppDelegate+SimulatorShortcutRouting.swift index cba865ba6ce1..1da052717c11 100644 --- a/Sources/AppDelegate+SimulatorShortcutRouting.swift +++ b/Sources/AppDelegate+SimulatorShortcutRouting.swift @@ -33,15 +33,18 @@ extension AppDelegate { func performConfiguredNewSimulatorAction( context: MainWindowContext, - onExecuted: (() -> Void)? + onExecuted: (() -> Void)?, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { guard CmuxFeatureFlags.shared.isSimulatorEnabled, let workspace = context.tabManager.selectedWorkspace, let pane = workspace.bonsplitController.focusedPaneId, workspace.newSimulatorSurface(inPane: pane, focus: true) != nil else { + onCompleted?(false) return false } onExecuted?() + onCompleted?(true) return true } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c1b50586b5ec..0dc791764ab3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -548,7 +548,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent MainWindowContext, NSWindow?, (() -> Void)?, - ((CloudVMActionLauncher.Completion) -> Void)? + ((CloudVMActionLauncher.Completion) -> Void)?, + ((Bool) -> Void)? ) -> Bool typealias StartupSessionRestoreDeferral = @MainActor ( _ resume: @escaping @MainActor @Sendable () -> Void @@ -6779,10 +6780,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func discardMainWindowWithoutClosedHistory(windowId: UUID) { - defer { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) } - guard let window = mainWindowForClose(windowId: windowId) else { return } + guard let window = mainWindowForClose(windowId: windowId) else { + vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) + return + } closedWindowHistorySuppressedWindowIds.insert(windowId) - if !closeMainWindowWithoutInteractiveVeto(window) { + if closeMainWindowWithoutInteractiveVeto(window) { + vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) + } else { closedWindowHistorySuppressedWindowIds.remove(windowId) } } @@ -8648,12 +8653,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #endif let windowId = createMainWindow(initialWorkspaceHistoryContext: .bootstrap) - var retainedWindow = false - defer { + var didFinalizeWindowHistory = false + let finalizeWindowHistory: (Bool) -> Void = { [weak self] retainedWindow in + guard let self, !didFinalizeWindowHistory else { return } + didFinalizeWindowHistory = true if retainedWindow { - vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) + self.vaultHistoryEventLog?.commitWindowCreation(windowId: windowId) } else { - discardMainWindowWithoutClosedHistory(windowId: windowId) + self.discardMainWindowWithoutClosedHistory(windowId: windowId) } } if let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { @@ -8663,10 +8670,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let didExecute = executeConfiguredNewWorkspaceActionIfAvailable( in: context, debugSource: debugSource, - replacingInitialWorkspace: initialWorkspace + replacingInitialWorkspace: initialWorkspace, + onCompleted: { [weak context] succeeded in + guard let context else { return } + if !succeeded, + let initialWorkspace = context.tabManager.tabs.first(where: { $0.id == initialWorkspace?.id }) { + context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) + } + finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) + } ) - if !didExecute, let initialWorkspace { + if !didExecute, !didFinalizeWindowHistory, let initialWorkspace { context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) + finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) + } else if !didExecute, !didFinalizeWindowHistory { + finalizeWindowHistory(false) } case .browser: // The fresh window boots with a terminal workspace; add the @@ -8679,7 +8697,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle - ) else { return false } + ) else { + finalizeWindowHistory(false) + return false + } closeInitialWorkspaceIfNeeded( initialWorkspaceId: initialWorkspace?.id, in: context @@ -8690,6 +8711,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } case .cloudVMLoading: guard let workspace = context.tabManager.addWorkspaceIfActive(initialSurface: .cloudVMLoading) else { + finalizeWindowHistory(false) return false } closeInitialWorkspaceIfNeeded( @@ -8698,9 +8720,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) context.tabManager.setPinned(workspace, pinned: true) } - retainedWindow = !context.tabManager.isFinalizedForWindowClose + finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) + } else { + finalizeWindowHistory(false) } - return retainedWindow + return mainWindowContexts.values.contains { $0.windowId == windowId } } let context = livePreferredContext @@ -9297,7 +9321,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent in context: MainWindowContext, debugSource: String, replacingInitialWorkspace initialWorkspace: Workspace? = nil, - workspaceGroupTarget: WorkspaceGroupNewWorkspaceTarget? = nil + workspaceGroupTarget: WorkspaceGroupNewWorkspaceTarget? = nil, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { guard let cmuxConfigStore = context.cmuxConfigStore, let action = cmuxConfigStore.resolvedNewWorkspaceAction() else { @@ -9328,16 +9353,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var didFinishInitialWorkspace = false // Inspect the action's actual result instead of maintaining a second list // of workspace-producing actions. Cloud VM may complete after onExecuted. - let finishInitialWorkspace: (Bool) -> Void = { [weak self, weak context] mayRetainInitial in + let finishInitialWorkspace: (Bool) -> Void = { [weak self, weak context] shouldReplaceInitial in guard !didFinishInitialWorkspace, let self, let context, let initialWorkspaceId, !context.tabManager.isFinalizedForWindowClose, let initial = context.tabManager.tabs.first(where: { $0.id == initialWorkspaceId }) else { return } - guard mayRetainInitial || context.tabManager.tabs.count > 1 else { return } - self.closeInitialWorkspaceIfNeeded(initialWorkspaceId: initialWorkspaceId, in: context) - if context.tabManager.tabs.contains(where: { $0.id == initialWorkspaceId }) { + if shouldReplaceInitial { + self.closeInitialWorkspaceIfNeeded(initialWorkspaceId: initialWorkspaceId, in: context) + } + if !shouldReplaceInitial || context.tabManager.tabs.contains(where: { $0.id == initialWorkspaceId }) { context.tabManager.recordVaultHistoryWorkspaceCreated(initial) } didFinishInitialWorkspace = true @@ -9385,7 +9411,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context: context, preferredWindow: window, onExecuted: onExecuted, - onCloudVMCompletion: onCloudVMCompletion + onCloudVMCompletion: onCloudVMCompletion, + onCompleted: onCompleted ) } @@ -17631,27 +17658,72 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context: MainWindowContext, preferredWindow: NSWindow? = nil, onExecuted: (() -> Void)? = nil, - onCloudVMCompletion: ((CloudVMActionLauncher.Completion) -> Void)? = nil + onCloudVMCompletion: ((CloudVMActionLauncher.Completion) -> Void)? = nil, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { if let configuredActionExecutor { - return configuredActionExecutor(action, context, preferredWindow, onExecuted, onCloudVMCompletion) + var didComplete = false + let complete: (Bool) -> Void = { succeeded in + guard !didComplete else { return } + didComplete = true + onCompleted?(succeeded) + } + let didRun = configuredActionExecutor( + action, + context, + preferredWindow, + { + onExecuted?() + if action.action != .builtIn(.cloudVM) { + complete(true) + } + }, + { completion in + onCloudVMCompletion?(completion) + if action.action == .builtIn(.cloudVM) { + complete(completion.succeeded) + } + }, + complete + ) + if !didRun { + complete(false) + } + return didRun } switch action.action { case .builtIn(let builtIn): switch builtIn { case .newWorkspace: - guard context.tabManager.addWorkspaceIfActive() != nil else { return false } + guard context.tabManager.addWorkspaceIfActive() != nil else { + onCompleted?(false) + return false + } onExecuted?() + onCompleted?(true) return true - case .newAgentChat: return performConfiguredNewAgentChatAction(context: context, preferredWindow: preferredWindow, onExecuted: onExecuted) + case .newAgentChat: + return performConfiguredNewAgentChatAction( + context: context, + preferredWindow: preferredWindow, + onExecuted: onExecuted, + onCompleted: onCompleted + ) case .cloudVM: let didStart = performCloudVMAction( tabManager: context.tabManager, preferredWindow: resolvedWindow(for: context) ?? preferredWindow, debugSource: "configured.cmux.cloudvm", - onCompletion: onCloudVMCompletion + onCompletion: { completion in + onCloudVMCompletion?(completion) + onCompleted?(completion.succeeded) + } ) - if didStart { onExecuted?() } + if didStart { + onExecuted?() + } else { + onCompleted?(false) + } return didStart case .mobileConnect: let workspace = performMobileConnectWorkspaceAction( @@ -17659,21 +17731,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preferredWindow: resolvedWindow(for: context), debugSource: "configured.cmux.mobileConnect" ) - if workspace != nil { onExecuted?() } + if workspace != nil { + onExecuted?() + onCompleted?(true) + } else { + onCompleted?(false) + } return workspace != nil - case .newSimulator: return performConfiguredNewSimulatorAction(context: context, onExecuted: onExecuted) + case .newSimulator: + return performConfiguredNewSimulatorAction( + context: context, + onExecuted: onExecuted, + onCompleted: onCompleted + ) case .newTerminal: context.tabManager.newSurface() onExecuted?() + onCompleted?(true) return true case .newBrowser: let previousTabManager = tabManager tabManager = context.tabManager defer { tabManager = previousTabManager } guard openBrowserAndFocusAddressBar(insertAtEnd: true) != nil else { + onCompleted?(false) return false } onExecuted?() + onCompleted?(true) return true case .splitRight: if shouldSuppressSplitShortcutForTransientTerminalFocusState( @@ -17686,7 +17771,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: .right, preferredWindow: preferredWindow ?? shortcutRoutingActiveWindow ) - if didSplit { onExecuted?() } + if didSplit { + onExecuted?() + onCompleted?(true) + } else { + onCompleted?(false) + } return didSplit case .splitDown: if shouldSuppressSplitShortcutForTransientTerminalFocusState( @@ -17699,7 +17789,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: .down, preferredWindow: preferredWindow ?? shortcutRoutingActiveWindow ) - if didSplit { onExecuted?() } + if didSplit { + onExecuted?() + onCompleted?(true) + } else { + onCompleted?(false) + } return didSplit } case .command, .agent, .workspaceCommand, .workspace: @@ -17717,9 +17812,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent baseCwd: baseCwd, globalConfigPath: cmuxConfigStore.globalConfigPath, presentingWindow: preferredWindow, - onExecuted: onExecuted + onExecuted: onExecuted, + onCompleted: onCompleted ) case .actionReference: + onCompleted?(false) return false } } diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index d6ce5c973000..fc17bdcd2775 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -16,8 +16,15 @@ struct CmuxConfigExecutor { icon: CmuxButtonIcon? = nil, iconSourcePath: String? = nil, presentingWindow: NSWindow? = nil, - onExecuted: (() -> Void)? = nil + onExecuted: (() -> Void)? = nil, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { + var didComplete = false + let complete: (Bool) -> Void = { succeeded in + guard !didComplete else { return } + didComplete = true + onCompleted?(succeeded) + } if let workspace = command.workspace { return authorizeProjectActionIfNeeded( descriptor: workspaceTrustDescriptor( @@ -40,12 +47,21 @@ struct CmuxConfigExecutor { workspace: workspace, tabManager: tabManager, baseCwd: baseCwd - ) else { return } + ) else { + complete(false) + return + } onExecuted?() + complete(true) + } onDenied: { + complete(false) } } else if let rawCommand = command.command { let targetTerminal = tabManager.selectedWorkspace?.focusedTerminalInputTarget()?.panel - guard let targetTerminal else { return false } + guard let targetTerminal else { + complete(false) + return false + } return prepareShellInputIfAuthorized( rawCommand, confirm: command.confirm ?? false, @@ -56,12 +72,15 @@ struct CmuxConfigExecutor { displayTitle: displayTitle ?? command.name, icon: icon, iconSourcePath: iconSourcePath, - presentingWindow: presentingWindow + presentingWindow: presentingWindow, + onDenied: { complete(false) } ) { shellInput in targetTerminal.sendInput(shellInput) onExecuted?() + complete(true) } } + complete(false) return false } @@ -74,7 +93,8 @@ struct CmuxConfigExecutor { baseCwd: String, globalConfigPath: String, presentingWindow: NSWindow? = nil, - onExecuted: (() -> Void)? = nil + onExecuted: (() -> Void)? = nil, + onCompleted: ((Bool) -> Void)? = nil ) -> Bool { if let syntheticCommand = action.inlineWorkspaceSyntheticCommand { // Inline `type: "workspace"` actions reuse the named-command path via a @@ -91,7 +111,8 @@ struct CmuxConfigExecutor { icon: action.icon, iconSourcePath: action.iconSourcePath, presentingWindow: presentingWindow, - onExecuted: onExecuted + onExecuted: onExecuted, + onCompleted: onCompleted ) } @@ -109,7 +130,8 @@ struct CmuxConfigExecutor { icon: action.icon, iconSourcePath: action.iconSourcePath, presentingWindow: presentingWindow, - onExecuted: onExecuted + onExecuted: onExecuted, + onCompleted: onCompleted ) } @@ -119,7 +141,13 @@ struct CmuxConfigExecutor { ? tabManager.selectedWorkspace?.focusedTerminalInputTarget()?.panel : nil let targetWorkspace = (target == .newTabInCurrentPane) ? tabManager.selectedWorkspace : nil - return prepareShellInputIfAuthorized( + var didComplete = false + let complete: (Bool) -> Void = { succeeded in + guard !didComplete else { return } + didComplete = true + onCompleted?(succeeded) + } + let didStart = prepareShellInputIfAuthorized( command, confirm: action.confirm ?? false, actionID: action.id, @@ -129,7 +157,8 @@ struct CmuxConfigExecutor { displayTitle: action.title, icon: action.icon, iconSourcePath: action.iconSourcePath, - presentingWindow: presentingWindow + presentingWindow: presentingWindow, + onDenied: { complete(false) } ) { shellInput in switch target { case .currentTerminal: @@ -139,7 +168,12 @@ struct CmuxConfigExecutor { targetWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: shellInput) } onExecuted?() + complete(true) + } + if !didStart { + complete(false) } + return didStart } @discardableResult @@ -154,6 +188,7 @@ struct CmuxConfigExecutor { icon: CmuxButtonIcon? = nil, iconSourcePath: String? = nil, presentingWindow: NSWindow? = nil, + onDenied: (() -> Void)? = nil, onAuthorized: @escaping (String) -> Void ) -> Bool { let shellCommand = sanitizeForDisplay(rawCommand) @@ -175,10 +210,12 @@ struct CmuxConfigExecutor { globalConfigPath: globalConfigPath, displayCommand: shellCommand, displayTitle: displayTitle, - presentingWindow: presentingWindow - ) { - onAuthorized(shellCommand + "\n") - } + presentingWindow: presentingWindow, + onAuthorized: { + onAuthorized(shellCommand + "\n") + }, + onDenied: onDenied + ) } @discardableResult diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index f788720d6f93..b1a0fdf13930 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -38,11 +38,11 @@ final class VaultHistoryEventLog { } func record(_ event: VaultHistoryEvent) { - guard phase == .active else { return } if let windowId = event.subject.windowId, pendingWindowEvents[windowId] != nil { pendingWindowEvents[windowId, default: []].append(event) return } + guard phase == .active else { return } let store = store let previous = pendingRecordTask pendingRecordCount += 1 diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 030631f26798..830998659125 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -29,6 +29,22 @@ import Testing #expect(await log.recentEvents().map(\.id) == ["active"]) } + @Test func pendingWindowEventsStageDuringLaunchUntilCommitted() async { + let store = VaultHistoryEventStore(fileURL: nil) + let log = VaultHistoryEventLog(store: store, phase: .launching) + let windowId = UUID() + + log.beginWindowCreation(windowId: windowId) + log.record(event(id: "launch", windowId: windowId)) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + + log.transition(to: .active) + log.commitWindowCreation(windowId: windowId) + await log.flushPendingRecords() + #expect(await log.recentEvents().map(\.id) == ["launch"]) + } + @Test func revisionChangesOnlyAfterAcceptedPersistence() async { let acceptedStore = VaultHistoryEventStore(fileURL: nil) let acceptedLog = VaultHistoryEventLog(store: acceptedStore, phase: .active) @@ -273,7 +289,7 @@ import Testing var createdId: UUID? try await withWindowHistory( configuredActionId: actionId, - configuredActionExecutor: { _, context, _, onExecuted, _ in + configuredActionExecutor: { _, context, _, onExecuted, _, _ in guard let workspace = context.tabManager.addWorkspaceIfActive( initialSurface: .cloudVMLoading, select: true, @@ -301,7 +317,7 @@ import Testing var complete: ((CloudVMActionLauncher.Completion) -> Void)? try await withWindowHistory( configuredActionId: "cmux.cloudVM", - configuredActionExecutor: { _, _, _, onExecuted, onCompletion in + configuredActionExecutor: { _, _, _, onExecuted, onCompletion, _ in complete = onCompletion onExecuted?() return true @@ -331,7 +347,7 @@ import Testing @Test func configuredInWorkspaceActionRecordsItsRetainedInitialWorkspace() async throws { try await withWindowHistory( configuredActionId: "cmux.newTerminal", - configuredActionExecutor: { _, _, _, onExecuted, _ in + configuredActionExecutor: { _, _, _, onExecuted, _, _ in onExecuted?() return true } @@ -345,6 +361,31 @@ import Testing } } + @Test func cancelledConfiguredActionRecordsTheWorkspaceThatRemains() async throws { + var complete: ((Bool) -> Void)? + try await withWindowHistory( + configuredActionId: "cmux.newWorkspace", + configuredActionExecutor: { _, _, _, _, _, onCompleted in + complete = onCompleted + return true + } + ) { app, log in + #expect(app.performNewWorkspaceAction()) + let context = try #require(app.mainWindowContexts.values.first) + let initialWorkspace = try #require(context.tabManager.selectedWorkspace) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + + let completion = try #require(complete) + completion(false) + await log.flushPendingRecords() + + let events = await log.recentEvents() + #expect(events.filter { $0.kind == .windowOpened }.count == 1) + #expect(events.filter { $0.kind == .workspaceCreated }.map(\.subject.workspaceId) == [initialWorkspace.id]) + } + } + @Test func browserWorkspaceWithNoWindowsRecordsOnlyTheBrowserWorkspace() async throws { try await withWindowHistory { app, log in #expect(app.performNewBrowserWorkspaceAction()) @@ -450,12 +491,13 @@ import Testing } } - private func event(id: String) -> VaultHistoryEvent { + private func event(id: String, windowId: UUID? = nil) -> VaultHistoryEvent { VaultHistoryEvent( id: id, timestamp: Date(timeIntervalSince1970: 1_800_000_000), kind: .workspaceCreated, - title: id + title: id, + subject: VaultHistorySubject(windowId: windowId) ) } } From a42f58f24ca270cf871ad92682bc9477d4d8327f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 13:36:40 -0700 Subject: [PATCH 45/76] fix: discard startup restore history transaction --- Sources/AppDelegate.swift | 14 +++++++++++++ cmuxTests/VaultHistoryEventLogTests.swift | 24 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0dc791764ab3..adacb776f5d1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1163,6 +1163,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Reset to `.zero` so the first window seeds the point from its own position. private var lastCascadePoint = NSPoint.zero private(set) var startupSessionSnapshot: AppSessionSnapshot? +#if DEBUG + func setStartupSessionSnapshotForTesting(_ snapshot: AppSessionSnapshot?) { + startupSessionSnapshot = snapshot + } +#endif private var didPrepareStartupSessionSnapshot = false /// Classification of the process that preceded this launch. Captured before /// the current run arms its sentinel so a crash can recover a missing primary @@ -3991,9 +3996,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: NSWindow) { + let primaryWindowId = contextForMainTerminalWindow(primaryWindow)?.windowId let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded( primaryWindow: primaryWindow ) + if didApplyStartupSessionRestore, let primaryWindowId { + // The initial window is constructed before startup restore begins, + // so its staged open/workspace events must be discarded once the + // persisted snapshot is applied. Restore completion can make the + // log active again before createMainWindow's deferred transaction + // finalizer runs. + vaultHistoryEventLog?.discardWindowCreation(windowId: primaryWindowId) + } synchronizeVaultHistoryRecordingPhase() if !didApplyStartupSessionRestore, didAttemptStartupSessionRestore { // No snapshot restore ran (fresh start / restore disabled): diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 830998659125..779c67e67d5f 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -45,6 +45,30 @@ import Testing #expect(await log.recentEvents().map(\.id) == ["launch"]) } + @Test func startupRestoreDiscardsInitialWindowHistoryAfterRestoreCompletes() async throws { + try await withWindowHistory(initialPhase: .launching) { app, log in + let restoredWindowSnapshot = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + ) + app.setStartupSessionSnapshotForTesting(AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: [restoredWindowSnapshot] + )) + + _ = app.createMainWindow(shouldActivate: false) + await log.flushPendingRecords() + + #expect(await log.recentEvents().isEmpty) + } + } + @Test func revisionChangesOnlyAfterAcceptedPersistence() async { let acceptedStore = VaultHistoryEventStore(fileURL: nil) let acceptedLog = VaultHistoryEventLog(store: acceptedStore, phase: .active) From b5a5cfa018683f823f5d1daa1f1f35633e4c6708 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 14:21:25 -0700 Subject: [PATCH 46/76] fix: keep deferred startup history interactive --- Sources/AppDelegate.swift | 31 +++++++++++++++++++++++++++++- Sources/VaultHistoryEventLog.swift | 12 +++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index adacb776f5d1..84a0e2749110 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1178,6 +1178,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// History may record user actions once a window exists, even while restore /// is waiting for external readiness. Actual restoration has its own phase. private var didRegisterInitialMainWindow = false + /// Startup restore can be deferred while the initial window remains + /// interactive. The initial bootstrap events are filtered and committed + /// immediately, while this sentinel prevents later windows from trying to + /// re-register the same readiness callback. + private var startupSessionRestoreIsDeferred = false + private var startupRestoreDeferredWindowId: UUID? var didAttemptStartupSessionRestore = false var isApplyingSessionRestore = false { didSet { @@ -4000,7 +4006,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded( primaryWindow: primaryWindow ) - if didApplyStartupSessionRestore, let primaryWindowId { + if startupSessionRestoreIsDeferred, + startupRestoreDeferredWindowId == nil, + let primaryWindowId { + startupRestoreDeferredWindowId = primaryWindowId + let bootstrapWorkspaceId = contextForMainTerminalWindow(primaryWindow)? + .tabManager.tabs.first?.id + vaultHistoryEventLog?.commitWindowCreation( + windowId: primaryWindowId, + excludingBootstrapWorkspaceId: bootstrapWorkspaceId + ) + } else if didApplyStartupSessionRestore, let primaryWindowId { // The initial window is constructed before startup restore begins, // so its staged open/workspace events must be discarded once the // persisted snapshot is applied. Restore completion can make the @@ -4029,10 +4045,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) -> Bool { guard !didAttemptStartupSessionRestore else { return false } + guard !startupSessionRestoreIsDeferred else { return false } if startupSessionRestoreDeferral({ [weak self, weak primaryWindow] in guard let self, let primaryWindow else { return } + self.startupSessionRestoreIsDeferred = false + self.startupRestoreDeferredWindowId = nil self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: primaryWindow) }) { + startupSessionRestoreIsDeferred = true return false } didAttemptStartupSessionRestore = true @@ -6790,17 +6810,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !didClose, !recordHistory { closedWindowHistorySuppressedWindowIds.remove(windowId) } + if didClose, startupRestoreDeferredWindowId == windowId { + startupRestoreDeferredWindowId = nil + } return didClose } func discardMainWindowWithoutClosedHistory(windowId: UUID) { guard let window = mainWindowForClose(windowId: windowId) else { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) + if startupRestoreDeferredWindowId == windowId { + startupRestoreDeferredWindowId = nil + } return } closedWindowHistorySuppressedWindowIds.insert(windowId) if closeMainWindowWithoutInteractiveVeto(window) { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) + if startupRestoreDeferredWindowId == windowId { + startupRestoreDeferredWindowId = nil + } } else { closedWindowHistorySuppressedWindowIds.remove(windowId) } diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index b1a0fdf13930..e512f72b5b73 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -70,9 +70,19 @@ final class VaultHistoryEventLog { } /// Publishes the original event snapshots exactly once after the window is retained. - func commitWindowCreation(windowId: UUID) { + /// + /// A deferred startup restore may need to suppress only the initial + /// bootstrap open/create pair while retaining user actions that happened + /// before the readiness callback completed. + func commitWindowCreation(windowId: UUID, excludingBootstrapWorkspaceId: UUID? = nil) { guard let events = pendingWindowEvents.removeValue(forKey: windowId) else { return } for event in events { + if let excludingBootstrapWorkspaceId, + (event.kind == .windowOpened + || (event.kind == .workspaceCreated + && event.subject.workspaceId == excludingBootstrapWorkspaceId)) { + continue + } record(event) } } From 85c0423f046b4eb89755c347dd34e769694556c5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 16:19:54 -0700 Subject: [PATCH 47/76] fix: retry startup restore after window close --- Sources/AppDelegate.swift | 25 ++++++++----- cmuxTests/VaultHistoryEventLogTests.swift | 44 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 84a0e2749110..72f23fa640cb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4001,6 +4001,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent vaultHistoryEventLog?.transition(to: phase) } + private func clearDeferredStartupRestoreIfNeeded(windowId: UUID) { + guard startupRestoreDeferredWindowId == windowId else { return } + startupRestoreDeferredWindowId = nil + startupSessionRestoreIsDeferred = false + } + private func attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: NSWindow) { let primaryWindowId = contextForMainTerminalWindow(primaryWindow)?.windowId let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded( @@ -4047,7 +4053,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !didAttemptStartupSessionRestore else { return false } guard !startupSessionRestoreIsDeferred else { return false } if startupSessionRestoreDeferral({ [weak self, weak primaryWindow] in - guard let self, let primaryWindow else { return } + guard let self else { return } + guard let primaryWindow else { + self.startupSessionRestoreIsDeferred = false + self.startupRestoreDeferredWindowId = nil + return + } self.startupSessionRestoreIsDeferred = false self.startupRestoreDeferredWindowId = nil self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: primaryWindow) @@ -6810,8 +6821,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !didClose, !recordHistory { closedWindowHistorySuppressedWindowIds.remove(windowId) } - if didClose, startupRestoreDeferredWindowId == windowId { - startupRestoreDeferredWindowId = nil + if didClose { + clearDeferredStartupRestoreIfNeeded(windowId: windowId) } return didClose } @@ -6819,17 +6830,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func discardMainWindowWithoutClosedHistory(windowId: UUID) { guard let window = mainWindowForClose(windowId: windowId) else { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) - if startupRestoreDeferredWindowId == windowId { - startupRestoreDeferredWindowId = nil - } + clearDeferredStartupRestoreIfNeeded(windowId: windowId) return } closedWindowHistorySuppressedWindowIds.insert(windowId) if closeMainWindowWithoutInteractiveVeto(window) { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) - if startupRestoreDeferredWindowId == windowId { - startupRestoreDeferredWindowId = nil - } + clearDeferredStartupRestoreIfNeeded(windowId: windowId) } else { closedWindowHistorySuppressedWindowIds.remove(windowId) } diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 779c67e67d5f..42a741bf3dde 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -69,6 +69,50 @@ import Testing } } + @Test func startupRestoreRetriesAfterDeferredWindowCloses() async throws { + var signingSecretReady = false + var resumeCallbacks: [@MainActor @Sendable () -> Void] = [] + try await withWindowHistory( + initialPhase: .launching, + startupSessionRestoreDeferral: { resume in + guard !signingSecretReady else { return false } + resumeCallbacks.append(resume) + return true + } + ) { app, log in + let restoredWindowSnapshot = SessionWindowSnapshot( + frame: nil, + display: nil, + tabManager: SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + ) + app.setStartupSessionSnapshotForTesting(AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: [restoredWindowSnapshot] + )) + + let firstWindowId = app.createMainWindow(shouldActivate: false) + #expect(resumeCallbacks.count == 1) + #expect(app.closeMainWindow(windowId: firstWindowId, recordHistory: false)) + + _ = app.createMainWindow(shouldActivate: false) + #expect(resumeCallbacks.count == 2) + #expect(!app.didAttemptStartupSessionRestore) + + signingSecretReady = true + resumeCallbacks[0]() + #expect(!app.didAttemptStartupSessionRestore) + resumeCallbacks[1]() + #expect(app.didAttemptStartupSessionRestore) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + } + } + @Test func revisionChangesOnlyAfterAcceptedPersistence() async { let acceptedStore = VaultHistoryEventStore(fileURL: nil) let acceptedLog = VaultHistoryEventLog(store: acceptedStore, phase: .active) From 1fe7f54435c3f2da3ef9cf7342cb43f44adcf76a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 16:33:24 -0700 Subject: [PATCH 48/76] test: cover deferred restore ownership and action completion --- cmuxTests/VaultHistoryEventLogTests.swift | 81 ++++++++++++++++++++--- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 42a741bf3dde..b68b93727c7d 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -69,7 +69,11 @@ import Testing } } - @Test func startupRestoreRetriesAfterDeferredWindowCloses() async throws { + @Test(arguments: ["nativeClose", "close", "discard"], [true, false]) + func startupRestoreRetriesAfterDeferredWindowCloses( + closePath: String, + replacementExistsBeforeClose: Bool + ) async throws { var signingSecretReady = false var resumeCallbacks: [@MainActor @Sendable () -> Void] = [] try await withWindowHistory( @@ -87,7 +91,7 @@ import Testing selectedWorkspaceIndex: nil, workspaces: [] ), - sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: nil) + sidebar: SessionSidebarSnapshot(isVisible: false, selection: .tabs, width: nil) ) app.setStartupSessionSnapshotForTesting(AppSessionSnapshot( version: SessionSnapshotSchema.currentVersion, @@ -96,20 +100,74 @@ import Testing )) let firstWindowId = app.createMainWindow(shouldActivate: false) + let firstWindow = try #require(app.mainWindowContexts.values.first?.window) + // AppKit can retain a closed window. A live weak reference must not + // let its stale callback consume the replacement's restore. + defer { withExtendedLifetime(firstWindow) {} } #expect(resumeCallbacks.count == 1) - #expect(app.closeMainWindow(windowId: firstWindowId, recordHistory: false)) + let existingReplacementId = replacementExistsBeforeClose + ? app.createMainWindow(shouldActivate: false) : nil + switch closePath { + case "nativeClose": + firstWindow.close() + case "discard": + app.discardMainWindowWithoutClosedHistory(windowId: firstWindowId) + default: + #expect(app.closeMainWindow(windowId: firstWindowId, recordHistory: false)) + } + #expect(app.tabManagerFor(windowId: firstWindowId) == nil) - _ = app.createMainWindow(shouldActivate: false) - #expect(resumeCallbacks.count == 2) + let replacementId = existingReplacementId ?? app.createMainWindow(shouldActivate: false) + let replacement = try #require(app.mainWindowContexts.values.first { $0.windowId == replacementId }) + let replacementWorkspaceId = try #require(replacement.tabManager.tabs.first?.id) + #expect(resumeCallbacks.count == 1) #expect(!app.didAttemptStartupSessionRestore) + await log.flushPendingRecords() + let interactiveEvents = await log.recentEvents() + #expect(interactiveEvents.filter { $0.kind == .windowOpened }.map(\.subject.windowId) == [replacementId]) + #expect(interactiveEvents.filter { $0.kind == .workspaceCreated }.map(\.subject.workspaceId) + == [replacementWorkspaceId]) signingSecretReady = true - resumeCallbacks[0]() + let resume = try #require(resumeCallbacks.first) + resume() + #expect(app.didAttemptStartupSessionRestore) + #expect(!replacement.sidebarState.isVisible) + await log.flushPendingRecords() + #expect(await log.recentEvents().map(\.id) == interactiveEvents.map(\.id)) + + // Readiness notifications are safe to deliver more than once. + replacement.sidebarState.isVisible = true + resume() + #expect(replacement.sidebarState.isVisible) + } + } + + @Test func startupRestoreWaitsForAWindowAfterReadinessArrives() async throws { + var signingSecretReady = false + var resume: (@MainActor @Sendable () -> Void)? + try await withWindowHistory( + initialPhase: .launching, + startupSessionRestoreDeferral: { continuation in + guard !signingSecretReady else { return false } + resume = continuation + return true + } + ) { app, log in + let firstWindowId = app.createMainWindow(shouldActivate: false) + let firstWindow = try #require(app.mainWindowContexts.values.first?.window) + defer { withExtendedLifetime(firstWindow) {} } + #expect(app.closeMainWindow(windowId: firstWindowId, recordHistory: false)) + signingSecretReady = true + let continuation = try #require(resume) + continuation() #expect(!app.didAttemptStartupSessionRestore) - resumeCallbacks[1]() + + let replacementId = app.createMainWindow(shouldActivate: false) #expect(app.didAttemptStartupSessionRestore) await log.flushPendingRecords() - #expect(await log.recentEvents().isEmpty) + #expect(await log.recentEvents().filter { $0.kind == .windowOpened }.map(\.subject.windowId) + == [replacementId]) } } @@ -357,7 +415,7 @@ import Testing var createdId: UUID? try await withWindowHistory( configuredActionId: actionId, - configuredActionExecutor: { _, context, _, onExecuted, _, _ in + configuredActionExecutor: { action, context, _, onExecuted, onCompletion, _ in guard let workspace = context.tabManager.addWorkspaceIfActive( initialSurface: .cloudVMLoading, select: true, @@ -365,6 +423,9 @@ import Testing ) else { return false } createdId = workspace.id onExecuted?() + if action.action == .builtIn(.cloudVM) { + onCompletion?(.init(terminationStatus: 0, output: "", workspaceId: workspace.id)) + } return true } ) { app, log in @@ -546,7 +607,7 @@ import Testing vaultHistoryEventLog: log, windowConfigStoreFactory: { CmuxConfigStore(globalConfigPath: configURL.path) }, configuredActionExecutor: configuredActionExecutor, - startupSessionRestoreDeferral: startupSessionRestoreDeferral + startupSessionRestoreDeferral: startupSessionRestoreDeferral ?? { _ in false } ) defer { for windowId in app.mainWindowContexts.values.map(\.windowId) { From cc6e70d580627856573bfc35ce284d1476e324db Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 16:37:28 -0700 Subject: [PATCH 49/76] fix: own deferred restore and finalize History on completion --- Sources/AppDelegate.swift | 99 ++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72f23fa640cb..e82b6181a389 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1178,12 +1178,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// History may record user actions once a window exists, even while restore /// is waiting for external readiness. Actual restoration has its own phase. private var didRegisterInitialMainWindow = false - /// Startup restore can be deferred while the initial window remains - /// interactive. The initial bootstrap events are filtered and committed - /// immediately, while this sentinel prevents later windows from trying to - /// re-register the same readiness callback. - private var startupSessionRestoreIsDeferred = false - private var startupRestoreDeferredWindowId: UUID? + /// Readiness belongs to the startup operation, not a window that may close + /// while waiting. The identity also rejects duplicate or stale callbacks. + private var startupSessionRestoreDeferralId: UUID? var didAttemptStartupSessionRestore = false var isApplyingSessionRestore = false { didSet { @@ -4001,23 +3998,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent vaultHistoryEventLog?.transition(to: phase) } - private func clearDeferredStartupRestoreIfNeeded(windowId: UUID) { - guard startupRestoreDeferredWindowId == windowId else { return } - startupRestoreDeferredWindowId = nil - startupSessionRestoreIsDeferred = false - } - - private func attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: NSWindow) { + private func attemptStartupSessionRestoreAndSaveIfNeeded( + primaryWindow: NSWindow, + bootstrapWorkspaceId: UUID? = nil + ) { let primaryWindowId = contextForMainTerminalWindow(primaryWindow)?.windowId let didApplyStartupSessionRestore = attemptStartupSessionRestoreIfNeeded( primaryWindow: primaryWindow ) - if startupSessionRestoreIsDeferred, - startupRestoreDeferredWindowId == nil, + synchronizeVaultHistoryRecordingPhase() + if startupSessionRestoreDeferralId != nil, + let bootstrapWorkspaceId, let primaryWindowId { - startupRestoreDeferredWindowId = primaryWindowId - let bootstrapWorkspaceId = contextForMainTerminalWindow(primaryWindow)? - .tabManager.tabs.first?.id vaultHistoryEventLog?.commitWindowCreation( windowId: primaryWindowId, excludingBootstrapWorkspaceId: bootstrapWorkspaceId @@ -4030,7 +4022,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // finalizer runs. vaultHistoryEventLog?.discardWindowCreation(windowId: primaryWindowId) } - synchronizeVaultHistoryRecordingPhase() if !didApplyStartupSessionRestore, didAttemptStartupSessionRestore { // No snapshot restore ran (fresh start / restore disabled): // replay the agent journal now. When a restore DID run, @@ -4051,21 +4042,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) -> Bool { guard !didAttemptStartupSessionRestore else { return false } - guard !startupSessionRestoreIsDeferred else { return false } + guard startupSessionRestoreDeferralId == nil, + let primaryContext = mainWindowContext(forExactWindowIdentity: primaryWindow) else { return false } + let deferralId = UUID() + startupSessionRestoreDeferralId = deferralId if startupSessionRestoreDeferral({ [weak self, weak primaryWindow] in - guard let self else { return } - guard let primaryWindow else { - self.startupSessionRestoreIsDeferred = false - self.startupRestoreDeferredWindowId = nil - return - } - self.startupSessionRestoreIsDeferred = false - self.startupRestoreDeferredWindowId = nil - self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: primaryWindow) + guard let self, self.startupSessionRestoreDeferralId == deferralId else { return } + self.startupSessionRestoreDeferralId = nil + // Prefer the original window only while it is still registered. + // Native close, CLI close, and discarded bootstrap windows all + // retire their route through the same lifecycle coordinator. + let preferredContext = primaryWindow.flatMap { + self.mainWindowContext(forExactWindowIdentity: $0) + } + let liveWindow = preferredContext.flatMap { self.resolvedWindow(for: $0) } + ?? self.mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .compactMap { self.resolvedWindow(for: $0) } + .first + // Do not consume the snapshot when readiness arrives windowless. + // The next registration will attempt restore with readiness met. + guard let liveWindow else { return } + self.attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: liveWindow) }) { - startupSessionRestoreIsDeferred = true return false } + if startupSessionRestoreDeferralId == deferralId { + startupSessionRestoreDeferralId = nil + } + guard !didAttemptStartupSessionRestore else { return false } didAttemptStartupSessionRestore = true // Flush deferred navigation links unless additional restored windows remain pending. defer { @@ -4074,7 +4079,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } guard !didHandleExplicitOpenIntentAtStartup else { return false } - guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return false } let startupSnapshot = startupSessionSnapshot primaryContext.tabManager.prepareLegacyWorkspaceCustomizationMigration( @@ -5798,8 +5802,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) } + let bootstrapWorkspaceId = didRegisterInitialMainWindow ? nil : tabManager.tabs.first?.id didRegisterInitialMainWindow = true - attemptStartupSessionRestoreAndSaveIfNeeded(primaryWindow: window) + attemptStartupSessionRestoreAndSaveIfNeeded( + primaryWindow: window, + bootstrapWorkspaceId: bootstrapWorkspaceId + ) } #if DEBUG @@ -6821,22 +6829,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !didClose, !recordHistory { closedWindowHistorySuppressedWindowIds.remove(windowId) } - if didClose { - clearDeferredStartupRestoreIfNeeded(windowId: windowId) - } return didClose } func discardMainWindowWithoutClosedHistory(windowId: UUID) { guard let window = mainWindowForClose(windowId: windowId) else { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) - clearDeferredStartupRestoreIfNeeded(windowId: windowId) return } closedWindowHistorySuppressedWindowIds.insert(windowId) if closeMainWindowWithoutInteractiveVeto(window) { vaultHistoryEventLog?.discardWindowCreation(windowId: windowId) - clearDeferredStartupRestoreIfNeeded(windowId: windowId) } else { closedWindowHistorySuppressedWindowIds.remove(windowId) } @@ -8721,12 +8724,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent in: context, debugSource: debugSource, replacingInitialWorkspace: initialWorkspace, - onCompleted: { [weak context] succeeded in + onCompleted: { [weak context] _ in guard let context else { return } - if !succeeded, - let initialWorkspace = context.tabManager.tabs.first(where: { $0.id == initialWorkspace?.id }) { - context.tabManager.recordVaultHistoryWorkspaceCreated(initialWorkspace) - } finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) } ) @@ -8770,7 +8769,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) context.tabManager.setPinned(workspace, pinned: true) } - finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) + if initialSurface != .terminal { + finalizeWindowHistory(!context.tabManager.isFinalizedForWindowClose) + } } else { finalizeWindowHistory(false) } @@ -9401,8 +9402,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let beforeIds = workspaceGroupTarget.map { _ in Set(context.tabManager.tabs.map(\.id)) } var asyncObserverId: UUID? var didFinishInitialWorkspace = false - // Inspect the action's actual result instead of maintaining a second list - // of workspace-producing actions. Cloud VM may complete after onExecuted. + // Finalize the initial workspace only from the action's completion. + // onExecuted can mean "started" while authorization or VM creation is + // pending, and cannot decide which workspace will remain. let finishInitialWorkspace: (Bool) -> Void = { [weak self, weak context] shouldReplaceInitial in guard !didFinishInitialWorkspace, let self, @@ -9444,7 +9446,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } } - finishInitialWorkspace(action.action != .builtIn(.cloudVM)) } let onCloudVMCompletion: (CloudVMActionLauncher.Completion) -> Void = { [weak context] completion in if let context, let asyncObserverId { @@ -9454,7 +9455,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent workspaceId: completion.succeeded ? completion.workspaceId : nil ) } - finishInitialWorkspace(true) } return executeConfiguredCmuxAction( action, @@ -9462,7 +9462,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preferredWindow: window, onExecuted: onExecuted, onCloudVMCompletion: onCloudVMCompletion, - onCompleted: onCompleted + onCompleted: { succeeded in + finishInitialWorkspace(succeeded) + onCompleted?(succeeded) + } ) } From d76996fc8a31870c5952178c584040b67ab1a6bd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 16:59:14 -0700 Subject: [PATCH 50/76] test: cover completion of suppressed configured splits --- cmuxTests/VaultHistoryEventLogTests.swift | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index b68b93727c7d..ee766a6155c2 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -515,6 +515,45 @@ import Testing } } + @Test(arguments: ["cmux.splitRight", "cmux.splitDown"]) + func suppressedConfiguredSplitCompletesWithoutExecuting(actionId: String) async throws { + try await withWindowHistory(configuredActionId: actionId) { app, _ in + let windowId = app.createMainWindow(shouldActivate: false) + let context = try #require(app.mainWindowContexts.values.first { $0.windowId == windowId }) + let window = try #require(context.window) + let workspace = try #require(context.tabManager.selectedWorkspace) + let terminal = try #require(workspace.focusedTerminalInputTarget()?.panel) + let action = try #require(context.cmuxConfigStore?.resolvedNewWorkspaceAction()) + let previousRoutingWindow = app.shortcutRoutingKeyWindow + let wasHidden = terminal.hostedView.isHidden + defer { + terminal.hostedView.isHidden = wasHidden + app.debugSetShortcutRoutingFocusedWindowForTesting(previousRoutingWindow) + } + terminal.hostedView.isHidden = true + #expect(window.makeFirstResponder(nil)) + app.debugSetShortcutRoutingFocusedWindowForTesting(window) + try #require(app.shouldSuppressSplitShortcutForTransientTerminalFocusState( + tabManager: context.tabManager + )) + + let originalPanelIds = Set(workspace.panels.keys) + var executionCount = 0 + var completions: [Bool] = [] + #expect(app.executeConfiguredCmuxAction( + action, + context: context, + preferredWindow: window, + onExecuted: { executionCount += 1 }, + onCompleted: { completions.append($0) } + )) + + #expect(executionCount == 0) + #expect(completions == [false]) + #expect(Set(workspace.panels.keys) == originalPanelIds) + } + } + @Test func browserWorkspaceWithNoWindowsRecordsOnlyTheBrowserWorkspace() async throws { try await withWindowHistory { app, log in #expect(app.performNewBrowserWorkspaceAction()) From 966b1c4f7bc730218cdac3de796e0142ba4e7862 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 17:21:05 -0700 Subject: [PATCH 51/76] test: require a stable accessible History grouping control --- .../RightSidebarChromeHeightUITests.swift | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 145704343b77..683fd0338d78 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -39,23 +39,9 @@ final class RightSidebarChromeHeightUITests: XCTestCase { addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") history.click() - // macOS exposes the Menu's visible text as its AXTitle, not its - // AXDescription (XCUIElement.label). Keep both attributes in the query - // so it follows the visible grouping across supported macOS versions. - let groupings = ["Date", "Workspace", "Window", "Agent", "Type"] - // SwiftUI exposes Menu with different XCUI roles across supported - // macOS/Xcode combinations. Prefer the stable identifier from the - // production view, then retain the title/label fallback for older - // accessibility bridges. - let identifiedPicker = app.descendants(matching: .any)[ - "VaultHistoryGroupPicker" - ].firstMatch - let titledPicker = app.menuButtons.matching( - NSPredicate(format: "title IN %@ OR label IN %@", groupings, groupings) - ).firstMatch - let picker = identifiedPicker.waitForExistence(timeout: 3) - ? identifiedPicker - : titledPicker + // The picker exposes its purpose as its accessible label and the + // selected grouping as its value, independently of inherited IDs. + let picker = app.menuButtons["Group history by"].firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() @@ -64,7 +50,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { item.click() let selected = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - picker.title == grouping || picker.label == grouping + picker.value as? String == grouping }, object: nil ) From c9ff2a4d2bebbdbe1df23ea84c29d5dea307ef68 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 17:21:32 -0700 Subject: [PATCH 52/76] fix: complete suppressed actions and stabilize verification contracts --- .../MobileTerminalLaneCoordinator.swift | 19 ++++++++------- .../MobileTerminalLaneCoordinatorTests.swift | 18 +++++++-------- Sources/AppDelegate.swift | 2 ++ Sources/VaultHistoryView.swift | 23 +++++++++++-------- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift index a9603ebf3e4e..08e55660d9dc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift @@ -110,6 +110,16 @@ actor MobileTerminalLaneCoordinator { self.inputOnlyProvider = inputOnlyProvider } + /// Output lanes must never fall back to the input-only transport. + func resolvedProvider(for mode: LaneMode) -> MobileTerminalLaneProvider? { + switch mode { + case .output: + return provider + case .inputOnly: + return inputOnlyProvider ?? provider + } + } + func ensure(_ configuration: Configuration) async { guard let key = LaneKey(configuration: configuration) else { return } focusedKeyBySurfaceID[configuration.surfaceID] = key @@ -232,14 +242,7 @@ actor MobileTerminalLaneCoordinator { ? nil : await configuration.cursor() do { - let laneProvider: MobileTerminalLaneProvider? - switch configuration.mode { - case .output: - laneProvider = provider - case .inputOnly: - laneProvider = inputOnlyProvider ?? provider - } - guard let laneProvider else { + guard let laneProvider = resolvedProvider(for: configuration.mode) else { await markFailed(key: key, id: id) return } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift index 0e70a9549ab4..6368e6e1c84b 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift @@ -140,17 +140,15 @@ struct MobileTerminalLaneCoordinatorTests { } ) - await coordinator.ensure(Self.configuration( - providerRequest: try Self.request(), - cursor: { nil }, - consume: { _ in .accepted(outputReady: true) }, - readinessChanged: { _ in } - )) - try await Task.sleep(for: .milliseconds(10)) - + // Test the policy used by run() directly: waiting for an absent call + // races the background launch and cannot prove a forbidden fallback. + #expect(await coordinator.resolvedProvider(for: .output) == nil) #expect(await inputProvider.requestCount() == 0) - #expect(await coordinator.isOutputReady(surfaceID: Self.surfaceID) == false) - await coordinator.deactivateAll() + + let selectedInputProvider = try #require(await coordinator.resolvedProvider(for: .inputOnly)) + let lane = try await selectedInputProvider(try Self.request(), Self.surfaceID, nil) + #expect(await inputProvider.requestCount() == 1) + await lane.close() } @Test diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e82b6181a389..06e7399b3a23 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -17818,6 +17818,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: .right, tabManager: context.tabManager ) { + onCompleted?(false) return true } let didSplit = performSplitShortcut( @@ -17836,6 +17837,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: .down, tabManager: context.tabManager ) { + onCompleted?(false) return true } let didSplit = performSplitShortcut( diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index 4bb12aa42e11..968f592e27c0 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -69,13 +69,19 @@ private struct VaultHistoryContentView: View { } private var controlBar: some View { - HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { + let selectedGroup = model.groupKey + let selectGroup: (VaultHistoryGroupKey) -> Void = { model.groupKey = $0 } + let groupPickerLabel = String( + localized: "vaultHistory.groupPicker.tooltip", + defaultValue: "Group history by" + ) + return HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { Menu { ForEach(VaultHistoryGroupKey.allCases) { key in Button { - model.groupKey = key + selectGroup(key) } label: { - if model.groupKey == key { + if selectedGroup == key { Label(key.label, systemImage: "checkmark") } else { Text(key.label) @@ -85,7 +91,7 @@ private struct VaultHistoryContentView: View { } label: { HStack(spacing: 3) { CmuxSystemSymbolImage( - magnified: model.groupKey.symbolName, + magnified: selectedGroup.symbolName, pointSize: RightSidebarChromeControlStyle.secondaryIconSize, weight: RightSidebarChromeControlStyle.iconWeight, tint: RightSidebarChromeControlStyle.pillForegroundColor( @@ -93,7 +99,7 @@ private struct VaultHistoryContentView: View { isHovered: isGroupPickerHovered ) ) - Text(model.groupKey.label) + Text(selectedGroup.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight @@ -107,10 +113,9 @@ private struct VaultHistoryContentView: View { } .menuStyle(.borderlessButton) .fixedSize() - .help(String( - localized: "vaultHistory.groupPicker.tooltip", - defaultValue: "Group history by" - )) + .help(groupPickerLabel) + .accessibilityLabel(groupPickerLabel) + .accessibilityValue(selectedGroup.label) .accessibilityIdentifier("VaultHistoryGroupPicker") .titlebarInteractiveControl() .onHover { isGroupPickerHovered = $0 } From 2abf95265db03a803d9d6682414784637fb9c9fa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 17:25:57 -0700 Subject: [PATCH 53/76] fix: restore History translations lost during main integration --- Resources/Localizable.xcstrings | 510 ++++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bb1a136ec179..4d72191bae39 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -282814,6 +282814,516 @@ } } }, + "vaultHistory.bucket.last24Hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 24 hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去24時間" + } + } + } + }, + "vaultHistory.bucket.older": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Older" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "それ以前" + } + } + } + }, + "vaultHistory.bucket.thisMonth": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月" + } + } + } + }, + "vaultHistory.bucket.thisWeek": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This week" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週" + } + } + } + }, + "vaultHistory.bucket.yesterday": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + } + } + }, + "vaultHistory.detail.renamedFrom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "was “%@”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "旧名 “%@”" + } + } + } + }, + "vaultHistory.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace, window, and agent session activity will appear here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース、ウインドウ、エージェントセッションのアクティビティがここに表示されます。" + } + } + } + }, + "vaultHistory.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No history yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴はまだありません" + } + } + } + }, + "vaultHistory.group.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + } + } + }, + "vaultHistory.group.cmux": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + } + } + }, + "vaultHistory.group.date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" + } + } + } + }, + "vaultHistory.group.kind": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "種類" + } + } + } + }, + "vaultHistory.group.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + } + } + }, + "vaultHistory.group.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + } + } + }, + "vaultHistory.group.workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + } + } + }, + "vaultHistory.groupPicker.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Group history by" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のグループ化" + } + } + } + }, + "vaultHistory.kind.sessionActivity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントセッション" + } + } + } + }, + "vaultHistory.kind.windowClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを閉じました" + } + } + } + }, + "vaultHistory.kind.windowOpened": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window opened" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを開きました" + } + } + } + }, + "vaultHistory.kind.workspaceClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じました" + } + } + } + }, + "vaultHistory.kind.workspaceCreated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace created" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを作成しました" + } + } + } + }, + "vaultHistory.kind.workspaceRenamed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace renamed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名を変更しました" + } + } + } + }, + "vaultHistory.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading history…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を読み込み中…" + } + } + } + }, + "vaultHistory.reload.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を再読み込み" + } + } + } + }, + "vaultHistory.untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題" + } + } + } + }, + "vaultHistory.window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + } + } + }, + "vaultHistory.workspaceCount.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 個のワークスペース" + } + } + } + }, + "vaultHistory.workspaceCount.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のワークスペース" + } + } + } + }, + "vaultPane.tab.history": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "History" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴" + } + } + } + }, + "vaultPane.tab.sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + } + } + }, "workspace.agentChat.defaultTitle": { "extractionState": "manual", "localizations": { From c6ecd0c12b2374a3f3f8b6202f6338fdfc2b42ff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 17:27:19 -0700 Subject: [PATCH 54/76] fix: retain all reviewed History locales --- Resources/Localizable.xcstrings | 3754 ++++++++++++++++++++++++++++--- 1 file changed, 3497 insertions(+), 257 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4d72191bae39..b72e578d2cdb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -282817,510 +282817,3750 @@ "vaultHistory.bucket.last24Hours": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Last 24 hours" + "value": "آخر 24 ساعة" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "過去24時間" + "value": "Posljednja 24 sata" } - } - } - }, - "vaultHistory.bucket.older": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "da": { "stringUnit": { "state": "translated", - "value": "Older" + "value": "Seneste 24 timer" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "それ以前" + "value": "Letzte 24 Stunden" } - } - } - }, - "vaultHistory.bucket.thisMonth": { - "extractionState": "manual", - "localizations": { + }, "en": { "stringUnit": { "state": "translated", - "value": "This month" + "value": "Last 24 hours" } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "今月" + "value": "Últimas 24 horas" } - } - } - }, - "vaultHistory.bucket.thisWeek": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "fr": { "stringUnit": { "state": "translated", - "value": "This week" + "value": "Dernières 24 heures" } }, - "ja": { + "it": { "stringUnit": { "state": "translated", - "value": "今週" + "value": "Ultime 24 ore" } - } - } - }, - "vaultHistory.bucket.yesterday": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Yesterday" + "value": "過去24時間" } }, - "ja": { + "km": { "stringUnit": { "state": "translated", - "value": "昨日" + "value": "24 ម៉ោងចុងក្រោយ" } - } - } - }, - "vaultHistory.detail.renamedFrom": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "was “%@”" + "value": "지난 24시간" } }, - "ja": { + "nb": { "stringUnit": { "state": "translated", - "value": "旧名 “%@”" + "value": "Siste 24 timer" } - } - } - }, - "vaultHistory.empty.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "pl": { "stringUnit": { "state": "translated", - "value": "Workspace, window, and agent session activity will appear here." + "value": "Ostatnie 24 godziny" } }, - "ja": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "ワークスペース、ウインドウ、エージェントセッションのアクティビティがここに表示されます。" + "value": "Últimas 24 horas" } - } - } - }, - "vaultHistory.empty.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ru": { "stringUnit": { "state": "translated", - "value": "No history yet" + "value": "Последние 24 часа" } }, - "ja": { + "th": { "stringUnit": { "state": "translated", - "value": "履歴はまだありません" + "value": "24 ชั่วโมงที่ผ่านมา" } - } - } - }, - "vaultHistory.group.agent": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "tr": { "stringUnit": { "state": "translated", - "value": "Agent" + "value": "Son 24 saat" } }, - "ja": { + "uk": { "stringUnit": { "state": "translated", - "value": "エージェント" + "value": "Останні 24 години" } - } - } - }, - "vaultHistory.group.cmux": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "cmux" + "value": "过去 24 小时" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "cmux" + "value": "過去 24 小時" } } } }, - "vaultHistory.group.date": { + "vaultHistory.bucket.older": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Date" + "value": "أقدم" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "日付" + "value": "Starije" } - } - } - }, - "vaultHistory.group.kind": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "da": { "stringUnit": { "state": "translated", - "value": "Type" + "value": "Ældre" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "種類" + "value": "Älter" } - } - } - }, - "vaultHistory.group.other": { - "extractionState": "manual", - "localizations": { + }, "en": { "stringUnit": { "state": "translated", - "value": "Other" + "value": "Older" } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "その他" + "value": "Más antiguo" } - } - } - }, - "vaultHistory.group.window": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "fr": { "stringUnit": { "state": "translated", - "value": "Window" + "value": "Plus ancien" } }, - "ja": { + "it": { "stringUnit": { "state": "translated", - "value": "ウインドウ" + "value": "Meno recenti" } - } - } - }, - "vaultHistory.group.workspace": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Workspace" + "value": "それ以前" } }, - "ja": { + "km": { "stringUnit": { "state": "translated", - "value": "ワークスペース" + "value": "ចាស់ជាងនេះ" } - } - } - }, - "vaultHistory.groupPicker.tooltip": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ko": { "stringUnit": { "state": "translated", - "value": "Group history by" + "value": "이전" } }, - "ja": { + "nb": { "stringUnit": { "state": "translated", - "value": "履歴のグループ化" + "value": "Eldre" } - } - } - }, - "vaultHistory.kind.sessionActivity": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "pl": { "stringUnit": { "state": "translated", - "value": "Agent session" + "value": "Starsze" } }, - "ja": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "エージェントセッション" + "value": "Mais antigos" } - } - } - }, - "vaultHistory.kind.windowClosed": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ru": { "stringUnit": { "state": "translated", - "value": "Window closed" + "value": "Более ранние" } }, - "ja": { + "th": { "stringUnit": { "state": "translated", - "value": "ウインドウを閉じました" + "value": "เก่ากว่านี้" } - } - } - }, - "vaultHistory.kind.windowOpened": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "tr": { "stringUnit": { "state": "translated", - "value": "Window opened" + "value": "Daha eski" } }, - "ja": { + "uk": { "stringUnit": { "state": "translated", - "value": "ウインドウを開きました" + "value": "Давніші" } - } - } - }, - "vaultHistory.kind.workspaceClosed": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Workspace closed" + "value": "更早" } }, - "ja": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "ワークスペースを閉じました" + "value": "較早" } } } }, - "vaultHistory.kind.workspaceCreated": { + "vaultHistory.bucket.thisMonth": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "Workspace created" + "value": "هذا الشهر" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "ワークスペースを作成しました" + "value": "Ovog mjeseca" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne måned" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Monat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This month" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este mes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce mois-ci" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questo mese" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今月" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ខែនេះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 달" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne måneden" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W tym miesiącu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este mês" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В этом месяце" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เดือนนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu ay" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Цього місяця" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本月" + } + } + } + }, + "vaultHistory.bucket.thisWeek": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا الأسبوع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ove sedmice" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne uge" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Woche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This week" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta semana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette semaine" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa settimana" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今週" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សប្ដាហ៍នេះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 주" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne uken" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W tym tygodniu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta semana" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На этой неделе" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สัปดาห์นี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu hafta" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Цього тижня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本周" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本週" + } + } + } + }, + "vaultHistory.bucket.yesterday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمس" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Jučer" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "I går" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ayer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ieri" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម្សិលមិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어제" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "I går" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczoraj" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ontem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вчера" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เมื่อวาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Dün" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Учора" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + } + } + }, + "vaultHistory.detail.renamedFrom": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم السابق «%@»" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "prethodno „%@”" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "var “%@”" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "zuvor „%@“" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "was “%@”" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "antes «%@»" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "précédemment « %@ »" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "in precedenza “%@”" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "旧名 “%@”" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពីមុន “%@”" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 이름 “%@”" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "var «%@»" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "poprzednio „%@”" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "antes “%@”" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ранее «%@»" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ชื่อเดิม “%@”" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "önceki adı “%@”" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "раніше «%@»" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原名“%@”" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原名「%@」" + } + } + } + }, + "vaultHistory.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيظهر هنا نشاط مساحات العمل والنوافذ وجلسات الوكلاء." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Aktivnosti radnih prostora, prozora i sesija agenata pojavit će se ovdje." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Aktivitet i arbejdsområder, vinduer og agentsessioner vises her." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivitäten in Arbeitsbereichen, Fenstern und Agent-Sitzungen werden hier angezeigt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace, window, and agent session activity will appear here." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La actividad de espacios de trabajo, ventanas y sesiones de agentes aparecerá aquí." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’activité des espaces de travail, des fenêtres et des sessions d’agent apparaîtra ici." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le attività di aree di lavoro, finestre e sessioni agente verranno visualizzate qui." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース、ウインドウ、エージェントセッションのアクティビティがここに表示されます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សកម្មភាពកន្លែងធ្វើការ បង្អួច និងសម័យភ្នាក់ងារ នឹងបង្ហាញនៅទីនេះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간, 윈도우 및 에이전트 세션 활동이 여기에 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktivitet i arbeidsområder, vinduer og agentøkter vises her." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Aktywność przestrzeni roboczych, okien i sesji agentów pojawi się tutaj." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A atividade de áreas de trabalho, janelas e sessões de agentes aparecerá aqui." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Здесь появятся действия в рабочих пространствах, окнах и сеансах агентов." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กิจกรรมของเวิร์กสเปซ หน้าต่าง และเซสชันเอเจนต์จะปรากฏที่นี่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı, pencere ve ajan oturumu etkinlikleri burada görünecek." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тут відображатимуться дії в робочих областях, вікнах і сеансах агентів." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区、窗口和代理会话活动将显示在这里。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區、視窗和代理程式工作階段活動會顯示在這裡。" + } + } + } + }, + "vaultHistory.empty.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد سجل حتى الآن" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Još nema historije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen historik endnu" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch kein Verlauf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No history yet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay historial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun historique pour le moment" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna cronologia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴はまだありません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនទាន់មានប្រវត្តិទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 기록이 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen historikk ennå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak historii" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ainda não há histórico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Истории пока нет" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยังไม่มีประวัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Henüz geçmiş yok" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Історії ще немає" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚無歷史記錄" + } + } + } + }, + "vaultHistory.group.agent": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وكيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Agente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្នាក់ងារ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Agent" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Агент" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式" + } + } + } + }, + "vaultHistory.group.cmux": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux" + } + } + } + }, + "vaultHistory.group.date": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التاريخ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Datum" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Dato" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datum" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Date" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Data" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កាលបរិច្ឆេទ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dato" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Data" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Data" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дата" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วันที่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarih" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дата" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日期" + } + } + } + }, + "vaultHistory.group.kind": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النوع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vrsta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Typ" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "種類" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្រភេទ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유형" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Typ" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тип" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประเภท" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tür" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Тип" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "类型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "類型" + } + } + } + }, + "vaultHistory.group.other": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أخرى" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ostalo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Andet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Altro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្សេងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기타" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Annet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Inne" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Outro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Другое" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อื่นๆ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Diğer" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Інше" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "其他" + } + } + } + }, + "vaultHistory.group.window": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្អួច" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вікно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" + } + } + } + }, + "vaultHistory.group.workspace": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة العمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestrzeń robocza" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Робоча область" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作區" + } + } + } + }, + "vaultHistory.groupPicker.tooltip": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تجميع السجل حسب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Grupiši historiju prema" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gruppér historik efter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf gruppieren nach" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Group history by" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agrupar historial por" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Regrouper l’historique par" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Raggruppa cronologia per" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴のグループ化" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ដាក់ប្រវត្តិជាក្រុមតាម" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 그룹화 기준" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Grupper historikk etter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Grupuj historię według" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agrupar histórico por" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Группировать историю по" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จัดกลุ่มประวัติตาม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmişi şuna göre grupla" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Групувати історію за" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史记录分组依据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷史記錄分組依據" + } + } + } + }, + "vaultHistory.kind.sessionActivity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة الوكيل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sesija agenta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agentsession" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Agent-Sitzung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent session" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión del agente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session d’agent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sessione agente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントセッション" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សម័យភ្នាក់ងារ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 세션" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agentøkt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sesja agenta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sessão do agente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеанс агента" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชันเอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan oturumu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сеанс агента" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式工作階段" + } + } + } + }, + "vaultHistory.kind.windowClosed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إغلاق النافذة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor je zatvoren" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue lukket" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster geschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Window closed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana cerrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre fermée" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra chiusa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを閉じました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបិទបង្អួច" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 닫힘" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu lukket" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknięto okno" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela fechada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно закрыто" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดหน้าต่างแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere kapatıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вікно закрито" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉視窗" + } + } + } + }, + "vaultHistory.kind.windowOpened": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم فتح النافذة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor je otvoren" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue åbnet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster geöffnet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Window opened" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana abierta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre ouverte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra aperta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウを開きました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបើកបង្អួច" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우 열림" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu åpnet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwarto okno" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela aberta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно открыто" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดหน้าต่างแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere açıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вікно відкрито" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已打开窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟視窗" + } + } + } + }, + "vaultHistory.kind.workspaceClosed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إغلاق مساحة العمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor je zatvoren" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde lukket" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich geschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace closed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo cerrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail fermé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro chiusa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបិទកន្លែងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 닫힘" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde lukket" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zamknięto przestrzeń roboczą" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de trabalho fechada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство закрыто" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดเวิร์กสเปซแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı kapatıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Робочу область закрито" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉工作區" + } + } + } + }, + "vaultHistory.kind.workspaceCreated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إنشاء مساحة العمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor je kreiran" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde oprettet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich erstellt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo creado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail créé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro creata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを作成しました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបង្កើតកន្លែងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 생성됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde opprettet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Utworzono przestrzeń roboczą" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de trabalho criada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство создано" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สร้างเวิร์กสเปซแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı oluşturuldu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Робочу область створено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已创建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已建立工作區" + } + } + } + }, + "vaultHistory.kind.workspaceRenamed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت إعادة تسمية مساحة العمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor je preimenovan" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Arbejdsområde omdøbt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsbereich umbenannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace renamed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo renombrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail renommé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Area di lavoro rinominata" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース名を変更しました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានប្តូរឈ្មោះកន្លែងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 이름 변경됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Arbeidsområde endret navn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmieniono nazwę przestrzeni roboczej" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Área de trabalho renomeada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочее пространство переименовано" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปลี่ยนชื่อเวิร์กสเปซแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma alanı yeniden adlandırıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Робочу область перейменовано" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已重命名工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已重新命名工作區" + } + } + } + }, + "vaultHistory.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل السجل…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učitavanje historije…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indlæser historik…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf wird geladen…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading history…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando historial…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement de l’historique…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento cronologia…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を読み込み中…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងផ្ទុកប្រវត្តិ…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 불러오는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster historikk…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytywanie historii…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carregando histórico…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка истории…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังโหลดประวัติ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmiş yükleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантаження історії…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载历史记录…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入歷史記錄…" + } + } + } + }, + "vaultHistory.reload.tooltip": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تحميل السجل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ponovo učitaj historiju" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genindlæs historik" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlauf neu laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload History" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volver a cargar el historial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recharger l’historique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ricarica cronologia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を再読み込み" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទុកប្រវត្តិឡើងវិញ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 다시 불러오기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Last historikk på nytt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytaj historię ponownie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recarregar histórico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезагрузить историю" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โหลดประวัติใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmişi yeniden yükle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перезавантажити історію" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新加载历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新載入歷史記錄" + } + } + } + }, + "vaultHistory.untitled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بلا عنوان" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Bez naslova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Uden titel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Titel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin título" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sans titre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Senza titolo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គ្មានចំណងជើង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목 없음" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uten tittel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Bez tytułu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sem título" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Без названия" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีชื่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlıksız" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Без назви" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未命名" + } + } + } + }, + "vaultHistory.window": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نافذة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vindue" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fenster" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fenêtre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finestra" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្អួច" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "윈도우" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vindu" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Okno" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Janela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Окно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Pencere" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вікно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視窗" } } } }, - "vaultHistory.kind.workspaceRenamed": { + "vaultHistory.workspaceCount.one": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل واحدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "1 radni prostor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "1 arbejdsområde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1 Arbeitsbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "1 workspace" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1 espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1 espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1 area di lavoro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 個のワークスペース" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការ 1" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간 1개" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "1 arbeidsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1 przestrzeń robocza" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "1 área de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 рабочее пространство" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "1 เวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "1 çalışma alanı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "1 робоча область" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 個工作區" + } + } + } + }, + "vaultHistory.workspaceCount.other": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d مساحات عمل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%d radnih prostora" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%d arbejdsområder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Arbeitsbereiche" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Workspace renamed" + "value": "%d workspaces" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%d aree di lavoro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のワークスペース" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការ %d" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "ワークスペース名を変更しました" + "value": "작업 공간 %d개" } - } - } - }, - "vaultHistory.loading": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "nb": { "stringUnit": { "state": "translated", - "value": "Loading history…" + "value": "%d arbeidsområder" } }, - "ja": { + "pl": { "stringUnit": { "state": "translated", - "value": "履歴を読み込み中…" + "value": "%d przestrzeni roboczych" } - } - } - }, - "vaultHistory.reload.tooltip": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "pt-BR": { "stringUnit": { "state": "translated", - "value": "Reload History" + "value": "%d áreas de trabalho" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "履歴を再読み込み" + "value": "%d рабочих пространств" } - } - } - }, - "vaultHistory.untitled": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "th": { "stringUnit": { "state": "translated", - "value": "Untitled" + "value": "%d เวิร์กสเปซ" } }, - "ja": { + "tr": { "stringUnit": { "state": "translated", - "value": "無題" + "value": "%d çalışma alanı" } - } - } - }, - "vaultHistory.window": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "uk": { "stringUnit": { "state": "translated", - "value": "Window" + "value": "%d робочих областей" } }, - "ja": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "ウインドウ" + "value": "%d 个工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個工作區" } } } }, - "vaultHistory.workspaceCount.one": { + "vaultPane.tab.history": { "extractionState": "manual", "localizations": { - "en": { + "ar": { "stringUnit": { "state": "translated", - "value": "1 workspace" + "value": "السجل" } }, - "ja": { + "bs": { "stringUnit": { "state": "translated", - "value": "1 個のワークスペース" + "value": "Historija" } - } - } - }, - "vaultHistory.workspaceCount.other": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "da": { "stringUnit": { "state": "translated", - "value": "%d workspaces" + "value": "Historik" } }, - "ja": { + "de": { "stringUnit": { "state": "translated", - "value": "%d 個のワークスペース" + "value": "Verlauf" } - } - } - }, - "vaultPane.tab.history": { - "extractionState": "manual", - "localizations": { + }, "en": { "stringUnit": { "state": "translated", "value": "History" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Historial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cronologia" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "履歴" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្រវត្តិ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Historikk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Historia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Histórico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ประวัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçmiş" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Історія" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史记录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歷史記錄" + } } } }, "vaultPane.tab.sessions": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الجلسات" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sesije" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sessioner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sitzungen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Sessions" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesiones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sessioni" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "セッション" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សម័យ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세션" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Økter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sesje" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sessões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеансы" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เซสชัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturumlar" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сеанси" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作階段" + } } } }, From 753d7c22d00f44802162a10bee8b09e254ee6b20 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 18:27:00 -0700 Subject: [PATCH 55/76] test: require stable Vault control identifiers in both mounts --- .../RightSidebarChromeHeightUITests.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 683fd0338d78..5cd38373b5f7 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -26,10 +26,10 @@ final class RightSidebarChromeHeightUITests: XCTestCase { let vault = app.buttons["RightSidebarModeButton.sessions"] XCTAssertTrue(vault.waitForExistence(timeout: 10)) vault.click() - // SwiftUI propagates the sidebar container's accessibility identifier - // over these children; their localized labels remain exposed in AX. - let sessions = app.buttons["Sessions"].firstMatch - let history = app.buttons["History"].firstMatch + // The Vault container must preserve its children's identifiers in + // both sidebar and standalone-pane mounts. + let sessions = app.buttons["VaultPaneTabButton.sessions"].firstMatch + let history = app.buttons["VaultPaneTabButton.history"].firstMatch XCTAssertTrue(sessions.waitForExistence(timeout: 10)) XCTAssertTrue(history.waitForExistence(timeout: 10)) // A launch-argument override would pin this AppStorage value even @@ -39,9 +39,9 @@ final class RightSidebarChromeHeightUITests: XCTestCase { addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") history.click() - // The picker exposes its purpose as its accessible label and the - // selected grouping as its value, independently of inherited IDs. - let picker = app.menuButtons["Group history by"].firstMatch + // Query the stable identifier, not a localized label or the Menu's + // platform-dependent title/description mapping. + let picker = app.menuButtons["VaultHistoryGroupPicker"].firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() @@ -68,7 +68,7 @@ final class RightSidebarChromeHeightUITests: XCTestCase { popout.click() let mountedTwice = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - app.buttons.matching(NSPredicate(format: "label == %@", "History")).count == 2 + app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 2 }, object: nil ) @@ -79,15 +79,15 @@ final class RightSidebarChromeHeightUITests: XCTestCase { closeSidebar.click() let paneOnly = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in - app.buttons.matching(NSPredicate(format: "label == %@", "History")).count == 1 + app.buttons.matching(identifier: "VaultPaneTabButton.history").count == 1 && !closeSidebar.exists }, object: nil ) XCTAssertEqual(XCTWaiter.wait(for: [paneOnly], timeout: 10), .completed) - app.buttons["Sessions"].click() + app.buttons["VaultPaneTabButton.sessions"].click() XCTAssertTrue(app.buttons["Folder"].waitForExistence(timeout: 5)) - app.buttons["History"].click() + app.buttons["VaultPaneTabButton.history"].click() XCTAssertTrue(picker.waitForExistence(timeout: 5)) addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-history-standalone-pane") } From cb75df3c7c7e495d0a5c12d03cf3ed0d6967051a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 18:35:24 -0700 Subject: [PATCH 56/76] fix: preserve Vault accessibility boundaries and isolate blocking test tasks --- Sources/VaultPaneView.swift | 4 ++++ ...laudeHookSurfaceResolutionSwiftTests.swift | 22 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 32b3eb66278f..44792b4ba71e 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -39,6 +39,10 @@ struct VaultPaneView: View { ) } } + // Keep the sidebar's identifier on its own container instead of + // inheriting it on every Vault tab, menu, and timeline control. + .accessibilityElement(children: .contain) + .accessibilityIdentifier("VaultPane") } private var tabBar: some View { diff --git a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift index c24bb1556cc4..715b1b16a42c 100644 --- a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift +++ b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift @@ -507,6 +507,20 @@ struct ClaudeHookSurfaceResolutionSwiftTests { return fd } + /// Keep intentionally blocking fixture loops off libdispatch's shared pool. + /// The app-host test bundle runs Swift Testing work concurrently, and a + /// saturated global pool can otherwise delay both socket replies and child + /// exit observation without changing the CLI behavior under test. + private static func detachBlockingThread( + name: String, + _ body: @escaping @Sendable () -> Void + ) { + let thread = Thread(block: body) + thread.name = name + thread.stackSize = 1 << 20 + thread.start() + } + private func startMockServer( listenerFD: Int32, state: MockSocketServerState, @@ -514,7 +528,9 @@ struct ClaudeHookSurfaceResolutionSwiftTests { handler: @escaping @Sendable (String) -> String ) -> DispatchSemaphore { let handled = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { + // Accept and per-client loops block indefinitely; keep them off the + // libdispatch pool so parallel suites cannot starve each other. + Self.detachBlockingThread(name: "cmux-test-claude-mock-accept") { while true { var clientAddr = sockaddr_un() var clientAddrLen = socklen_t(MemoryLayout.size) @@ -528,7 +544,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { return } - DispatchQueue.global(qos: .userInitiated).async { + Self.detachBlockingThread(name: "cmux-test-claude-mock-client") { var authenticated = requiredSocketPassword == nil defer { @@ -829,7 +845,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { } let exitSignal = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { + Self.detachBlockingThread(name: "cmux-test-process-reaper") { process.waitUntilExit() exitSignal.signal() } From 885592af3fe3e8eedb137ed38df80cabfa386603 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 18:38:50 -0700 Subject: [PATCH 57/76] ci: allow opt-in crash symbolication for focused E2E diagnostics --- .github/workflows/test-e2e.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9b2e8b8701b3..9a82810d6f99 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -24,6 +24,11 @@ on: required: false default: true type: boolean + symbolicate_crashes: + description: Resolve Swift crash symbols for a focused diagnostic run + required: false + default: false + type: boolean runner: description: "Runner OS (auto follows MACOS_RUNNER_15; tart-* choices use isolated VMs)" required: false @@ -341,7 +346,7 @@ jobs: RECORD_VIDEO: ${{ steps.filter.outputs.record_video }} # App-host crash recovery must not stall on interactive backtraces # (mirrors ci.yml's tests job hardening). - SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no" + SWIFT_BACKTRACE: ${{ inputs.symbolicate_crashes && 'interactive=no,timeout=0s,symbolicate=full,color=no' || 'interactive=no,timeout=0s,symbolicate=off,color=no' }} CMUX_XCODEBUILD_NONINTERACTIVE_POST_TEST_TIMEOUT_SECONDS: "45" run: | set -euo pipefail From 71a311cafe4d64a6bbcd78aa5664d7957f78ff0f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 18:48:59 -0700 Subject: [PATCH 58/76] fix: retain committed history events during launch --- Sources/VaultHistoryEventLog.swift | 22 +++++++++++++++++++--- cmuxTests/VaultHistoryEventLogTests.swift | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index e512f72b5b73..649f34a6b266 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -19,6 +19,9 @@ final class VaultHistoryEventLog { private var pendingRecordCount = 0 /// Uncommitted window operations cannot publish an open or any child event. private var pendingWindowEvents: [UUID: [VaultHistoryEvent]] = [:] + /// A retained window can finish construction before startup recording is active. + /// Keep its committed events until the lifecycle gate opens instead of dropping them. + private var pendingLaunchCommittedEvents: [VaultHistoryEvent] = [] /// Whether accepted records are still queued or being persisted. var hasPendingRecords: Bool { @@ -35,6 +38,12 @@ final class VaultHistoryEventLog { func transition(to phase: VaultHistoryRecordingPhase) { self.phase = phase + guard phase == .active, !pendingLaunchCommittedEvents.isEmpty else { return } + let events = pendingLaunchCommittedEvents + pendingLaunchCommittedEvents.removeAll(keepingCapacity: true) + for event in events { + record(event) + } } func record(_ event: VaultHistoryEvent) { @@ -76,14 +85,21 @@ final class VaultHistoryEventLog { /// before the readiness callback completed. func commitWindowCreation(windowId: UUID, excludingBootstrapWorkspaceId: UUID? = nil) { guard let events = pendingWindowEvents.removeValue(forKey: windowId) else { return } - for event in events { + let retainedEvents = events.filter { event in if let excludingBootstrapWorkspaceId, (event.kind == .windowOpened || (event.kind == .workspaceCreated && event.subject.workspaceId == excludingBootstrapWorkspaceId)) { - continue + return false + } + return true + } + if phase == .launching { + pendingLaunchCommittedEvents.append(contentsOf: retainedEvents) + } else { + for event in retainedEvents { + record(event) } - record(event) } } diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index ee766a6155c2..2f777eb42533 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -45,6 +45,22 @@ import Testing #expect(await log.recentEvents().map(\.id) == ["launch"]) } + @Test func committedWindowEventsRemainStagedUntilLaunchCompletes() async { + let store = VaultHistoryEventStore(fileURL: nil) + let log = VaultHistoryEventLog(store: store, phase: .launching) + let windowId = UUID() + + log.beginWindowCreation(windowId: windowId) + log.record(event(id: "launch", windowId: windowId)) + log.commitWindowCreation(windowId: windowId) + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + + log.transition(to: .active) + await log.flushPendingRecords() + #expect(await log.recentEvents().map(\.id) == ["launch"]) + } + @Test func startupRestoreDiscardsInitialWindowHistoryAfterRestoreCompletes() async throws { try await withWindowHistory(initialPhase: .launching) { app, log in let restoredWindowSnapshot = SessionWindowSnapshot( From 128654482d6dc8dce6bf18a9b45c5248fdf71a4d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:24:21 -0700 Subject: [PATCH 59/76] test: cover discarding a committed launch-window transaction --- cmuxTests/VaultHistoryEventLogTests.swift | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 2f777eb42533..efe5648cc343 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -9,6 +9,40 @@ import Testing @testable import cmux #endif +@MainActor +@Suite struct VaultHistoryLaunchTransactionTests { + @Test(arguments: [false, true]) + func discardedLaunchWindowDoesNotPublishEvents(discardAfterCommit: Bool) async { + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil)) + let discardedWindowId = UUID() + let retainedWindowId = UUID() + for (id, windowId) in [("discarded", discardedWindowId), ("retained", retainedWindowId)] { + log.beginWindowCreation(windowId: windowId) + log.record(VaultHistoryEvent( + id: id, + timestamp: Date(timeIntervalSince1970: 1_800_000_000), + kind: .windowOpened, + title: id, + subject: VaultHistorySubject(windowId: windowId) + )) + } + if !discardAfterCommit { + log.discardWindowCreation(windowId: discardedWindowId) + } + log.commitWindowCreation(windowId: discardedWindowId) + log.commitWindowCreation(windowId: retainedWindowId) + log.transition(to: .restoring) + if discardAfterCommit { + log.discardWindowCreation(windowId: discardedWindowId) + } + log.transition(to: .active) + await log.flushPendingRecords() + + #expect(await log.recentEvents().map(\.id) == ["retained"]) + #expect(log.revision == 1) + } +} + @MainActor @Suite(.serialized) struct VaultHistoryEventLogTests { @Test func lifecyclePhasesOnlyAcceptActiveEvents() async { From 557ee6792f19279611be032a63f9607b1bcb1ffe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:37:08 -0700 Subject: [PATCH 60/76] fix: retract discarded launch events and await the relay scheduling signal --- .../CmxIrohRelayCredentialCoordinatorTests.swift | 3 ++- Sources/VaultHistoryEventLog.swift | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift index c258d8206c01..7ab1825b0c18 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift @@ -133,6 +133,7 @@ struct CmxIrohRelayCredentialCoordinatorTests { let endpoint = TestIrohEndpoint(identity: fixture.identity) let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) let clock = TestRelayClock(now: fixture.now) + var clockEvents = clock.events().makeAsyncIterator() let persistence = TestRelayCredentialPersistenceGate() let coordinator = CmxIrohRelayCredentialCoordinator( supervisor: supervisor, @@ -154,7 +155,7 @@ struct CmxIrohRelayCredentialCoordinatorTests { ) } await persistence.waitUntilStarted() - for _ in 0 ..< 20 { await Task.yield() } + #expect(await clockEvents.next() != nil, "Expected refresh scheduling while persistence is blocked") #expect(clock.observedSleepDeadlines() == [fixture.refreshAfter]) #expect(await endpoint.observedRelayUpdates().count == 1) diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index 649f34a6b266..adb053fb427b 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -106,6 +106,7 @@ final class VaultHistoryEventLog { /// Failed bootstrap operations never enter the append-only store. func discardWindowCreation(windowId: UUID) { pendingWindowEvents.removeValue(forKey: windowId) + pendingLaunchCommittedEvents.removeAll { $0.subject.windowId == windowId } } func recentEvents(limit: Int = Int.max) async -> [VaultHistoryEvent] { From 41c9875d269a5f25ec3133fc172a6395dccc47a6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:45:04 -0700 Subject: [PATCH 61/76] test: reproduce background destruction of the agent chat service --- ...TranscriptResolutionCoordinatorTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/AgentChatFallbackTranscriptResolutionCoordinatorTests.swift b/cmuxTests/AgentChatFallbackTranscriptResolutionCoordinatorTests.swift index 0653a95ee222..ffa024f86473 100644 --- a/cmuxTests/AgentChatFallbackTranscriptResolutionCoordinatorTests.swift +++ b/cmuxTests/AgentChatFallbackTranscriptResolutionCoordinatorTests.swift @@ -8,6 +8,27 @@ import Testing @testable import cmux #endif +@MainActor +struct AgentChatTranscriptServiceLifetimeTests { + @Test func finalBackgroundReleaseDoesNotAssumeMainActor() async { + weak var releasedService: AgentChatTranscriptService? + let retainedService = { + let service = AgentChatTranscriptService( + registry: AgentChatSessionRegistry(), + hasEventSubscribers: { false }, + emitEventPayload: { _ in } + ) + releasedService = service + // Deliberately transfer the sole retain to a background executor, + // as the snapshot writer does when its AppDelegate capture expires. + return Unmanaged.passRetained(service) + }() + + await Task.detached { retainedService.release() }.value + #expect(releasedService == nil) + } +} + struct AgentChatFallbackTranscriptResolutionCoordinatorTests { @MainActor @Test func authoritativeTranscriptBindingCancelsFallbackResolution() async throws { From 3633cc66867a67f96c8efdc4198847aafa707840 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:49:48 -0700 Subject: [PATCH 62/76] fix: transfer agent chat teardown to the main actor --- .../AgentChat/AgentChatTranscriptService.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 03a5b5638ca5..4ea813790d23 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -704,12 +704,14 @@ final class AgentChatTranscriptService { } deinit { - // This app-owned service is created and released on the main actor. - // `isolated deinit` still has Xcode compatibility constraints in cmux, - // so keep teardown synchronous while asserting that owner invariant. - MainActor.assumeIsolated { - proseWakeDriver?.stop() - proseStreamer?.stopAll() + // A background snapshot write can release the last AppDelegate retain. + // Transfer only its cleanup resources to the main actor; never capture + // the service being destroyed or assume deinit inherits its isolation. + let wakeDriver = proseWakeDriver + let streamer = proseStreamer + Task { @MainActor in + wakeDriver?.stop() + streamer?.stopAll() } } } From 28184f3e49a4bd6b79933092097e8aebc49a19ab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:50:30 -0700 Subject: [PATCH 63/76] test: require sidebar text accessibility leaves to stay acyclic --- cmuxTests/SidebarAppKitRowCellTests.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 278ad397ca59..2e8b88b996e9 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -4,6 +4,27 @@ import SwiftUI import Testing @testable import cmux_DEV +@MainActor +struct SidebarRowTextAccessibilityTreeTests { + @Test(arguments: [1, 2]) + func plainTextExposesItsValueWithoutRecursiveChildren(lines: Int) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 100), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + defer { window.close() } + let label = SidebarRowTextView(lines: lines) + label.frame = NSRect(x: 0, y: 0, width: 280, height: 80) + label.configurePlainText("Workspace", font: .systemFont(ofSize: 12), color: .labelColor) + window.contentView?.addSubview(label) + + #expect(label.accessibilityValue() as? String == "Workspace") + #expect((label.accessibilityChildren() ?? []).isEmpty) + } +} + /// Behavior tests for the pure-AppKit workspace row cell: hover enforcement /// (authoritative sweep) and optimistic selection paint semantics. @Suite(.serialized) From bdbd7ea6a1a43ef7f8272ae28c7399650ad66ede Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:50:54 -0700 Subject: [PATCH 64/76] fix: keep sidebar text accessibility children free of cell aliases --- .../Cells/SidebarWorkspaceRowSlotViews.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 64341602102b..7966106c0d37 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -319,13 +319,10 @@ final class SidebarRowTextView: NSTextField { accessibilityLinksAreStale = true } materializeAccessibilityLinks() - var children = super.accessibilityChildren() ?? [] - for link in accessibilityLinks where !children.contains(where: { - ($0 as? SidebarRowTextAccessibilityLink) === link - }) { - children.append(link) - } - return children + // This read-only field exposes its text value directly. Forwarding + // NSTextField's cell children can alias the field itself and create an + // accessibility cycle. Only the row-owned link proxies are children. + return accessibilityLinks } /// Applies the row palette to rendered Markdown while retaining ownership From 517ef00a1d1b849179cd1d0761c9dd04162ff175 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 19:53:45 -0700 Subject: [PATCH 65/76] test: retain ownership of the accessibility fixture window --- cmuxTests/SidebarAppKitRowCellTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 2e8b88b996e9..7e881fc52cfb 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -14,6 +14,7 @@ struct SidebarRowTextAccessibilityTreeTests { backing: .buffered, defer: false ) + window.isReleasedWhenClosed = false defer { window.close() } let label = SidebarRowTextView(lines: lines) label.frame = NSRect(x: 0, y: 0, width: 280, height: 80) From 7f823ca16cf0f2eb0a7118900e9f820b2fa5cd9a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 20:24:15 -0700 Subject: [PATCH 66/76] test: query Vault grouping by its cross-version identifier --- cmuxUITests/RightSidebarChromeHeightUITests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 5cd38373b5f7..567132a6f275 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -39,9 +39,9 @@ final class RightSidebarChromeHeightUITests: XCTestCase { addKeptScreenshot(app.windows.firstMatch.screenshot(), name: "vault-sessions-before") history.click() - // Query the stable identifier, not a localized label or the Menu's - // platform-dependent title/description mapping. - let picker = app.menuButtons["VaultHistoryGroupPicker"].firstMatch + // SwiftUI exposes this Menu as a PopUpButton on macOS 15 and a + // MenuButton on newer systems. Its stable identifier is the contract. + let picker = app.descendants(matching: .any)["VaultHistoryGroupPicker"].firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() From 273cf171d66727b8f7b2a907de66a12ca5e78e39 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 20:55:20 -0700 Subject: [PATCH 67/76] test: match visible Vault menus and row-owned theme semantics --- cmuxTests/SidebarAppKitRowCellTests.swift | 23 ++++++++++++++----- .../RightSidebarChromeHeightUITests.swift | 18 +++++++++++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 7e881fc52cfb..5b9c31140e5b 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -88,7 +88,8 @@ struct SidebarAppKitRowCellTests { metadataEntries: [SidebarStatusEntry] = [], metadataBlocks: [SidebarMetadataBlock] = [], shortcutHintText: String? = nil, - isMarkdownExpanded: Bool = false + isMarkdownExpanded: Bool = false, + colorSchemeIsDark: Bool = true ) -> SidebarWorkspaceRowModel { let resolvedSettings = settings ?? SidebarTabItemSettingsSnapshot(defaults: UserDefaults(suiteName: UUID().uuidString)!) @@ -118,7 +119,7 @@ struct SidebarAppKitRowCellTests { isFirstRow: true, shortcutHintText: shortcutHintText, showsShortcutHints: shortcutHintText != nil, - colorSchemeIsDark: true, + colorSchemeIsDark: colorSchemeIsDark, globalFontMagnificationPercent: 100, isChecklistExpanded: false, checklistAddFieldActivationToken: 0, @@ -766,8 +767,10 @@ struct SidebarAppKitRowCellTests { #expect(Self.distance(proseGlyph, linkGlyph) > 0.15) } - @Test - func rowPaletteSemanticColorsRemainDynamicAcrossAppearances() throws { + @Test(arguments: [false, true]) + func rowPaletteSemanticColorsFollowRowSchemeAcrossAmbientAppearances( + colorSchemeIsDark: Bool + ) throws { let lightAppearance = try #require(NSAppearance(named: .aqua)) let darkAppearance = try #require(NSAppearance(named: .darkAqua)) let semanticColor = NSColor(name: nil) { appearance in @@ -775,7 +778,12 @@ struct SidebarAppKitRowCellTests { ? .white : .black } - let palette = SidebarRowPalette(model: Self.makeModel()) + let palette = SidebarRowPalette( + model: Self.makeModel(colorSchemeIsDark: colorSchemeIsDark) + ) + let expected = try #require( + (colorSchemeIsDark ? NSColor.white : NSColor.black).usingColorSpace(.sRGB) + ) let colors = [ (palette.semantic(semanticColor), CGFloat(1)), (palette.semantic(semanticColor, opacity: 0.6), CGFloat(0.6)), @@ -785,7 +793,10 @@ struct SidebarAppKitRowCellTests { let light = try Self.resolvedColor(color, in: lightAppearance) let dark = try Self.resolvedColor(color, in: darkAppearance) - #expect(Self.distance(light, dark) > 1) + // A window's ambient appearance may differ from the cmux theme. + // The row model remains authoritative for both color and opacity. + #expect(Self.distance(light, expected) < 0.001) + #expect(Self.distance(dark, expected) < 0.001) #expect(abs(light.alphaComponent - expectedAlpha) < 0.001) #expect(abs(dark.alphaComponent - expectedAlpha) < 0.001) } diff --git a/cmuxUITests/RightSidebarChromeHeightUITests.swift b/cmuxUITests/RightSidebarChromeHeightUITests.swift index 567132a6f275..37fcaef6d354 100644 --- a/cmuxUITests/RightSidebarChromeHeightUITests.swift +++ b/cmuxUITests/RightSidebarChromeHeightUITests.swift @@ -45,8 +45,22 @@ final class RightSidebarChromeHeightUITests: XCTestCase { XCTAssertTrue(picker.waitForExistence(timeout: 10), app.debugDescription) for grouping in ["Workspace", "Window", "Agent", "Type", "Date"] { picker.click() - let item = app.menuItems[grouping].firstMatch - XCTAssertTrue(item.waitForExistence(timeout: 5), "Missing History grouping: \(grouping)") + // The app's File menu also contains a hidden "Workspace" item. + // Choose from the open popup, never let XCTest traverse a hidden + // menu-bar item that happens to have the same title. + let options = app.menuItems.matching(identifier: grouping) + let visibleOption = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + options.allElementsBoundByIndex.contains { $0.isHittable } + }, + object: nil + ) + XCTAssertEqual( + XCTWaiter.wait(for: [visibleOption], timeout: 5), + .completed, + "Missing visible History grouping: \(grouping)" + ) + let item = try XCTUnwrap(options.allElementsBoundByIndex.first { $0.isHittable }) item.click() let selected = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in From dd84412cab7fbb7f832241d7c71254a98d7274aa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 21:25:38 -0700 Subject: [PATCH 68/76] ci: provision Node before app-host hook tests --- .github/workflows/ci.yml | 50 +++++++++++++++----------------- .github/workflows/test-depot.yml | 6 ++++ .github/workflows/test-e2e.yml | 6 ++++ 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2de59849adf3..65da0a2d5fce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1151,6 +1151,30 @@ jobs: -only-testing:cmuxTests/GhosttyTerminalViewVisibilityPolicyTests \ test + - name: Ensure node for hook and wrapper regressions + id: detect-node + run: | + set -euo pipefail + # App-host hook tests and the CLI wrapper regressions both execute + # Node. Prepare it before unit tests on every shard; runner images + # are not required to ship a Node runtime. + for candidate in "$(command -v node || true)" /opt/homebrew/bin/node /usr/local/bin/node; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + dirname "$candidate" >> "$GITHUB_PATH" + "$candidate" --version + echo "found=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "no node runtime in the runner image; installing via setup-node" + echo "found=false" >> "$GITHUB_OUTPUT" + + - name: Install node (runner image has none) + if: ${{ steps.detect-node.outputs.found == 'false' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + - name: Run unit tests run: | set -euo pipefail @@ -1295,32 +1319,6 @@ jobs: CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh - - name: Ensure node for wrapper regressions - id: detect-node - if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} - run: | - set -euo pipefail - # The claude wrapper regressions below exec `node` from fake claude - # binaries. The tart-macos-15 runner image shipped without node on - # the runner PATH (2026-07-10), which failed every branch. Reuse an - # image-provided node when present; otherwise install one below. - for candidate in "$(command -v node || true)" /opt/homebrew/bin/node /usr/local/bin/node; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then - dirname "$candidate" >> "$GITHUB_PATH" - "$candidate" --version - echo "found=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - done - echo "no node runtime in the runner image; installing via setup-node" - echo "found=false" >> "$GITHUB_OUTPUT" - - - name: Install node (runner image has none) - if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) && steps.detect-node.outputs.found == 'false' }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "20" - - name: Set up Bun for Pi extension dispatch regression if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index dd7ecbb7c163..34ae52ee0f13 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -81,6 +81,12 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Set up Node for hook integration tests + if: ${{ !inputs.skip_unit_tests }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + - name: Download pre-built GhosttyKit.xcframework env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9a82810d6f99..bc97a66626a0 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -175,6 +175,12 @@ jobs: echo "Resolved target: $test_target" echo "Resolved selector: $test_selector" + - name: Set up Node for hook integration tests + if: ${{ steps.filter.outputs.target == 'cmuxTests' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + - name: Select Xcode run: | set -euo pipefail From cb120814f1ff4f388691ad4b8e07bfc3a9d55da6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 22:02:34 -0700 Subject: [PATCH 69/76] ci: forward installed runtimes into XCTest app hosts --- .github/workflows/test-depot.yml | 1 + scripts/ci/run-app-host-xcodebuild.sh | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 34ae52ee0f13..9718c828d97e 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -150,6 +150,7 @@ jobs: set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" run_unit_suite() { + TEST_RUNNER_PATH="${TEST_RUNNER_PATH-$PATH:__CURRENT_VALUE__}" \ scripts/ci/xcodebuild_noninteractive.py \ xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ diff --git a/scripts/ci/run-app-host-xcodebuild.sh b/scripts/ci/run-app-host-xcodebuild.sh index 2e290694b8d3..2073dc5ba39b 100755 --- a/scripts/ci/run-app-host-xcodebuild.sh +++ b/scripts/ci/run-app-host-xcodebuild.sh @@ -48,7 +48,13 @@ fi # then pass it through Xcode's TEST_RUNNER_ environment channel. Xcode strips # that prefix when it launches the test runner, so the app host receives the # redirects without exposing them to the xcodebuild driver. -app_host_test_runner_environment=("TEST_RUNNER_CMUX_TEST_PROCESS=1") +app_host_test_runner_environment=( + "TEST_RUNNER_CMUX_TEST_PROCESS=1" + # Installing Node on the runner does not put it on the app-host's PATH. + # Use Xcode's documented test-runner channel after the console-user hop, + # retaining Xcode's own tool paths via its current-value substitution. + "TEST_RUNNER_PATH=${TEST_RUNNER_PATH-$PATH:__CURRENT_VALUE__}" +) app_host_home="" app_host_key="" app_host_receipt_dir="" From e514a4cbd8e5149663b9871be086b969143d8e0e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 22:33:43 -0700 Subject: [PATCH 70/76] test: reproduce losing committed launch History on quit --- cmuxTests/VaultHistoryEventLogTests.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index efe5648cc343..8da8650af3c3 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -95,6 +95,29 @@ import Testing #expect(await log.recentEvents().map(\.id) == ["launch"]) } + @Test(arguments: [VaultHistoryRecordingPhase.launching, .restoring]) + func terminatingFlushRetainsCommittedLaunchEvents(phaseBeforeQuit: VaultHistoryRecordingPhase) async { + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil)) + let retainedWindowId = UUID() + let uncommittedWindowId = UUID() + log.beginWindowCreation(windowId: retainedWindowId) + log.record(event(id: "retained", windowId: retainedWindowId)) + log.commitWindowCreation(windowId: retainedWindowId) + log.beginWindowCreation(windowId: uncommittedWindowId) + log.record(event(id: "uncommitted", windowId: uncommittedWindowId)) + log.transition(to: phaseBeforeQuit) + + #expect(log.hasPendingRecords) + log.transition(to: .terminating) + log.record(event(id: "shutdown")) + await log.flushPendingRecords() + + #expect(await log.recentEvents().map(\.id) == ["retained"]) + #expect(log.revision == 1) + #expect(!log.hasPendingRecords) + #expect(log.phase == .terminating) + } + @Test func startupRestoreDiscardsInitialWindowHistoryAfterRestoreCompletes() async throws { try await withWindowHistory(initialPhase: .launching) { app, log in let restoredWindowSnapshot = SessionWindowSnapshot( From 1c254dd27e0709981c78038ee2b64836116eb1d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 22:39:07 -0700 Subject: [PATCH 71/76] fix: drain accepted History on quit and bound hook test socket paths --- Sources/VaultHistoryEventLog.swift | 18 +++-- cmuxTests/OpenCodeHookRegressionTests.swift | 78 +++++++++++---------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index adb053fb427b..beeea554c2cf 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -25,7 +25,7 @@ final class VaultHistoryEventLog { /// Whether accepted records are still queued or being persisted. var hasPendingRecords: Bool { - pendingRecordCount > 0 + pendingRecordCount > 0 || !pendingLaunchCommittedEvents.isEmpty } init( @@ -38,11 +38,13 @@ final class VaultHistoryEventLog { func transition(to phase: VaultHistoryRecordingPhase) { self.phase = phase - guard phase == .active, !pendingLaunchCommittedEvents.isEmpty else { return } + guard phase == .active || phase == .terminating else { return } let events = pendingLaunchCommittedEvents pendingLaunchCommittedEvents.removeAll(keepingCapacity: true) for event in events { - record(event) + // These snapshots were accepted when their windows committed. + // Termination closes the gate to new events, not to this queue. + enqueueAcceptedRecord(event) } } @@ -52,6 +54,10 @@ final class VaultHistoryEventLog { return } guard phase == .active else { return } + enqueueAcceptedRecord(event) + } + + private func enqueueAcceptedRecord(_ event: VaultHistoryEvent) { let store = store let previous = pendingRecordTask pendingRecordCount += 1 @@ -113,7 +119,11 @@ final class VaultHistoryEventLog { await store.recentEvents(limit: limit) } + /// Drains all scheduled appends, including ones accepted during suspension. + /// Launch commits stay staged until recording becomes active or terminates. func flushPendingRecords() async { - await pendingRecordTask?.value + while let task = pendingRecordTask { + await task.value + } } } diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 6f3f755284d6..69490fdef8b4 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -1,7 +1,8 @@ -import XCTest import Darwin +import Foundation +import Testing -final class OpenCodeHookRegressionTests: XCTestCase { +@Suite final class OpenCodeHookRegressionTests { private struct ProcessRunResult { let status: Int32 let stdout: String @@ -9,21 +10,24 @@ final class OpenCodeHookRegressionTests: XCTestCase { let timedOut: Bool } - func testOpenCodeFeedPluginEmitsCompletionForBothIdleEventShapes() throws { + @Test func testOpenCodeFeedPluginEmitsCompletionForBothIdleEventShapes() throws { let fileManager = FileManager.default let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() let pluginURL = repoRoot.appendingPathComponent("Resources/opencode-plugin.js", isDirectory: false) - XCTAssertTrue(fileManager.fileExists(atPath: pluginURL.path)) + #expect(fileManager.fileExists(atPath: pluginURL.path)) - let root = fileManager.temporaryDirectory.appendingPathComponent( - "cmux-opencode-feed-\(UUID().uuidString)", isDirectory: true + // Darwin's Unix-domain socket path must fit in sun_path. The runner's + // /var/folders temporary root plus the old fixture name exceeded it. + let root = URL(fileURLWithPath: "/tmp", isDirectory: true).appendingPathComponent( + "cmux-oc-\(UUID().uuidString)", isDirectory: true ) - try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) defer { try? fileManager.removeItem(at: root) } let socketPath = root.appendingPathComponent("cmux.sock").path + try #require(socketPath.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path)) let harnessURL = root.appendingPathComponent("harness.js") try Self.openCodeFeedEventHarness.write(to: harnessURL, atomically: true, encoding: .utf8) @@ -33,11 +37,11 @@ final class OpenCodeHookRegressionTests: XCTestCase { environment: ProcessInfo.processInfo.environment, timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + #expect(!result.timedOut, "\(result.stderr)") + #expect(result.status == 0, "\(result.stderr)") - let data = try XCTUnwrap(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)) - let frames = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + let data = try #require(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8)) + let frames = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) let stopEvents = frames.compactMap { frame -> [String: Any]? in guard frame["method"] as? String == "feed.push", let params = frame["params"] as? [String: Any], @@ -45,13 +49,13 @@ final class OpenCodeHookRegressionTests: XCTestCase { event["hook_event_name"] as? String == "Stop" else { return nil } return event } - XCTAssertEqual(stopEvents.count, 3, "Expected all OpenCode idle forms to emit Stop: \(frames)") - XCTAssertTrue(stopEvents.allSatisfy { $0["session_id"] as? String == "opencode-ses-feed-shape" }) + #expect(stopEvents.count == 3, "Expected all OpenCode idle forms to emit Stop: \(frames)") + #expect(stopEvents.allSatisfy { $0["session_id"] as? String == "opencode-ses-feed-shape" }) let requestIDs = frames.compactMap { $0["id"] as? String } - XCTAssertEqual(requestIDs.count, Set(requestIDs).count, "OpenCode telemetry request IDs must not collide: \(frames)") + #expect(requestIDs.count == Set(requestIDs).count, "OpenCode telemetry request IDs must not collide: \(frames)") } - func testOpenCodeInstallHooksIsIdempotentForLegacySetupAlias() throws { + @Test func testOpenCodeInstallHooksIsIdempotentForLegacySetupAlias() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-opencode-hooks-\(UUID().uuidString)", isDirectory: true) let configDir = root.appendingPathComponent("opencode", isDirectory: true) @@ -72,39 +76,41 @@ final class OpenCodeHookRegressionTests: XCTestCase { environment["CMUX_CLI_SENTRY_DISABLED"] = "1" let result = runProcess(executablePath: cliPath, arguments: ["hooks", "opencode", "install", "--yes"], environment: environment, timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + #expect(!result.timedOut, "\(result.stderr)") + #expect(result.status == 0, "\(result.stderr)") let pluginURL = configDir.appendingPathComponent("plugins", isDirectory: true).appendingPathComponent("cmux-session.js", isDirectory: false) let pluginSource = try String(contentsOf: pluginURL, encoding: .utf8) - XCTAssertTrue(pluginSource.contains("cmux-opencode-session-plugin-marker")) - XCTAssertTrue(pluginSource.contains("\"hooks\", \"opencode\"")) + #expect(pluginSource.contains("cmux-opencode-session-plugin-marker")) + #expect(pluginSource.contains("\"hooks\", \"opencode\"")) let secondResult = runProcess(executablePath: cliPath, arguments: ["setup-hooks", "--agent", "opencode"], environment: environment, timeout: 5) - XCTAssertFalse(secondResult.timedOut, secondResult.stderr) - XCTAssertEqual(secondResult.status, 0, secondResult.stderr) - XCTAssertFalse(secondResult.stdout.contains("Will write OpenCode cmux plugin"), secondResult.stdout) - XCTAssertTrue(secondResult.stdout.contains("OpenCode hooks already up to date"), secondResult.stdout) - XCTAssertTrue(try String(contentsOf: configDir.appendingPathComponent("plugins/cmux-feed.js"), encoding: .utf8).contains("cmux-feed-plugin-marker")) - - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: try Data(contentsOf: configURL), options: []) as? [String: Any]) - XCTAssertEqual(try XCTUnwrap(json["plugin"] as? [String]), ["other-plugin", "./plugins/cmux-session.js"]) + #expect(!secondResult.timedOut, "\(secondResult.stderr)") + #expect(secondResult.status == 0, "\(secondResult.stderr)") + #expect(!secondResult.stdout.contains("Will write OpenCode cmux plugin"), "\(secondResult.stdout)") + #expect(secondResult.stdout.contains("OpenCode hooks already up to date"), "\(secondResult.stdout)") + let feedPluginSource = try String(contentsOf: configDir.appendingPathComponent("plugins/cmux-feed.js"), encoding: .utf8) + #expect(feedPluginSource.contains("cmux-feed-plugin-marker")) + + let json = try #require(JSONSerialization.jsonObject(with: try Data(contentsOf: configURL), options: []) as? [String: Any]) + let plugins = try #require(json["plugin"] as? [String]) + #expect(plugins == ["other-plugin", "./plugins/cmux-session.js"]) } - func testLegacyHookAliasesAreHiddenFromHelp() throws { + @Test func testLegacyHookAliasesAreHiddenFromHelp() throws { let cliPath = try bundledCLIPath() var environment = ProcessInfo.processInfo.environment environment["CMUX_CLI_SENTRY_DISABLED"] = "1" let result = runProcess(executablePath: cliPath, arguments: ["help"], environment: environment, timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse(result.stdout.contains("codex "), result.stdout) - XCTAssertFalse(result.stdout.contains("claude-hook "), result.stdout) - XCTAssertFalse(result.stdout.contains("codex-hook"), result.stdout) - XCTAssertFalse(result.stdout.contains("feed-hook"), result.stdout) - XCTAssertFalse(result.stdout.contains("setup-hooks"), result.stdout) - XCTAssertFalse(result.stdout.contains("uninstall-hooks"), result.stdout) + #expect(!result.timedOut, "\(result.stderr)") + #expect(result.status == 0, "\(result.stderr)") + #expect(!result.stdout.contains("codex "), "\(result.stdout)") + #expect(!result.stdout.contains("claude-hook "), "\(result.stdout)") + #expect(!result.stdout.contains("codex-hook"), "\(result.stdout)") + #expect(!result.stdout.contains("feed-hook"), "\(result.stdout)") + #expect(!result.stdout.contains("setup-hooks"), "\(result.stdout)") + #expect(!result.stdout.contains("uninstall-hooks"), "\(result.stdout)") } private func bundledCLIPath() throws -> String { From 1198ba65bfe2670bc62919177c7715971a1f60a4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 8 Sep 2026 23:33:05 -0700 Subject: [PATCH 72/76] ci: share the selected-test execution guard with Depot --- .github/workflows/test-depot.yml | 17 +++++++++-------- tests/test_ios_selected_test_execution.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 9718c828d97e..c72810c138ed 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -172,14 +172,15 @@ jobs: suite_output=$(run_unit_suite "-only-testing:cmuxTests/$suite") || suite_status=$? printf '%s\n' "$suite_output" if [ "$suite_status" -ne 0 ]; then return "$suite_status"; fi - # A pipeline here makes `set -o pipefail` treat grep's normal - # early exit as a printf SIGPIPE when the xcodebuild log is - # large. Feed grep with a here-string so a passing suite cannot - # be reported as empty because of that logging artifact. - if ! grep -Eq 'Test run with [1-9][0-9]* tests|Executed [1-9][0-9]* tests' <<<"$suite_output"; then - echo "No tests executed for $suite" >&2 - return 1 - fi + # Share the E2E lane's execution guard, including singular + # Swift Testing summaries and the app-activation failure check. + local suite_log execution_status=0 + suite_log=$(mktemp "${RUNNER_TEMP:-/tmp}/cmux-unit-suite.XXXXXX") + printf '%s\n' "$suite_output" > "$suite_log" + scripts/ci/require_selected_test_execution.sh "$suite_log" "cmuxTests/$suite" \ + || execution_status=$? + rm -f -- "$suite_log" + if [ "$execution_status" -ne 0 ]; then return "$execution_status"; fi done } diff --git a/tests/test_ios_selected_test_execution.py b/tests/test_ios_selected_test_execution.py index 0b529b9aa89e..c0c99d8a1a37 100644 --- a/tests/test_ios_selected_test_execution.py +++ b/tests/test_ios_selected_test_execution.py @@ -32,12 +32,15 @@ def test_accepts_xctest_singular_and_plural_nonzero_counts(self) -> None: result = self.run_guard(summary, "cmuxUITests/cmuxUITests/testExample") self.assertEqual(result.returncode, 0, result.stderr) - def test_accepts_swift_testing_nonzero_count(self) -> None: - result = self.run_guard( - "Test run with 2 tests passed after 0.012 seconds.", - "cmuxFeatureTests/ExampleSuite", - ) - self.assertEqual(result.returncode, 0, result.stderr) + def test_accepts_swift_testing_singular_and_plural_nonzero_counts(self) -> None: + for count, summary in ( + (1, "Test run with 1 test in 1 suite passed after 0.012 seconds."), + (2, "Test run with 2 tests passed after 0.012 seconds."), + ): + with self.subTest(summary=summary): + result = self.run_guard(summary, "cmuxFeatureTests/ExampleSuite") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(count)) def test_rejects_zero_tests_for_requested_filter(self) -> None: test_filter = "cmuxUITests/testMissingMethod\n::error::injected" From 52ab6f3ec3c6d3cdc9aeb1dbaf1f546e6838810c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 00:15:53 -0700 Subject: [PATCH 73/76] test: preserve OpenCode execution after the Bun runtime merge --- .github/workflows/ci.yml | 6 +++--- cmuxTests/OpenCodeHookRegressionTests.swift | 15 +++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f87ed07ad1a4..85ac3c697e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1193,6 +1193,9 @@ jobs: with: node-version: "20" + - name: Set up Bun for app-host and Pi extension regressions + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Run unit tests run: | set -euo pipefail @@ -1356,9 +1359,6 @@ jobs: CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh - - name: Set up Bun for Pi extension dispatch regression - if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.6" diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index b412ecb53a1a..3286835c0982 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -129,10 +129,17 @@ import Testing "/opt/homebrew/bin/bun", "/usr/local/bin/bun", ] - if let path = candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) { - return URL(fileURLWithPath: path) - } - throw XCTSkip("Bun runtime is required for the OpenCode plugin harness") + // CI isolates HOME but forwards the installed tool PATH into the host. + // Do not require the Bun installation to live inside the fixture home. + candidates += (ProcessInfo.processInfo.environment["PATH"] ?? "") + .split(separator: ":") + .filter { $0.hasPrefix("/") } + .map { URL(fileURLWithPath: String($0)).appendingPathComponent("bun").path } + let path = try #require( + candidates.first(where: { fileManager.isExecutableFile(atPath: $0) }), + "Bun runtime is required for the OpenCode plugin harness" + ) + return URL(fileURLWithPath: path) } private static let openCodeFeedEventHarness = #""" From 5ccec50cb7b23802fd8768accaad0a2ddc78176e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 00:26:09 -0700 Subject: [PATCH 74/76] ci: move the pinned Bun version with its setup step --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ac3c697e21..c62124913031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1195,6 +1195,8 @@ jobs: - name: Set up Bun for app-host and Pi extension regressions uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.6" - name: Run unit tests run: | @@ -1359,9 +1361,6 @@ jobs: CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh - with: - bun-version: "1.3.6" - - name: Install pinned nushell (shell-integration tests) if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | From df065ab4877f5f9d6edae4e934bf14e60a8de5f5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 04:09:28 -0700 Subject: [PATCH 75/76] test: reproduce History commits crossing lifecycle phases --- cmuxTests/VaultHistoryEventLogTests.swift | 76 ++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/cmuxTests/VaultHistoryEventLogTests.swift b/cmuxTests/VaultHistoryEventLogTests.swift index 8da8650af3c3..4e04c20e4ad6 100644 --- a/cmuxTests/VaultHistoryEventLogTests.swift +++ b/cmuxTests/VaultHistoryEventLogTests.swift @@ -11,8 +11,11 @@ import Testing @MainActor @Suite struct VaultHistoryLaunchTransactionTests { - @Test(arguments: [false, true]) - func discardedLaunchWindowDoesNotPublishEvents(discardAfterCommit: Bool) async { + @Test(arguments: [false, true], [VaultHistoryRecordingPhase.launching, .restoring]) + func discardedLaunchWindowDoesNotPublishEvents( + discardAfterCommit: Bool, + phaseAtCommit: VaultHistoryRecordingPhase + ) async { let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil)) let discardedWindowId = UUID() let retainedWindowId = UUID() @@ -29,6 +32,7 @@ import Testing if !discardAfterCommit { log.discardWindowCreation(windowId: discardedWindowId) } + log.transition(to: phaseAtCommit) log.commitWindowCreation(windowId: discardedWindowId) log.commitWindowCreation(windowId: retainedWindowId) log.transition(to: .restoring) @@ -41,6 +45,74 @@ import Testing #expect(await log.recentEvents().map(\.id) == ["retained"]) #expect(log.revision == 1) } + + @Test( + arguments: [VaultHistoryRecordingPhase.launching, .active], + [VaultHistoryRecordingPhase.restoring, .terminating] + ) + func committedSnapshotsSurvivePhaseChanges( + initialPhase: VaultHistoryRecordingPhase, + phaseAtCommit: VaultHistoryRecordingPhase + ) async { + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: initialPhase) + let windowId = UUID() + let acceptedEvent = VaultHistoryEvent( + id: "accepted", + timestamp: Date(timeIntervalSince1970: 1_800_000_000), + kind: .windowOpened, + title: "Original snapshot", + subject: VaultHistorySubject(windowId: windowId) + ) + log.beginWindowCreation(windowId: windowId) + log.record(acceptedEvent) + log.transition(to: phaseAtCommit) + log.record(VaultHistoryEvent( + id: "suppressed", + timestamp: acceptedEvent.timestamp.addingTimeInterval(1), + kind: .windowClosed, + title: "Restore or shutdown must remain silent", + subject: acceptedEvent.subject + )) + log.commitWindowCreation(windowId: windowId) + log.commitWindowCreation(windowId: windowId) + + #expect(log.hasPendingRecords) + if phaseAtCommit == .restoring { + await log.flushPendingRecords() + #expect(await log.recentEvents().isEmpty) + #expect(log.revision == 0) + log.transition(to: .active) + } + await log.flushPendingRecords() + // Repeated lifecycle notifications must not replay an accepted batch. + log.transition(to: .terminating) + await log.flushPendingRecords() + + #expect(await log.recentEvents() == [acceptedEvent]) + #expect(log.revision == 1) + #expect(!log.hasPendingRecords) + } + + @Test(arguments: [VaultHistoryRecordingPhase.restoring, .terminating]) + func transactionsStartedDuringSuppressionDoNotPublishEvents(phase: VaultHistoryRecordingPhase) async { + let log = VaultHistoryEventLog(store: VaultHistoryEventStore(fileURL: nil), phase: phase) + let windowId = UUID() + log.beginWindowCreation(windowId: windowId) + log.record(VaultHistoryEvent( + id: "suppressed", + timestamp: Date(timeIntervalSince1970: 1_800_000_000), + kind: .windowOpened, + title: "Programmatic window", + subject: VaultHistorySubject(windowId: windowId) + )) + log.transition(to: .active) + log.commitWindowCreation(windowId: windowId) + await log.flushPendingRecords() + + #expect(await log.recentEvents().isEmpty) + #expect(log.revision == 0) + #expect(!log.hasPendingRecords) + } } @MainActor From 66a0ff3a9364a3b374961639c7112f10954d7107 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 04:10:01 -0700 Subject: [PATCH 76/76] fix: preserve accepted History across lifecycle phase changes --- Sources/VaultHistoryEventLog.swift | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index beeea554c2cf..a20c4e7de703 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -19,13 +19,13 @@ final class VaultHistoryEventLog { private var pendingRecordCount = 0 /// Uncommitted window operations cannot publish an open or any child event. private var pendingWindowEvents: [UUID: [VaultHistoryEvent]] = [:] - /// A retained window can finish construction before startup recording is active. - /// Keep its committed events until the lifecycle gate opens instead of dropping them. - private var pendingLaunchCommittedEvents: [VaultHistoryEvent] = [] + /// A retained window can commit while launch or restore suppresses publication. + /// Keep its previously captured snapshots until recording opens or termination drains them. + private var pendingCommittedEvents: [VaultHistoryEvent] = [] /// Whether accepted records are still queued or being persisted. var hasPendingRecords: Bool { - pendingRecordCount > 0 || !pendingLaunchCommittedEvents.isEmpty + pendingRecordCount > 0 || !pendingCommittedEvents.isEmpty } init( @@ -39,8 +39,8 @@ final class VaultHistoryEventLog { func transition(to phase: VaultHistoryRecordingPhase) { self.phase = phase guard phase == .active || phase == .terminating else { return } - let events = pendingLaunchCommittedEvents - pendingLaunchCommittedEvents.removeAll(keepingCapacity: true) + let events = pendingCommittedEvents + pendingCommittedEvents.removeAll(keepingCapacity: true) for event in events { // These snapshots were accepted when their windows committed. // Termination closes the gate to new events, not to this queue. @@ -49,6 +49,9 @@ final class VaultHistoryEventLog { } func record(_ event: VaultHistoryEvent) { + // Transactions may outlive the phase that began them. Suppress new + // restore/teardown events without discarding their earlier snapshots. + guard phase == .launching || phase == .active else { return } if let windowId = event.subject.windowId, pendingWindowEvents[windowId] != nil { pendingWindowEvents[windowId, default: []].append(event) return @@ -100,11 +103,12 @@ final class VaultHistoryEventLog { } return true } - if phase == .launching { - pendingLaunchCommittedEvents.append(contentsOf: retainedEvents) - } else { + switch phase { + case .launching, .restoring: + pendingCommittedEvents.append(contentsOf: retainedEvents) + case .active, .terminating: for event in retainedEvents { - record(event) + enqueueAcceptedRecord(event) } } } @@ -112,7 +116,7 @@ final class VaultHistoryEventLog { /// Failed bootstrap operations never enter the append-only store. func discardWindowCreation(windowId: UUID) { pendingWindowEvents.removeValue(forKey: windowId) - pendingLaunchCommittedEvents.removeAll { $0.subject.windowId == windowId } + pendingCommittedEvents.removeAll { $0.subject.windowId == windowId } } func recentEvents(limit: Int = Int.max) async -> [VaultHistoryEvent] { @@ -120,7 +124,7 @@ final class VaultHistoryEventLog { } /// Drains all scheduled appends, including ones accepted during suspension. - /// Launch commits stay staged until recording becomes active or terminates. + /// Launch/restore commits stay staged until recording becomes active or terminates. func flushPendingRecords() async { while let task = pendingRecordTask { await task.value