From f1ed7d82985d1f85284dfb34a6947cfee499bb9f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:14:37 -0700 Subject: [PATCH 01/10] Expand History grouping and restore controls --- CLI/CMUXCLI+ThemeSupport.swift | 4 +- CLI/cmux.swift | 10 +- Resources/Localizable.xcstrings | 345 ++++++++++++++++-- Sources/AppDelegate.swift | 15 +- Sources/ClosedItemHistory.swift | 5 + ...ntentView+RightSidebarCommandPalette.swift | 2 +- Sources/KeyboardShortcutSettings.swift | 2 +- Sources/RightSidebarMode+Availability.swift | 2 +- Sources/RightSidebarPanelView.swift | 13 +- Sources/RightSidebarRemoteCommand.swift | 2 +- Sources/RightSidebarToolPanel.swift | 4 + Sources/SessionIndexView.swift | 8 +- Sources/TabManager.swift | 22 +- Sources/VaultHistoryControls.swift | 152 ++++++++ Sources/VaultHistoryEvent.swift | 10 +- Sources/VaultHistoryEventRow.swift | 166 +++++++++ Sources/VaultHistoryGroupHeader.swift | 25 ++ Sources/VaultHistoryGrouping.swift | 128 +++++-- Sources/VaultHistoryLifecycleRecording.swift | 17 +- Sources/VaultHistoryQuery.swift | 149 ++++++++ Sources/VaultHistorySessionEvents.swift | 1 + Sources/VaultHistoryTimelineList.swift | 42 +++ Sources/VaultHistoryTimelineModel.swift | 64 +++- Sources/VaultHistoryView.swift | 309 +++++----------- Sources/VaultPaneView.swift | 21 +- cmux.xcodeproj/project.pbxproj | 20 + ...ifyProcessIntegrationRegressionTests.swift | 2 + ...ileExplorerStateModePersistenceTests.swift | 5 +- .../RightSidebarRemoteCommandTests.swift | 8 + cmuxTests/VaultHistoryEventStoreTests.swift | 3 +- cmuxTests/VaultHistoryGroupingTests.swift | 132 ++++++- docs/cli-contract.md | 4 +- 32 files changed, 1373 insertions(+), 319 deletions(-) create mode 100644 Sources/VaultHistoryControls.swift create mode 100644 Sources/VaultHistoryEventRow.swift create mode 100644 Sources/VaultHistoryGroupHeader.swift create mode 100644 Sources/VaultHistoryQuery.swift create mode 100644 Sources/VaultHistoryTimelineList.swift diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 2b12a5824492..2261f5100002 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -432,7 +432,7 @@ extension CMUXCLI { func isRightSidebarCLIMode(_ value: String) -> Bool { switch value.lowercased() { - case "files", "find", "vault", "sessions", "feed", "dock": + case "files", "find", "history", "sessions", "vault", "feed", "dock": return true default: return false @@ -441,7 +441,7 @@ extension CMUXCLI { func normalizedRightSidebarCLIArgument(_ value: String) -> String { switch value.lowercased() { - case "files", "find", "vault", "sessions", "feed", "dock": + case "files", "find", "history", "sessions", "vault", "feed", "dock": return value.lowercased() default: return value diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 13c7c3bd4a6a..749b0d8bbcf8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -16981,10 +16981,10 @@ struct CMUXCLI { show Show the right sidebar hide Hide the right sidebar focus Focus the current right sidebar mode - set + set Show, switch mode, and focus mode Print {"visible":bool,"mode":string} - files|find|vault|sessions|feed|dock + files|find|history|sessions|vault|feed|dock Alias for show + set + focus Flags: @@ -17718,7 +17718,7 @@ struct CMUXCLI { case "set": guard parsed.positional.count == 2 else { - throw CLIError(message: String(localized: "cli.rightSidebar.error.setRequiresMode", defaultValue: "right-sidebar set requires a mode: files, find, vault, sessions, feed, or dock")) + throw CLIError(message: String(localized: "cli.rightSidebar.error.setRequiresMode", defaultValue: "right-sidebar set requires a mode: files, find, history, sessions, vault, feed, or dock")) } let mode = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines) guard isRightSidebarCLIMode(mode) else { @@ -17730,7 +17730,7 @@ struct CMUXCLI { } return args - case "files", "find", "vault", "sessions", "feed", "dock": + case "files", "find", "history", "sessions", "vault", "feed", "dock": guard parsed.positional.count == 1 else { throw CLIError(message: String(localized: "cli.rightSidebar.error.unexpectedArguments", defaultValue: "right-sidebar \(action) received unexpected arguments")) } @@ -35598,7 +35598,7 @@ export default CMUXSessionRestore; open-notification --id jump-to-unread clear-notifications [--workspace ] [--window ] - right-sidebar [--workspace ] [--window ] [--no-focus] + right-sidebar [--workspace ] [--window ] [--no-focus] sidebar [name] set-status [--workspace ] [--window ] [--icon ] [--color <#hex>] [--priority ] clear-status [--workspace ] [--window ] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fc34b40c71c3..9a4a02a3a30d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -46992,13 +46992,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "right-sidebar set requires a mode: files, find, vault, sessions, feed, or dock" + "value": "right-sidebar set requires a mode: files, find, history, sessions, vault, feed, or dock" } }, "ja": { "stringUnit": { "state": "translated", - "value": "right-sidebar set にはモードが必要です: files, find, vault, sessions, feed, dock" + "value": "right-sidebar set にはモードが必要です: files, find, history, sessions, vault, feed, dock" } } } @@ -47145,13 +47145,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Usage: cmux right-sidebar [flags]\n\nControl the right sidebar from the CLI.\n\nCommands:\n toggle Toggle right sidebar visibility\n show Show the right sidebar\n hide Hide the right sidebar\n focus Focus the current right sidebar mode\n set \n Show, switch mode, and focus\n mode Print {\"visible\":bool,\"mode\":string}\n files|find|vault|sessions|feed|dock\n Alias for show + set + focus\n\nFlags:\n --workspace Target the window containing a workspace\n --window Target a window\n --no-focus With set, switch mode without moving focus\n\nExamples:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set vault --no-focus\n cmux right-sidebar mode" + "value": "Usage: cmux right-sidebar [flags]\n\nControl the right sidebar from the CLI.\n\nCommands:\n toggle Toggle right sidebar visibility\n show Show the right sidebar\n hide Hide the right sidebar\n focus Focus the current right sidebar mode\n set \n Show, switch mode, and focus\n mode Print {\"visible\":bool,\"mode\":string}\n files|find|history|sessions|vault|feed|dock\n Alias for show + set + focus\n\nFlags:\n --workspace Target the window containing a workspace\n --window Target a window\n --no-focus With set, switch mode without moving focus\n\nExamples:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set history --no-focus\n cmux right-sidebar mode" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使用法: cmux right-sidebar [flags]\n\nCLI から右サイドバーを操作します。\n\nコマンド:\n toggle 右サイドバーの表示を切り替えます\n show 右サイドバーを表示します\n hide 右サイドバーを隠します\n focus 現在の右サイドバーモードにフォーカスします\n set \n 表示して、モードを切り替え、フォーカスします\n mode {\"visible\":bool,\"mode\":string} を出力します\n files|find|vault|sessions|feed|dock\n show + set + focus のエイリアス\n\nフラグ:\n --workspace ワークスペースを含むウィンドウを対象にします\n --window ウィンドウを対象にします\n --no-focus set で、フォーカスを移さずにモードだけ切り替えます\n\n例:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set vault --no-focus\n cmux right-sidebar mode" + "value": "使用法: cmux right-sidebar [flags]\n\nCLI から右サイドバーを操作します。\n\nコマンド:\n toggle 右サイドバーの表示を切り替えます\n show 右サイドバーを表示します\n hide 右サイドバーを隠します\n focus 現在の右サイドバーモードにフォーカスします\n set \n 表示して、モードを切り替え、フォーカスします\n mode {\"visible\":bool,\"mode\":string} を出力します\n files|find|history|sessions|vault|feed|dock\n show + set + focus のエイリアス\n\nフラグ:\n --workspace ワークスペースを含むウィンドウを対象にします\n --window ウィンドウを対象にします\n --no-focus set で、フォーカスを移さずにモードだけ切り替えます\n\n例:\n cmux right-sidebar toggle\n cmux right-sidebar set find\n cmux right-sidebar set history --no-focus\n cmux right-sidebar mode" } } } @@ -62160,13 +62160,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Open Vault as Pane" + "value": "Open History as Pane" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Vaultをペインで開く" + "value": "履歴をペインで開く" } } } @@ -141816,19 +141816,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Vault" + "value": "History" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ボールト" + "value": "履歴" } }, "ko": { "stringUnit": { "state": "translated", - "value": "볼트" + "value": "기록" } } } @@ -142162,13 +142162,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]" + "value": "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ERROR: 使用法: right_sidebar set [--no-focus] [--workspace=] [--window=]" + "value": "ERROR: 使用法: right_sidebar set [--no-focus] [--workspace=] [--window=]" } } } @@ -142996,19 +142996,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Vault is empty" + "value": "No sessions yet" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ボールトは空です" + "value": "セッションはまだありません" } }, "ko": { "stringUnit": { "state": "translated", - "value": "볼트가 비어 있습니다" + "value": "아직 세션이 없습니다" } } } @@ -143070,19 +143070,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Loading Vault…" + "value": "Loading sessions…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ボールトを読み込み中…" + "value": "セッションを読み込み中…" } }, "ko": { "stringUnit": { "state": "translated", - "value": "볼트를 불러오는 중…" + "value": "세션을 불러오는 중…" } } } @@ -143161,19 +143161,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Search Vault" + "value": "Search sessions" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ボールトを検索" + "value": "セッションを検索" } }, "ko": { "stringUnit": { "state": "translated", - "value": "볼트 검색" + "value": "세션 검색" } } } @@ -143388,19 +143388,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Reload Vault" + "value": "Reload sessions" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ボールトを再読み込み" + "value": "セッションを再読み込み" } }, "ko": { "stringUnit": { "state": "translated", - "value": "볼트 새로고침" + "value": "세션 새로고침" } } } @@ -208612,19 +208612,19 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show Sidebar Vault" + "value": "Show Sidebar History" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サイドバーのボールトを表示" + "value": "サイドバーに履歴を表示" } }, "ko": { "stringUnit": { "state": "translated", - "value": "사이드바 볼트 표시" + "value": "사이드바 기록 표시" } } } @@ -259120,6 +259120,295 @@ } } }, + "vaultHistory.action.reopen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reopen" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再度開く" + } + } + } + }, + "vaultHistory.empty.filtered.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try another search or time range." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索条件または期間を変更してください。" + } + } + } + }, + "vaultHistory.empty.filtered.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "該当する履歴はありません" + } + } + } + }, + "vaultHistory.filters.clear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear filters" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィルタをクリア" + } + } + } + }, + "vaultHistory.group.directory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディレクトリ" + } + } + } + }, + "vaultHistory.group.unknownDirectory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なディレクトリ" + } + } + } + }, + "vaultHistory.range.allTime": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての期間" + } + } + } + }, + "vaultHistory.range.last24Hours": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 24 hours" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去24時間" + } + } + } + }, + "vaultHistory.range.last30Days": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 30 days" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去30日間" + } + } + } + }, + "vaultHistory.range.last7Days": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last 7 days" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "過去7日間" + } + } + } + }, + "vaultHistory.rangePicker.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filter history by time" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期間で履歴を絞り込む" + } + } + } + }, + "vaultHistory.search.clear.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear history search" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴の検索をクリア" + } + } + } + }, + "vaultHistory.search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を検索" + } + } + } + }, + "vaultHistory.sort.newestFirst": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newest first" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しい順" + } + } + } + }, + "vaultHistory.sort.oldestFirst": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Oldest first" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "古い順" + } + } + } + }, + "vaultHistory.sort.titleAscending": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Title A–Z" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトル順" + } + } + } + }, + "vaultHistory.sortPicker.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort history" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "履歴を並べ替え" + } + } + } + }, "vaultPane.tab.sessions": { "extractionState": "manual", "localizations": { @@ -259143,13 +259432,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "History" + "value": "Timeline" } }, "ja": { "stringUnit": { "state": "translated", - "value": "履歴" + "value": "タイムライン" } } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 349f4c7d8bf3..61162eb6ff53 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16519,12 +16519,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !snapshot.tabManager.workspaces.isEmpty else { return } - ClosedItemHistoryStore.shared.push(.window(ClosedWindowHistoryEntry( + let record = ClosedItemHistoryRecord( + entry: .window(ClosedWindowHistoryEntry( + windowId: context.windowId, + snapshot: snapshot, + workspaceIds: snapshot.tabManager.workspaces.compactMap(\.workspaceId) + )) + ) + ClosedItemHistoryStore.shared.push(record) + recordVaultHistoryWindowClosed( windowId: context.windowId, snapshot: snapshot, - workspaceIds: snapshot.tabManager.workspaces.compactMap(\.workspaceId) - ))) - recordVaultHistoryWindowClosed(windowId: context.windowId, snapshot: snapshot) + closedItemId: record.id + ) } #if DEBUG diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index e53f7fba218d..92d67d1b8405 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -184,6 +184,11 @@ final class ClosedItemHistoryStore: ObservableObject { !records.isEmpty } + /// Stable record IDs currently available for a targeted restore. + var recordIdsSnapshot: Set { + Set(records.lazy.map(\.id)) + } + func push(_ entry: ClosedItemHistoryEntry) { push(ClosedItemHistoryRecord(entry: entry)) } diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index be11a632af8d..f4392ca72453 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -186,7 +186,7 @@ extension ContentView { case .find: return String(localized: "command.openFindPane.title", defaultValue: "Open Find as Pane") case .sessions: - return String(localized: "command.openVaultPane.title", defaultValue: "Open Vault as Pane") + return String(localized: "command.openVaultPane.title", defaultValue: "Open History as Pane") case .feed, .dock, .customSidebar: return nil } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 9d6d22997005..4d4025297068 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -231,7 +231,7 @@ enum KeyboardShortcutSettings { case .focusRightSidebar: return String(localized: "shortcut.focusRightSidebar.label", defaultValue: "Toggle Right Sidebar Focus") case .switchRightSidebarToFiles: return String(localized: "shortcut.switchRightSidebarToFiles.label", defaultValue: "Show Sidebar Files") case .switchRightSidebarToFind: return String(localized: "shortcut.switchRightSidebarToFind.label", defaultValue: "Show Sidebar Find") - case .switchRightSidebarToSessions: return String(localized: "shortcut.switchRightSidebarToSessions.label", defaultValue: "Show Sidebar Vault") + case .switchRightSidebarToSessions: return String(localized: "shortcut.switchRightSidebarToSessions.label", defaultValue: "Show Sidebar History") case .switchRightSidebarToFeed: return String(localized: "shortcut.switchRightSidebarToFeed.label", defaultValue: "Show Sidebar Feed") case .switchRightSidebarToDock: return String(localized: "shortcut.switchRightSidebarToDock.label", defaultValue: "Show Sidebar Dock") case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift index 26e8440683ac..9f9316a8fa25 100644 --- a/Sources/RightSidebarMode+Availability.swift +++ b/Sources/RightSidebarMode+Availability.swift @@ -8,7 +8,7 @@ extension RightSidebarMode { return .files case "find": return .find - case "vault", "sessions": + case "history", "vault", "sessions": return .sessions case "feed": return .feed diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index d8a377f4b219..f0a715151b49 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -25,7 +25,7 @@ enum RightSidebarMode: String, CaseIterable, Codable, Sendable { switch self { case .files: return String(localized: "rightSidebar.mode.files", defaultValue: "Files") case .find: return String(localized: "rightSidebar.mode.find", defaultValue: "Find") - case .sessions: return String(localized: "rightSidebar.mode.sessions", defaultValue: "Vault") + case .sessions: return String(localized: "rightSidebar.mode.sessions", defaultValue: "History") case .feed: return String(localized: "rightSidebar.mode.feed", defaultValue: "Feed") case .dock: return String(localized: "rightSidebar.mode.dock", defaultValue: "Dock") case .customSidebar: return String(localized: "rightSidebar.mode.customSidebar", defaultValue: "Custom") @@ -36,7 +36,7 @@ enum RightSidebarMode: String, CaseIterable, Codable, Sendable { switch self { case .files: return "folder" case .find: return "magnifyingglass" - case .sessions: return "books.vertical" + case .sessions: return "clock.arrow.circlepath" case .feed: return "dot.radiowaves.left.and.right" case .dock: return "dock.rectangle" case .customSidebar: return "wand.and.stars" @@ -393,7 +393,14 @@ struct RightSidebarPanelView: View { presentation: .find ) case .sessions: - VaultPaneView(store: sessionIndexStore, onResume: onResumeSession) + VaultPaneView( + store: sessionIndexStore, + closedItemStore: .shared, + onResume: onResumeSession, + onReopenClosedItem: { id in + tabManager.reopenClosedHistoryItem(id: id) + } + ) .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) } diff --git a/Sources/RightSidebarRemoteCommand.swift b/Sources/RightSidebarRemoteCommand.swift index 32823d0e5c37..3527adbd705a 100644 --- a/Sources/RightSidebarRemoteCommand.swift +++ b/Sources/RightSidebarRemoteCommand.swift @@ -133,7 +133,7 @@ extension RightSidebarRemoteRequest { return .success(.init(command: .getState, target: target)) case "set": guard positional.count == 2 else { - return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.set", defaultValue: "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]"))) + return .failure(.init(message: String(localized: "rightSidebar.remote.error.usage.set", defaultValue: "ERROR: Usage: right_sidebar set [--no-focus] [--workspace=] [--window=]"))) } let rawMode = positional[1].trimmingCharacters(in: .whitespacesAndNewlines) if let mode = RightSidebarMode.from(cliArgument: rawMode), mode != .customSidebar { diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 640773826bd3..8b1a27260b57 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -286,8 +286,12 @@ struct RightSidebarToolPanelView: View { case .sessions: VaultPaneView( store: panel.sessionIndexStore, + closedItemStore: .shared, onResume: { entry in SessionEntryResumeCoordinator.resume(entry, tabManager: tabManager) + }, + onReopenClosedItem: { id in + tabManager.reopenClosedHistoryItem(id: id) } ) .background( diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 1639221665e3..5ee15a4f0b9b 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -132,7 +132,7 @@ struct SessionIndexView: View { .cmuxFont(size: 10, weight: .medium) } .buttonStyle(.borderless) - .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload Vault")) + .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload sessions")) .disabled(store.isLoading) .titlebarInteractiveControl() } @@ -144,7 +144,7 @@ struct SessionIndexView: View { private var loadingView: some View { VStack(spacing: 6) { ProgressView().controlSize(.small) - Text(String(localized: "sessionIndex.loading", defaultValue: "Loading Vault…")) + Text(String(localized: "sessionIndex.loading", defaultValue: "Loading sessions…")) .cmuxFont(size: 11) .foregroundColor(.secondary) } @@ -153,7 +153,7 @@ struct SessionIndexView: View { private var emptyView: some View { VStack(spacing: 4) { - Text(String(localized: "sessionIndex.empty.title", defaultValue: "Vault is empty")) + Text(String(localized: "sessionIndex.empty.title", defaultValue: "No sessions yet")) .cmuxFont(size: 12) .foregroundColor(.secondary) Text(String(localized: "sessionIndex.empty.subtitle", @@ -2186,7 +2186,7 @@ struct SectionPopoverView: View { .foregroundColor(.secondary) TextField( String(localized: "sessionIndex.popover.searchPlaceholder", - defaultValue: "Search Vault"), + defaultValue: "Search sessions"), text: $query ) .textFieldStyle(.plain) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f4d1b25fbc23..2331dfa05d90 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2092,6 +2092,7 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace, recordHistory: Bool = true) { guard tabs.count > 1 else { return } + var closedItemHistoryRecordId: UUID? panelTitleUpdateCoalescer.flushNow() sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) // Closing a mirrored remote tmux workspace DETACHES from the remote session, @@ -2113,15 +2114,22 @@ class TabManager: ObservableObject { restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() ?? RestorableAgentSessionIndex.load() ) - ClosedItemHistoryStore.shared.push(.workspace(ClosedWorkspaceHistoryEntry( - workspaceId: workspace.id, - windowId: AppDelegate.shared?.windowId(for: self), - workspaceIndex: index, - snapshot: snapshot - ))) + let record = ClosedItemHistoryRecord( + entry: .workspace(ClosedWorkspaceHistoryEntry( + workspaceId: workspace.id, + windowId: AppDelegate.shared?.windowId(for: self), + workspaceIndex: index, + snapshot: snapshot + )) + ) + ClosedItemHistoryStore.shared.push(record) + closedItemHistoryRecordId = record.id } if recordHistory { - recordVaultHistoryWorkspaceClosed(workspace) + recordVaultHistoryWorkspaceClosed( + workspace, + closedItemId: closedItemHistoryRecordId + ) } sidebarGitMetadataService.clearWorkspaceGitProbes(workspaceId: workspace.id) pullRequestProbing.clearWorkspacePullRequestTracking(workspaceId: workspace.id) diff --git a/Sources/VaultHistoryControls.swift b/Sources/VaultHistoryControls.swift new file mode 100644 index 000000000000..0fbab15ad312 --- /dev/null +++ b/Sources/VaultHistoryControls.swift @@ -0,0 +1,152 @@ +import SwiftUI + +/// Search, time-range, grouping, sorting, and reload controls for History. +struct VaultHistoryControls: View { + @Bindable var model: VaultHistoryTimelineModel + let isReloadDisabled: Bool + let onReload: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + timeRangeMenu + groupMenu + Spacer(minLength: 4) + sortMenu + reloadButton + } + .rightSidebarChromeBar() + + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .cmuxFont(size: 10) + .foregroundColor(.secondary) + TextField( + String(localized: "vaultHistory.search.placeholder", defaultValue: "Search history"), + text: $model.searchText + ) + .textFieldStyle(.plain) + .cmuxFont(size: 11) + .accessibilityIdentifier("VaultHistorySearchField") + if !model.searchText.isEmpty { + Button { + model.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .cmuxFont(size: 10) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String( + localized: "vaultHistory.search.clear.tooltip", + defaultValue: "Clear history search" + )) + .help(String( + localized: "vaultHistory.search.clear.tooltip", + defaultValue: "Clear history search" + )) + } + } + .padding(.horizontal, 10) + .frame(height: RightSidebarChromeMetrics.secondaryBarHeight) + } + .rightSidebarChromeBottomBorder() + } + + private var timeRangeMenu: some View { + Menu { + ForEach(VaultHistoryQuery.TimeRange.allCases) { range in + Button { + model.timeRange = range + } label: { + if model.timeRange == range { + Label(range.label, systemImage: "checkmark") + } else { + Text(range.label) + } + } + } + } label: { + Label(model.timeRange.label, systemImage: model.timeRange.symbolName) + .cmuxFont( + size: RightSidebarChromeControlStyle.labelSize, + weight: RightSidebarChromeControlStyle.labelWeight + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(String(localized: "vaultHistory.rangePicker.tooltip", defaultValue: "Filter history by time")) + .accessibilityIdentifier("VaultHistoryTimeRangePicker") + .titlebarInteractiveControl() + } + + private var groupMenu: some View { + 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: { + Label(model.groupKey.label, systemImage: model.groupKey.symbolName) + .labelStyle(.iconOnly) + .cmuxFont( + size: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(String(localized: "vaultHistory.groupPicker.tooltip", defaultValue: "Group history by")) + .accessibilityIdentifier("VaultHistoryGroupPicker") + .titlebarInteractiveControl() + } + + private var sortMenu: some View { + Menu { + ForEach(VaultHistoryQuery.SortOrder.allCases) { order in + Button { + model.sortOrder = order + } label: { + if model.sortOrder == order { + Label(order.label, systemImage: "checkmark") + } else { + Text(order.label) + } + } + } + } label: { + Label(model.sortOrder.label, systemImage: model.sortOrder.symbolName) + .labelStyle(.iconOnly) + .cmuxFont( + size: RightSidebarChromeControlStyle.secondaryIconSize, + weight: RightSidebarChromeControlStyle.iconWeight + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help(String(localized: "vaultHistory.sortPicker.tooltip", defaultValue: "Sort history")) + .accessibilityIdentifier("VaultHistorySortPicker") + .titlebarInteractiveControl() + } + + private var reloadButton: some View { + Button { + onReload() + } label: { + Image(systemName: "arrow.clockwise") + .cmuxFont(size: 10, weight: .medium) + } + .buttonStyle(.borderless) + .accessibilityLabel(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) + .help(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) + .disabled(isReloadDisabled) + .titlebarInteractiveControl() + } +} diff --git a/Sources/VaultHistoryEvent.swift b/Sources/VaultHistoryEvent.swift index b40961c75abb..d9ae028ea4cb 100644 --- a/Sources/VaultHistoryEvent.swift +++ b/Sources/VaultHistoryEvent.swift @@ -52,29 +52,37 @@ enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { struct VaultHistorySubject: Hashable, Codable, Sendable { var workspaceId: UUID? var windowId: UUID? + /// Recently-closed record that can restore this workspace or window. + var closedItemId: UUID? /// Native agent session identifier for `sessionActivity` events. var sessionId: String? /// `SessionAgent` raw value for `sessionActivity` events. var agent: String? + /// User-facing agent name captured from the session index. + var agentDisplayName: String? /// Working directory associated with the subject, when known. var directory: String? init( workspaceId: UUID? = nil, windowId: UUID? = nil, + closedItemId: UUID? = nil, sessionId: String? = nil, agent: String? = nil, + agentDisplayName: String? = nil, directory: String? = nil ) { self.workspaceId = workspaceId self.windowId = windowId + self.closedItemId = closedItemId self.sessionId = sessionId self.agent = agent + self.agentDisplayName = agentDisplayName self.directory = directory } } -/// One entry in the unified Vault history timeline. +/// One entry in the unified 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 diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift new file mode 100644 index 000000000000..39da460eff58 --- /dev/null +++ b/Sources/VaultHistoryEventRow.swift @@ -0,0 +1,166 @@ +import SwiftUI + +enum VaultHistoryRowAction: Equatable { + case resumeSession(SessionEntry) + case reopenClosedItem(UUID) + + var label: String { + switch self { + case .resumeSession: + return String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab") + case .reopenClosedItem: + return String(localized: "vaultHistory.action.reopen", defaultValue: "Reopen") + } + } + + var symbolName: String { + switch self { + case .resumeSession: return "play.fill" + case .reopenClosedItem: return "arrow.uturn.backward" + } + } +} + +struct VaultHistoryRowActions { + let onResume: ((SessionEntry) -> Void)? + let onReopenClosedItem: ((UUID) -> Bool)? + + var canResume: Bool { onResume != nil } + var canReopen: Bool { onReopenClosedItem != nil } + + func perform(_ action: VaultHistoryRowAction) { + switch action { + case .resumeSession(let entry): + onResume?(entry) + case .reopenClosedItem(let id): + _ = onReopenClosedItem?(id) + } + } +} + +/// Value-only History row with optional resume or reopen behavior. +struct VaultHistoryEventRow: View, Equatable { + let event: VaultHistoryEvent + let action: VaultHistoryRowAction? + let actions: VaultHistoryRowActions + @State private var isHovered = false + + nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { + lhs.event == rhs.event && lhs.action == rhs.action + } + + 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, style: .relative) + .cmuxFont(size: 10) + .foregroundColor(.secondary.opacity(0.7)) + .help(event.timestamp.formatted(date: .abbreviated, time: .shortened)) + if let action { + Button { + actions.perform(action) + } label: { + Image(systemName: action.symbolName) + .cmuxFont(size: 9, weight: .medium) + } + .buttonStyle(.borderless) + .accessibilityLabel(action.label) + .help(action.label) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.05) : Color.clear) + .padding(.horizontal, 4) + ) + .onHover { isHovered = $0 } + .onTapGesture(count: 2) { + if let action { + actions.perform(action) + } + } + .contextMenu { + if let action { + Button { + actions.perform(action) + } label: { + Label(action.label, systemImage: action.symbolName) + } + } + } + .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 { + if let displayName = event.subject.agentDisplayName, !displayName.isEmpty { + parts.append(displayName) + } else if let raw = event.subject.agent, + let agent = SessionAgent(rawValue: raw) { + parts.append(agent.displayName) + } else { + parts.append(event.kind.label) + } + } 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 { + 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/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift new file mode 100644 index 000000000000..c854d3ec324d --- /dev/null +++ b/Sources/VaultHistoryGroupHeader.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Pinned section heading for a group of History events. +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) + .help(title) + 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/VaultHistoryGrouping.swift b/Sources/VaultHistoryGrouping.swift index c2cc66c9f620..14d521383f1b 100644 --- a/Sources/VaultHistoryGrouping.swift +++ b/Sources/VaultHistoryGrouping.swift @@ -7,6 +7,7 @@ enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable { case date case workspace case window + case directory case agent case kind @@ -17,6 +18,7 @@ enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable { 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 .directory: return String(localized: "vaultHistory.group.directory", defaultValue: "Directory") case .agent: return String(localized: "vaultHistory.group.agent", defaultValue: "Agent") case .kind: return String(localized: "vaultHistory.group.kind", defaultValue: "Type") } @@ -27,6 +29,7 @@ enum VaultHistoryGroupKey: String, CaseIterable, Identifiable, Codable { case .date: return "clock" case .workspace: return "square.on.square" case .window: return "macwindow" + case .directory: return "folder" case .agent: return "person.2" case .kind: return "tag" } @@ -94,7 +97,7 @@ struct VaultHistoryGroup: Identifiable, Equatable, Sendable { } /// Pure grouping over a flat event list. Given the same events, key, `now`, -/// and calendar, the output is deterministic — no clock or store access — +/// and calendar, the output is deterministic, with no clock or store access, /// so every grouping behavior is unit-testable. struct VaultHistoryGrouper: Sendable { var calendar: Calendar @@ -108,49 +111,59 @@ struct VaultHistoryGrouper: Sendable { func groups( events: [VaultHistoryEvent], by key: VaultHistoryGroupKey, + query: VaultHistoryQuery = VaultHistoryQuery(), now: Date ) -> [VaultHistoryGroup] { - let sorted = events.sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.id > $1.id - } + let sorted = query.visibleEvents(from: events, now: now) switch key { case .date: - return dateGroups(sortedEvents: sorted, now: now) + return dateGroups( + sortedEvents: sorted, + now: now, + sortOrder: query.sortOrder + ) case .workspace: return subjectGroups(sortedEvents: sorted, idFor: { event in event.subject.workspaceId.map { "workspace:\($0.uuidString)" } - }) + }, sortOrder: query.sortOrder) case .window: return subjectGroups(sortedEvents: sorted, idFor: { event in event.subject.windowId.map { "window:\($0.uuidString)" } - }) + }, sortOrder: query.sortOrder) + case .directory: + return directoryGroups(sortedEvents: sorted, sortOrder: query.sortOrder) case .agent: - return agentGroups(sortedEvents: sorted) + return agentGroups(sortedEvents: sorted, sortOrder: query.sortOrder) case .kind: - return kindGroups(sortedEvents: sorted) + return kindGroups(sortedEvents: sorted, sortOrder: query.sortOrder) } } - - private func dateGroups(sortedEvents: [VaultHistoryEvent], now: Date) -> [VaultHistoryGroup] { + private func dateGroups( + sortedEvents: [VaultHistoryEvent], + now: Date, + sortOrder: VaultHistoryQuery.SortOrder + ) -> [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 + let bucketOrder = sortOrder == .oldestFirst + ? Array(VaultHistoryDateBucket.allCases.reversed()) + : VaultHistoryDateBucket.allCases + return bucketOrder.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? + idFor: (VaultHistoryEvent) -> String?, + sortOrder: VaultHistoryQuery.SortOrder ) -> [VaultHistoryGroup] { var order: [String] = [] var byId: [String: [VaultHistoryEvent]] = [:] @@ -163,17 +176,43 @@ struct VaultHistoryGrouper: Sendable { order.remove(at: otherIndex) order.append(Self.otherGroupID) } - return order.compactMap { id in + let groups = order.compactMap { id -> VaultHistoryGroup? 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 + : (latestNonemptyTitle(in: events) ?? String(localized: "vaultHistory.untitled", defaultValue: "Untitled")) return VaultHistoryGroup(id: id, title: title, events: events) } + return titleSortedIfNeeded(groups, sortOrder: sortOrder) + } + private func directoryGroups( + sortedEvents: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> [VaultHistoryGroup] { + var order: [String] = [] + var byDirectory: [String: [VaultHistoryEvent]] = [:] + for event in sortedEvents { + let directory = Self.normalizedDirectory(event.subject.directory) + let id = directory.map { "directory:\($0)" } ?? Self.otherGroupID + if byDirectory[id] == nil { order.append(id) } + byDirectory[id, default: []].append(event) + } + moveOtherGroupToEnd(&order) + let groups = order.compactMap { id -> VaultHistoryGroup? in + guard let events = byDirectory[id], !events.isEmpty else { return nil } + let title = id == Self.otherGroupID + ? String(localized: "vaultHistory.group.unknownDirectory", defaultValue: "Unknown directory") + : String(id.dropFirst("directory:".count)) + return VaultHistoryGroup(id: id, title: title, events: events) + } + return titleSortedIfNeeded(groups, sortOrder: sortOrder) } - private func agentGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { + private func agentGroups( + sortedEvents: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> [VaultHistoryGroup] { var order: [String] = [] var byId: [String: [VaultHistoryEvent]] = [:] for event in sortedEvents { @@ -185,11 +224,14 @@ struct VaultHistoryGrouper: Sendable { order.remove(at: otherIndex) order.append(Self.otherGroupID) } - return order.compactMap { id in + let groups = order.compactMap { id -> VaultHistoryGroup? 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 displayName = events.first?.subject.agentDisplayName, + !displayName.isEmpty { + title = displayName } else if let raw = events.first?.subject.agent, let agent = SessionAgent(rawValue: raw) { title = agent.displayName } else { @@ -197,18 +239,62 @@ struct VaultHistoryGrouper: Sendable { } return VaultHistoryGroup(id: id, title: title, events: events) } + return titleSortedIfNeeded(groups, sortOrder: sortOrder) } - private func kindGroups(sortedEvents: [VaultHistoryEvent]) -> [VaultHistoryGroup] { + private func kindGroups( + sortedEvents: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> [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 + let groups = order.compactMap { kind -> VaultHistoryGroup? in guard let events = byKind[kind], !events.isEmpty else { return nil } return VaultHistoryGroup(id: "kind:\(kind.rawValue)", title: kind.label, events: events) } + return titleSortedIfNeeded(groups, sortOrder: sortOrder) + } + + private func latestNonemptyTitle(in events: [VaultHistoryEvent]) -> String? { + events + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .max { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + return $0.id < $1.id + }? + .title + } + + private func titleSortedIfNeeded( + _ groups: [VaultHistoryGroup], sortOrder: VaultHistoryQuery.SortOrder + ) -> [VaultHistoryGroup] { + guard sortOrder == .titleAscending else { return groups } + return groups.sorted { lhs, rhs in + if lhs.id == Self.otherGroupID { return false } + if rhs.id == Self.otherGroupID { return true } + let comparison = lhs.title.localizedStandardCompare(rhs.title) + if comparison != .orderedSame { return comparison == .orderedAscending } + return lhs.id < rhs.id + } + } + + private func moveOtherGroupToEnd(_ order: inout [String]) { + guard let otherIndex = order.firstIndex(of: Self.otherGroupID), + otherIndex != order.count - 1 else { + return + } + order.remove(at: otherIndex) + order.append(Self.otherGroupID) + } + + private static func normalizedDirectory(_ directory: String?) -> String? { + let trimmed = directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, trimmed != "." else { return nil } + let standardized = (trimmed as NSString).standardizingPath + return standardized.isEmpty || standardized == "." ? nil : standardized } } diff --git a/Sources/VaultHistoryLifecycleRecording.swift b/Sources/VaultHistoryLifecycleRecording.swift index 5d791ef15359..fbbafaa5403f 100644 --- a/Sources/VaultHistoryLifecycleRecording.swift +++ b/Sources/VaultHistoryLifecycleRecording.swift @@ -1,6 +1,6 @@ import Foundation -/// Lifecycle hooks that feed the Vault History timeline. Call sites in +/// Lifecycle hooks that feed the 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 { @@ -37,7 +37,7 @@ extension TabManager { )) } - func recordVaultHistoryWorkspaceClosed(_ workspace: Workspace) { + func recordVaultHistoryWorkspaceClosed(_ workspace: Workspace, closedItemId: UUID?) { guard !VaultHistoryEventLog.isRecordingSuppressed else { return } VaultHistoryEventLog.shared.record(VaultHistoryEvent( timestamp: Date(), @@ -46,6 +46,7 @@ extension TabManager { subject: VaultHistorySubject( workspaceId: workspace.id, windowId: AppDelegate.shared?.windowId(for: self), + closedItemId: closedItemId, directory: workspace.currentDirectory ) )) @@ -66,7 +67,11 @@ extension AppDelegate { /// 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) { + func recordVaultHistoryWindowClosed( + windowId: UUID, + snapshot: SessionWindowSnapshot, + closedItemId: UUID + ) { let workspaces = snapshot.tabManager.workspaces let title = workspaces .compactMap { workspace -> String? in @@ -80,7 +85,11 @@ extension AppDelegate { kind: .windowClosed, title: title, workspaceCount: workspaces.count, - subject: VaultHistorySubject(windowId: windowId) + subject: VaultHistorySubject( + windowId: windowId, + closedItemId: closedItemId, + directory: workspaces.first?.currentDirectory + ) )) } } diff --git a/Sources/VaultHistoryQuery.swift b/Sources/VaultHistoryQuery.swift new file mode 100644 index 000000000000..90e610595277 --- /dev/null +++ b/Sources/VaultHistoryQuery.swift @@ -0,0 +1,149 @@ +import Foundation + +/// User-selected filtering and ordering for the History timeline. +struct VaultHistoryQuery: Equatable, Sendable { + enum TimeRange: String, CaseIterable, Identifiable, Codable, Sendable { + case allTime + case last24Hours + case last7Days + case last30Days + + var id: String { rawValue } + + var label: String { + switch self { + case .allTime: + return String(localized: "vaultHistory.range.allTime", defaultValue: "All time") + case .last24Hours: + return String(localized: "vaultHistory.range.last24Hours", defaultValue: "Last 24 hours") + case .last7Days: + return String(localized: "vaultHistory.range.last7Days", defaultValue: "Last 7 days") + case .last30Days: + return String(localized: "vaultHistory.range.last30Days", defaultValue: "Last 30 days") + } + } + + var symbolName: String { + switch self { + case .allTime: return "calendar" + case .last24Hours: return "clock" + case .last7Days: return "calendar.day.timeline.leading" + case .last30Days: return "calendar.badge.clock" + } + } + + func includes(_ timestamp: Date, relativeTo now: Date) -> Bool { + switch self { + case .allTime: + return true + case .last24Hours: + return timestamp >= now.addingTimeInterval(-24 * 60 * 60) + case .last7Days: + return timestamp >= now.addingTimeInterval(-7 * 24 * 60 * 60) + case .last30Days: + return timestamp >= now.addingTimeInterval(-30 * 24 * 60 * 60) + } + } + } + + enum SortOrder: String, CaseIterable, Identifiable, Codable, Sendable { + case newestFirst + case oldestFirst + case titleAscending + + var id: String { rawValue } + + var label: String { + switch self { + case .newestFirst: + return String(localized: "vaultHistory.sort.newestFirst", defaultValue: "Newest first") + case .oldestFirst: + return String(localized: "vaultHistory.sort.oldestFirst", defaultValue: "Oldest first") + case .titleAscending: + return String(localized: "vaultHistory.sort.titleAscending", defaultValue: "Title A–Z") + } + } + + var symbolName: String { + switch self { + case .newestFirst: return "arrow.down" + case .oldestFirst: return "arrow.up" + case .titleAscending: return "textformat.abc" + } + } + } + + var timeRange: TimeRange + var sortOrder: SortOrder + var searchText: String + + init( + timeRange: TimeRange = .allTime, + sortOrder: SortOrder = .newestFirst, + searchText: String = "" + ) { + self.timeRange = timeRange + self.sortOrder = sortOrder + self.searchText = searchText + } + + func visibleEvents(from events: [VaultHistoryEvent], now: Date) -> [VaultHistoryEvent] { + let tokens = searchText + .split(whereSeparator: \.isWhitespace) + .map(String.init) + return events + .filter { timeRange.includes($0.timestamp, relativeTo: now) } + .filter { event in + tokens.isEmpty || tokens.allSatisfy { token in + searchableFields(for: event).contains { + $0.localizedCaseInsensitiveContains(token) + } + } + } + .sorted(by: eventComparator) + } + + private func searchableFields(for event: VaultHistoryEvent) -> [String] { + var fields = [ + event.title, + event.previousTitle ?? "", + event.kind.label, + event.subject.directory ?? "", + event.subject.sessionId ?? "", + event.subject.agentDisplayName ?? "", + event.subject.workspaceId?.uuidString ?? "", + event.subject.windowId?.uuidString ?? "", + ] + if let rawAgent = event.subject.agent { + fields.append(rawAgent) + if event.subject.agentDisplayName == nil, + let agent = SessionAgent(rawValue: rawAgent) { + fields.append(agent.displayName) + } + } + return fields + } + + private func eventComparator(_ lhs: VaultHistoryEvent, _ rhs: VaultHistoryEvent) -> Bool { + switch sortOrder { + case .newestFirst: + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.id > rhs.id + case .oldestFirst: + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.id < rhs.id + case .titleAscending: + let lhsTitle = sortTitle(for: lhs) + let rhsTitle = sortTitle(for: rhs) + let comparison = lhsTitle.localizedStandardCompare(rhsTitle) + if comparison != .orderedSame { return comparison == .orderedAscending } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.id > rhs.id + } + } + + private func sortTitle(for event: VaultHistoryEvent) -> String { + let title = event.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? event.kind.label : title + } +} diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift index 4f945f49a57e..01147b6dccb8 100644 --- a/Sources/VaultHistorySessionEvents.swift +++ b/Sources/VaultHistorySessionEvents.swift @@ -16,6 +16,7 @@ struct VaultHistorySessionEventProjection: Sendable { subject: VaultHistorySubject( sessionId: entry.sessionId, agent: entry.agent.rawValue, + agentDisplayName: entry.agent.displayName, directory: entry.cwd ) ) diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift new file mode 100644 index 000000000000..dd797839d194 --- /dev/null +++ b/Sources/VaultHistoryTimelineList.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// Immutable list projection kept below History's lazy layout boundary. +struct VaultHistoryTimelineList: View { + let groups: [VaultHistoryGroup] + let resumeEntriesByEventId: [String: SessionEntry] + let availableClosedItemIds: Set + let actions: VaultHistoryRowActions + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(groups) { group in + Section { + ForEach(group.events) { event in + VaultHistoryEventRow( + event: event, + action: rowAction(for: event), + actions: actions + ) + } + } header: { + VaultHistoryGroupHeader(title: group.title, count: group.events.count) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func rowAction(for event: VaultHistoryEvent) -> VaultHistoryRowAction? { + if let entry = resumeEntriesByEventId[event.id], actions.canResume { + return .resumeSession(entry) + } + if let closedItemId = event.subject.closedItemId, + availableClosedItemIds.contains(closedItemId), + actions.canReopen { + return .reopenClosedItem(closedItemId) + } + return nil + } +} diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 67c99fed44ad..c8196e30714f 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -1,8 +1,8 @@ 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 +/// View model for the History timeline: merges persisted lifecycle events +/// with session events derived from the agent-session index, and exposes grouped /// sections computed by the pure ``VaultHistoryGrouper``. @MainActor @Observable @@ -16,15 +16,41 @@ final class VaultHistoryTimelineModel { } } + var timeRange: VaultHistoryQuery.TimeRange { + didSet { + guard timeRange != oldValue else { return } + defaults.set(timeRange.rawValue, forKey: Self.timeRangeDefaultsKey) + regroup() + } + } + + var sortOrder: VaultHistoryQuery.SortOrder { + didSet { + guard sortOrder != oldValue else { return } + defaults.set(sortOrder.rawValue, forKey: Self.sortOrderDefaultsKey) + regroup() + } + } + + var searchText = "" { + didSet { + guard searchText != oldValue else { return } + regroup() + } + } + private(set) var groups: [VaultHistoryGroup] = [] + private(set) var resumeEntriesByEventId: [String: SessionEntry] = [:] 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" + private static let timeRangeDefaultsKey = "vaultHistory.timeRange" + private static let sortOrderDefaultsKey = "vaultHistory.sortOrder" /// Cap on merged timeline size handed to grouping; both inputs are - /// already bounded (store retention, session index page caps) — this is + /// already bounded by store retention and session index page caps. This is /// a final guard so the UI never renders an unbounded list. private static let maxTimelineEvents = 3000 @@ -36,6 +62,11 @@ final class VaultHistoryTimelineModel { private var mergedEvents: [VaultHistoryEvent] = [] private var refreshTask: Task? + var hasActiveFilters: Bool { + timeRange != .allTime + || !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + init( log: VaultHistoryEventLog, grouper: VaultHistoryGrouper = VaultHistoryGrouper(), @@ -48,6 +79,10 @@ final class VaultHistoryTimelineModel { self.now = now self.groupKey = defaults.string(forKey: Self.groupKeyDefaultsKey) .flatMap(VaultHistoryGroupKey.init(rawValue:)) ?? .date + self.timeRange = defaults.string(forKey: Self.timeRangeDefaultsKey) + .flatMap(VaultHistoryQuery.TimeRange.init(rawValue:)) ?? .allTime + self.sortOrder = defaults.string(forKey: Self.sortOrderDefaultsKey) + .flatMap(VaultHistoryQuery.SortOrder.init(rawValue:)) ?? .newestFirst } /// Reloads persisted events, merges the given session entries, and @@ -73,13 +108,34 @@ final class VaultHistoryTimelineModel { merged.removeLast(merged.count - Self.maxTimelineEvents) } self.mergedEvents = merged + var resumeEntries: [String: SessionEntry] = [:] + for entry in sessionEntries { + resumeEntries["session:\(entry.agent.rawValue):\(entry.id)"] = entry + } + self.resumeEntriesByEventId = resumeEntries self.isLoading = false self.didLoad = true self.regroup() } } + func clearFilters() { + searchText = "" + timeRange = .allTime + } + private func regroup() { - groups = grouper.groups(events: mergedEvents, by: groupKey, now: now()) + let currentTime = now() + let query = VaultHistoryQuery( + timeRange: timeRange, + sortOrder: sortOrder, + searchText: searchText + ) + groups = grouper.groups( + events: mergedEvents, + by: groupKey, + query: query, + now: currentTime + ) } } diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index 02eeb2951f8c..78fe9aac37c2 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -1,45 +1,73 @@ -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 History timeline, combining workspace/window lifecycle events with agent sessions. 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? + @ObservedObject private var sessionStore: SessionIndexStore + @ObservedObject private var closedItemStore: ClosedItemHistoryStore + private let log: VaultHistoryEventLog + private let onResume: ((SessionEntry) -> Void)? + private let onReopenClosedItem: ((UUID) -> Bool)? + @State private var model: VaultHistoryTimelineModel + + init( + sessionStore: SessionIndexStore, + closedItemStore: ClosedItemHistoryStore, + log: VaultHistoryEventLog, + onResume: ((SessionEntry) -> Void)?, + onReopenClosedItem: ((UUID) -> Bool)? + ) { + self.sessionStore = sessionStore + self.closedItemStore = closedItemStore + self.log = log + self.onResume = onResume + self.onReopenClosedItem = onReopenClosedItem + _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, + closedItemStore: closedItemStore, + log: log, + model: model, + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) } } private struct VaultHistoryContentView: View { @ObservedObject var sessionStore: SessionIndexStore + @ObservedObject var closedItemStore: ClosedItemHistoryStore let log: VaultHistoryEventLog let model: VaultHistoryTimelineModel + let onResume: ((SessionEntry) -> Void)? + let onReopenClosedItem: ((UUID) -> Bool)? var body: some View { VStack(spacing: 0) { - controlBar + VaultHistoryControls( + model: model, + isReloadDisabled: model.isLoading || sessionStore.isLoading, + onReload: reload + ) if !model.didLoad { - loadingView + VaultHistoryLoadingView() } else if model.groups.isEmpty { - emptyView + VaultHistoryEmptyView( + hasActiveFilters: model.hasActiveFilters, + onClearFilters: model.clearFilters + ) } else { - timelineList + VaultHistoryTimelineList( + groups: model.groups, + resumeEntriesByEventId: model.resumeEntriesByEventId, + availableClosedItemIds: closedItemStore.recordIdsSnapshot, + actions: VaultHistoryRowActions( + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) + ) } } .onAppear { @@ -56,222 +84,69 @@ private struct VaultHistoryContentView: View { } } - 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 func reload() { + sessionStore.reload() + model.refresh(sessionEntries: sessionStore.entries) } +} - private var loadingView: some View { +private struct VaultHistoryLoadingView: View { + var body: some View { VStack(spacing: 6) { - ProgressView().controlSize(.small) + 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 +private struct VaultHistoryEmptyView: View { + let hasActiveFilters: Bool + let onClearFilters: () -> Void var body: some View { - HStack(spacing: 4) { + VStack(spacing: 6) { 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) + .cmuxFont(size: 12) .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) + Text(subtitle) + .cmuxFont(size: 11) .foregroundColor(.secondary.opacity(0.7)) - .help(Self.absoluteFormatter.string(from: event.timestamp)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + if hasActiveFilters { + Button { + onClearFilters() + } label: { + Text(String(localized: "vaultHistory.filters.clear", defaultValue: "Clear filters")) + } + .buttonStyle(.link) + .controlSize(.small) + } } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - 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 title: String { + if hasActiveFilters { + return String(localized: "vaultHistory.empty.filtered.title", defaultValue: "No matching history") } + return String(localized: "vaultHistory.empty.title", defaultValue: "No history yet") } 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 + if hasActiveFilters { + return String( + localized: "vaultHistory.empty.filtered.subtitle", + defaultValue: "Try another search or time range." + ) + } + return String( + localized: "vaultHistory.empty.subtitle", + defaultValue: "Workspace, window, and agent session activity will appear here." ) } } diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 520da24186b6..13b24d157324 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -1,8 +1,7 @@ import CmuxFoundation import SwiftUI -/// Sub-navigation inside the Vault sidebar mode: the existing agent session -/// index ("Sessions") and the unified activity timeline ("History"). +/// Sub-navigation inside History: the unified timeline and agent-session index. enum VaultPaneTab: String, CaseIterable, Identifiable { case sessions case history @@ -14,7 +13,7 @@ enum VaultPaneTab: String, CaseIterable, Identifiable { case .sessions: return String(localized: "vaultPane.tab.sessions", defaultValue: "Sessions") case .history: - return String(localized: "vaultPane.tab.history", defaultValue: "History") + return String(localized: "vaultPane.tab.history", defaultValue: "Timeline") } } @@ -26,16 +25,18 @@ enum VaultPaneTab: String, CaseIterable, Identifiable { } } -/// Hosts the Vault mode's content behind a Sessions/History tab bar. Both +/// Hosts History behind Timeline/Sessions tabs. 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 + @ObservedObject var closedItemStore: ClosedItemHistoryStore let onResume: ((SessionEntry) -> Void)? - @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.sessions.rawValue + let onReopenClosedItem: ((UUID) -> Bool)? + @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.history.rawValue private var selectedTab: VaultPaneTab { - VaultPaneTab(rawValue: selectedTabRawValue) ?? .sessions + VaultPaneTab(rawValue: selectedTabRawValue) ?? .history } var body: some View { @@ -45,7 +46,13 @@ struct VaultPaneView: View { case .sessions: SessionIndexView(store: store, onResume: onResume) case .history: - VaultHistoryView(sessionStore: store, log: .shared) + VaultHistoryView( + sessionStore: store, + closedItemStore: closedItemStore, + log: .shared, + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) } } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 053c0fe1e13a..e418d714dd61 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2251,15 +2251,20 @@ 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 */; }; + AE727B475E634C9BA35472F6 /* VaultHistoryControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */; }; 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */; }; 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */; }; + 1E4037AE5B6947EDAAF6B26B /* VaultHistoryEventRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE36838BDA43D7976EC525 /* VaultHistoryEventRow.swift */; }; A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */; }; 59E6509DA68F5DB3550040DC /* VaultHistoryEventStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE06154F66355DB92D79141C /* VaultHistoryEventStoreTests.swift */; }; + 8C033D21369C4DB08F9034ED /* VaultHistoryGroupHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C22513C01441885554BD0 /* VaultHistoryGroupHeader.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 */; }; + 303270CA8AC548E59B433701 /* VaultHistoryQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9ACCCA76B04B2D8225691F /* VaultHistoryQuery.swift */; }; C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */; }; 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */; }; + 4A1150B7AB574080BB627944 /* VaultHistoryTimelineList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.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 */; }; @@ -4662,15 +4667,20 @@ 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 = ""; }; + 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryControls.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 = ""; }; + D0CE36838BDA43D7976EC525 /* VaultHistoryEventRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryEventRow.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 = ""; }; + 174C22513C01441885554BD0 /* VaultHistoryGroupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryGroupHeader.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 = ""; }; + 1F9ACCCA76B04B2D8225691F /* VaultHistoryQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryQuery.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 = ""; }; + C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryTimelineList.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 = ""; }; @@ -6531,6 +6541,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */, C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */, EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */, + 1F9ACCCA76B04B2D8225691F /* VaultHistoryQuery.swift */, + 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */, + C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.swift */, + 174C22513C01441885554BD0 /* VaultHistoryGroupHeader.swift */, + D0CE36838BDA43D7976EC525 /* VaultHistoryEventRow.swift */, 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */, BCBFF52FD6507CA579058F27 /* VaultPaneView.swift */, 5E034666E49FE351590FF447 /* VaultHistoryLifecycleRecording.swift */, @@ -9297,13 +9312,18 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */, 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */, B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */, + AE727B475E634C9BA35472F6 /* VaultHistoryControls.swift in Sources */, 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */, 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */, + 1E4037AE5B6947EDAAF6B26B /* VaultHistoryEventRow.swift in Sources */, A496AB305E8A7435CECAE73E /* VaultHistoryEventStore.swift in Sources */, + 8C033D21369C4DB08F9034ED /* VaultHistoryGroupHeader.swift in Sources */, 54D2FC0F493B36ACF14EB2CA /* VaultHistoryGrouping.swift in Sources */, 43BD568F3478D9570FE15C58 /* VaultHistoryLifecycleRecording.swift in Sources */, + 303270CA8AC548E59B433701 /* VaultHistoryQuery.swift in Sources */, C28468F9A88212D60EB5DA05 /* VaultHistoryRetentionPolicy.swift in Sources */, 609ADABD10C16EA8FAF9D794 /* VaultHistorySessionEvents.swift in Sources */, + 4A1150B7AB574080BB627944 /* VaultHistoryTimelineList.swift in Sources */, 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */, 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */, F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */, diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index cb76c6873fba..9c827d8cf7d1 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3440,9 +3440,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ("focus", ["right-sidebar", "focus"], "right_sidebar focus", "OK", ""), ("set-find", ["right-sidebar", "set", "find"], "right_sidebar set find", "OK", ""), ("set-no-focus", ["right-sidebar", "set", "vault", "--no-focus"], "right_sidebar set vault --no-focus", "OK", ""), + ("set-history", ["right-sidebar", "set", "history"], "right_sidebar set history", "OK", ""), ("set-sessions", ["right-sidebar", "set", "sessions"], "right_sidebar set sessions", "OK", ""), ("files-alias", ["right-sidebar", "files"], "right_sidebar set files", "OK", ""), ("find-alias", ["right-sidebar", "find"], "right_sidebar set find", "OK", ""), + ("history-alias", ["right-sidebar", "history"], "right_sidebar set history", "OK", ""), ("vault-alias", ["right-sidebar", "vault"], "right_sidebar set vault", "OK", ""), ("sessions-alias", ["right-sidebar", "sessions"], "right_sidebar set sessions", "OK", ""), ("feed-alias", ["right-sidebar", "feed"], "right_sidebar set feed", "OK", ""), diff --git a/cmuxTests/FileExplorerStateModePersistenceTests.swift b/cmuxTests/FileExplorerStateModePersistenceTests.swift index 6295f0318671..eea53fb5eafd 100644 --- a/cmuxTests/FileExplorerStateModePersistenceTests.swift +++ b/cmuxTests/FileExplorerStateModePersistenceTests.swift @@ -75,14 +75,15 @@ final class FileExplorerStateModePersistenceTests: XCTestCase { } } - func testCLIArgumentNormalizerMapsVaultAndSessionsToSessions() { + func testCLIArgumentNormalizerMapsHistoryAndLegacyNamesToSessions() { XCTAssertEqual(RightSidebarMode.from(cliArgument: "files"), .files) XCTAssertEqual(RightSidebarMode.from(cliArgument: "find"), .find) + XCTAssertEqual(RightSidebarMode.from(cliArgument: "history"), .sessions) XCTAssertEqual(RightSidebarMode.from(cliArgument: "vault"), .sessions) XCTAssertEqual(RightSidebarMode.from(cliArgument: "sessions"), .sessions) XCTAssertEqual(RightSidebarMode.from(cliArgument: "feed"), .feed) XCTAssertEqual(RightSidebarMode.from(cliArgument: "dock"), .dock) - XCTAssertEqual(RightSidebarMode.from(cliArgument: " Vault "), .sessions) + XCTAssertEqual(RightSidebarMode.from(cliArgument: " History "), .sessions) XCTAssertNil(RightSidebarMode.from(cliArgument: "custom-sidebar")) XCTAssertNil(RightSidebarMode.from(cliArgument: "custom")) XCTAssertNil(RightSidebarMode.from(cliArgument: "unknown")) diff --git a/cmuxTests/RightSidebarRemoteCommandTests.swift b/cmuxTests/RightSidebarRemoteCommandTests.swift index 0d320bf5ebb4..f26b9faed768 100644 --- a/cmuxTests/RightSidebarRemoteCommandTests.swift +++ b/cmuxTests/RightSidebarRemoteCommandTests.swift @@ -43,6 +43,9 @@ extension TerminalControllerSocketSecurityTests { #expect(TerminalController.shared.handleSocketLine("right_sidebar set sessions --no-focus") == "OK") #expect(fileExplorerState.mode == .sessions) + #expect(TerminalController.shared.handleSocketLine("right_sidebar set history --no-focus") == "OK") + #expect(fileExplorerState.mode == .sessions) + #expect(TerminalController.shared.handleSocketLine("right_sidebar hide") == "OK") #expect(!fileExplorerState.isVisible) @@ -136,6 +139,10 @@ extension TerminalControllerSocketSecurityTests { "right_sidebar set vault --no-focus", RightSidebarRemoteRequest(command: .setMode(.sessions, focus: false), target: RightSidebarRemoteTarget()) ), + ( + "right_sidebar history", + RightSidebarRemoteRequest(command: .setMode(.sessions, focus: true), target: RightSidebarRemoteTarget()) + ), ( "right_sidebar sessions", RightSidebarRemoteRequest(command: .setMode(.sessions, focus: true), target: RightSidebarRemoteTarget()) @@ -187,6 +194,7 @@ extension TerminalControllerSocketSecurityTests { ("right_sidebar show", true), ("right_sidebar focus", true), ("right_sidebar set find", true), + ("right_sidebar history", true), ("right_sidebar sessions", true), ("right_sidebar set vault --no-focus", false), ("right_sidebar hide", false), diff --git a/cmuxTests/VaultHistoryEventStoreTests.swift b/cmuxTests/VaultHistoryEventStoreTests.swift index 95be170d8c7e..8d524cd8b056 100644 --- a/cmuxTests/VaultHistoryEventStoreTests.swift +++ b/cmuxTests/VaultHistoryEventStoreTests.swift @@ -50,7 +50,7 @@ import Testing kind: .windowClosed, title: "win", workspaceCount: 3, - subject: VaultHistorySubject(windowId: UUID()) + subject: VaultHistorySubject(windowId: UUID(), closedItemId: UUID()) ) await store.append(created) await store.append(closed) @@ -61,6 +61,7 @@ import Testing #expect(events.count == 2) #expect(events.first?.id == "e2") #expect(events.first?.workspaceCount == 3) + #expect(events.first?.subject.closedItemId == closed.subject.closedItemId) #expect(events.last?.id == "e1") #expect(events.last?.previousTitle == "original") #expect(events.last?.subject.directory == "/tmp/project") diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index d39605846cea..0e9aab9f5521 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -26,7 +26,8 @@ import Testing title: String = "", workspaceId: UUID? = nil, windowId: UUID? = nil, - agent: String? = nil + agent: String? = nil, + directory: String? = nil ) -> VaultHistoryEvent { VaultHistoryEvent( id: id, @@ -37,7 +38,8 @@ import Testing workspaceId: workspaceId, windowId: windowId, sessionId: agent == nil ? nil : "session-\(id)", - agent: agent + agent: agent, + directory: directory ) ) } @@ -136,7 +138,33 @@ import Testing #expect(groups[0].events.map(\.id) == ["w-close", "w-open"]) } - // MARK: - Group by agent and kind + // MARK: - Group by directory, agent, and kind + + @Test func directoryGroupingNormalizesPathsAndKeepsUnknownLast() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let events = [ + event( + id: "repo-old", + secondsAgo: 300, + title: "old", + directory: "/tmp/project/../repo" + ), + event( + id: "repo-new", + secondsAgo: 20, + title: "new", + directory: "/tmp/repo/" + ), + event(id: "unknown", secondsAgo: 10, title: "unknown"), + ] + + let groups = grouper.groups(events: events, by: .directory, now: Self.now) + + #expect(groups.map(\.id) == ["directory:/tmp/repo", VaultHistoryGrouper.otherGroupID]) + #expect(groups[0].title == "/tmp/repo") + #expect(groups[0].events.map(\.id) == ["repo-new", "repo-old"]) + #expect(groups[1].events.map(\.id) == ["unknown"]) + } @Test func agentGroupingSeparatesAgentsFromAppEvents() { let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) @@ -166,6 +194,73 @@ import Testing #expect(groups[0].events.map(\.id) == ["c2", "c1"]) } + // MARK: - Filtering, search, and sorting + + @Test func timeRangesUseRollingBoundaries() { + let inside24Hours = event(id: "inside-24h", secondsAgo: 24 * 3600 - 1) + let outside24Hours = event(id: "outside-24h", secondsAgo: 24 * 3600 + 1) + let inside7Days = event(id: "inside-7d", secondsAgo: 7 * 24 * 3600 - 1) + let outside7Days = event(id: "outside-7d", secondsAgo: 7 * 24 * 3600 + 1) + let events = [outside7Days, inside7Days, outside24Hours, inside24Hours] + + let last24Hours = VaultHistoryQuery(timeRange: .last24Hours) + .visibleEvents(from: events, now: Self.now) + #expect(last24Hours.map(\.id) == ["inside-24h"]) + + let last7Days = VaultHistoryQuery(timeRange: .last7Days) + .visibleEvents(from: events, now: Self.now) + #expect(last7Days.map(\.id) == ["inside-24h", "outside-24h", "inside-7d"]) + } + + @Test func searchMatchesEveryTokenAcrossMetadataFields() { + let events = [ + event( + id: "matching", + secondsAgo: 20, + kind: .sessionActivity, + title: "Fix restore", + agent: "codex", + directory: "/Users/me/cmux" + ), + event( + id: "wrong-directory", + secondsAgo: 10, + kind: .sessionActivity, + title: "Fix restore", + agent: "codex", + directory: "/Users/me/other" + ), + ] + let query = VaultHistoryQuery(searchText: "codex cmux restore") + + #expect(query.visibleEvents(from: events, now: Self.now).map(\.id) == ["matching"]) + } + + @Test func sortOrdersApplyToRowsAndDateBuckets() { + let grouper = VaultHistoryGrouper(calendar: Self.utcCalendar()) + let events = [ + event(id: "beta-old", secondsAgo: 40 * 24 * 3600, title: "Beta"), + event(id: "zulu-new", secondsAgo: 10, title: "Zulu"), + event(id: "alpha-mid", secondsAgo: 25 * 3600, title: "Alpha"), + ] + + let oldest = grouper.groups( + events: events, + by: .date, + query: VaultHistoryQuery(sortOrder: .oldestFirst), + now: Self.now + ) + #expect(oldest.map(\.id) == [ + "date:\(VaultHistoryDateBucket.older.rawValue)", + "date:\(VaultHistoryDateBucket.yesterday.rawValue)", + "date:\(VaultHistoryDateBucket.last24Hours.rawValue)", + ]) + + let byTitle = VaultHistoryQuery(sortOrder: .titleAscending) + .visibleEvents(from: events, now: Self.now) + #expect(byTitle.map(\.id) == ["alpha-mid", "beta-old", "zulu-new"]) + } + // MARK: - Session projection @Test func sessionProjectionProducesStableDerivedEvents() throws { @@ -192,7 +287,38 @@ import Testing #expect(event.timestamp == Date(timeIntervalSince1970: 1_700_000_000)) #expect(event.title == "Fix the tests") #expect(event.subject.agent == "claude") + #expect(event.subject.agentDisplayName == "Claude Code") #expect(event.subject.sessionId == "abc123") #expect(event.subject.directory == "/tmp/repo") } + + @Test func customAgentDisplayNameSurvivesProjectionAndGrouping() throws { + let entry = SessionEntry( + id: "custom-1", + agent: .registered(RegisteredSessionAgent(id: "reviewer", name: "Review Bot")), + sessionId: "custom-1", + title: "Review changes", + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Self.now, + fileURL: nil, + specifics: .registered(CmuxVaultAgentRegistration( + id: "reviewer", + name: "Review Bot", + detect: CmuxVaultAgentDetectRule(processName: "reviewer"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "reviewer --resume {{sessionId}}" + )) + ) + let event = try #require(VaultHistorySessionEventProjection().events(from: [entry]).first) + + #expect(event.subject.agentDisplayName == "Review Bot") + let groups = VaultHistoryGrouper(calendar: Self.utcCalendar()).groups( + events: [event], + by: .agent, + now: Self.now + ) + #expect(groups.first?.title == "Review Bot") + } } diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 4b33a75021d7..9a8e22f87684 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -381,8 +381,8 @@ Right sidebar commands: | --- | --- | | `right-sidebar toggle`, `right-sidebar show`, `right-sidebar hide` | Change right-sidebar visibility without printing on success. | | `right-sidebar focus` | Focus the current right-sidebar mode. | -| `right-sidebar set ` | Show the right sidebar, switch mode, and focus it unless `--no-focus` is passed. | -| `right-sidebar files`, `right-sidebar find`, `right-sidebar vault`, `right-sidebar sessions`, `right-sidebar feed`, `right-sidebar dock` | Short aliases for `right-sidebar set ` with focus. | +| `right-sidebar set ` | Show the right sidebar, switch mode, and focus it unless `--no-focus` is passed. | +| `right-sidebar files`, `right-sidebar find`, `right-sidebar history`, `right-sidebar sessions`, `right-sidebar vault`, `right-sidebar feed`, `right-sidebar dock` | Short aliases for `right-sidebar set ` with focus. `vault` and `sessions` remain compatibility aliases for History. | | `right-sidebar mode` | Print JSON with `visible` and `mode`. | | `--workspace ` | Target the window containing a workspace. Refs and indexes resolve before the V1 socket command is sent. | | `--window ` | Target a window. Refs and indexes resolve before the V1 socket command is sent. | From e5166fad2806ac2db3d138bfc8ddedb7a5201c4e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:57:52 -0700 Subject: [PATCH 02/10] Use AppKit virtualization for History --- Sources/VaultHistoryEventRow.swift | 343 +++++++++++++++++----- Sources/VaultHistoryGroupHeader.swift | 142 +++++++-- Sources/VaultHistoryTimelineList.swift | 302 +++++++++++++++++-- cmuxTests/VaultHistoryGroupingTests.swift | 158 ++++++++++ 4 files changed, 825 insertions(+), 120 deletions(-) diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index 39da460eff58..6a83ed87fb80 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -1,4 +1,6 @@ -import SwiftUI +import AppKit +import CmuxAppKitSupportUI +import CmuxFoundation enum VaultHistoryRowAction: Equatable { case resumeSession(SessionEntry) @@ -30,88 +32,267 @@ struct VaultHistoryRowActions { func perform(_ action: VaultHistoryRowAction) { switch action { - case .resumeSession(let entry): - onResume?(entry) - case .reopenClosedItem(let id): - _ = onReopenClosedItem?(id) + case .resumeSession(let entry): onResume?(entry) + case .reopenClosedItem(let id): _ = onReopenClosedItem?(id) } } } -/// Value-only History row with optional resume or reopen behavior. -struct VaultHistoryEventRow: View, Equatable { - let event: VaultHistoryEvent - let action: VaultHistoryRowAction? - let actions: VaultHistoryRowActions - @State private var isHovered = false - - nonisolated static func == (lhs: VaultHistoryEventRow, rhs: VaultHistoryEventRow) -> Bool { - lhs.event == rhs.event && lhs.action == rhs.action - } - - 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, style: .relative) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.7)) - .help(event.timestamp.formatted(date: .abbreviated, time: .shortened)) - if let action { - Button { - actions.perform(action) - } label: { - Image(systemName: action.symbolName) - .cmuxFont(size: 9, weight: .medium) - } - .buttonStyle(.borderless) - .accessibilityLabel(action.label) - .help(action.label) - } +/// Recycled pure-AppKit History event row. +@MainActor +final class VaultHistoryTableEventCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("VaultHistoryTableEventCellView") + + private let hoverBackground = VaultHistoryHitTransparentView() + private let iconView = CmuxResolvedIconImageView() + private let titleLabel = NSTextField(labelWithString: "") + private let subtitleLabel = NSTextField(labelWithString: "") + private let timeLabel = NSTextField(labelWithString: "") + private let actionButton = NSButton() + private lazy var textStack = NSStackView(views: [titleLabel, subtitleLabel]) + private var actionButtonWidthConstraint: NSLayoutConstraint! + private var representedEvent: VaultHistoryEvent? + private var representedAction: VaultHistoryRowAction? + private var representedAgent: SessionAgent? + private var representedFontPercent = GlobalFontMagnification.defaultPercent + private var onPerformAction: ((VaultHistoryRowAction) -> Void)? + private var isPointerHovering = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = Self.reuseIdentifier + wantsLayer = true + + hoverBackground.translatesAutoresizingMaskIntoConstraints = false + hoverBackground.wantsLayer = true + hoverBackground.layer?.cornerRadius = 4 + hoverBackground.layer?.cornerCurve = .continuous + addSubview(hoverBackground) + + iconView.translatesAutoresizingMaskIntoConstraints = false + addSubview(iconView) + + for label in [titleLabel, subtitleLabel, timeLabel] { + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.cell?.truncatesLastVisibleLine = true + label.setAccessibilityElement(false) + } + titleLabel.textColor = NSColor.labelColor.withAlphaComponent(0.85) + subtitleLabel.textColor = .secondaryLabelColor + timeLabel.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.7) + timeLabel.alignment = .right + timeLabel.setContentHuggingPriority(.required, for: .horizontal) + timeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + textStack.translatesAutoresizingMaskIntoConstraints = false + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.distribution = .fill + textStack.spacing = 1 + addSubview(textStack) + + actionButton.translatesAutoresizingMaskIntoConstraints = false + actionButton.isBordered = false + actionButton.imagePosition = .imageOnly + actionButton.focusRingType = .none + actionButton.target = self + actionButton.action = #selector(performRepresentedAction(_:)) + addSubview(actionButton) + actionButtonWidthConstraint = actionButton.widthAnchor.constraint(equalToConstant: 0) + + timeLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(timeLabel) + NSLayoutConstraint.activate([ + hoverBackground.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + hoverBackground.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + hoverBackground.topAnchor.constraint(equalTo: topAnchor, constant: 1), + hoverBackground.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), + + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 14), + iconView.heightAnchor.constraint(equalToConstant: 14), + + textStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6), + textStack.centerYAnchor.constraint(equalTo: centerYAnchor), + + actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + actionButton.centerYAnchor.constraint(equalTo: centerYAnchor), + actionButtonWidthConstraint, + actionButton.heightAnchor.constraint(equalToConstant: 18), + + timeLabel.trailingAnchor.constraint(equalTo: actionButton.leadingAnchor, constant: -4), + timeLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + timeLabel.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 4), + textStack.trailingAnchor.constraint(lessThanOrEqualTo: timeLabel.leadingAnchor, constant: -4), + ]) + + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited], + owner: self, + userInfo: nil + )) + updateHoverPresentation() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + representedEvent = nil + representedAction = nil + representedAgent = nil + onPerformAction = nil + iconView.apply(nil) + menu = nil + } + + func configure( + event: VaultHistoryEvent, + action: VaultHistoryRowAction?, + agent: SessionAgent?, + globalFontMagnificationPercent: Int, + onPerformAction: @escaping (VaultHistoryRowAction) -> Void + ) { + self.onPerformAction = onPerformAction + guard representedEvent != event + || representedAction != action + || representedAgent != agent + || representedFontPercent != globalFontMagnificationPercent else { + updateRelativeTimestamp(for: event) + return } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isHovered ? Color.primary.opacity(0.05) : Color.clear) - .padding(.horizontal, 4) + representedEvent = event + representedAction = action + representedAgent = agent + representedFontPercent = globalFontMagnificationPercent + + titleLabel.stringValue = Self.displayTitle(for: event) + subtitleLabel.stringValue = Self.subtitle(for: event) + titleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(11.5, percent: globalFontMagnificationPercent) ) - .onHover { isHovered = $0 } - .onTapGesture(count: 2) { - if let action { - actions.perform(action) + subtitleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) + ) + timeLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) + ) + configureIcon(event: event, agent: agent) + configureAction(action) + updateRelativeTimestamp(for: event) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier("VaultHistoryEventRow:\(event.id)") + setAccessibilityLabel([ + titleLabel.stringValue, + subtitleLabel.stringValue, + timeLabel.stringValue, + ].filter { !$0.isEmpty }.joined(separator: ", ")) + } + + override func mouseEntered(with event: NSEvent) { + isPointerHovering = true + updateHoverPresentation() + } + + override func mouseExited(with event: NSEvent) { + isPointerHovering = false + updateHoverPresentation() + } + + private func configureIcon(event: VaultHistoryEvent, agent: SessionAgent?) { + if event.kind == .sessionActivity, let agent { + let description = event.subject.agentDisplayName ?? agent.displayName + let source: CmuxResolvedIconSource + if let assetName = agent.assetName { + source = .asset(name: assetName, bundle: .main) + } else { + source = .systemSymbol( + name: agent.systemImageName ?? "person.crop.circle", + accessibilityDescription: description + ) } + iconView.apply(CmuxResolvedIconRequest( + source: source, + size: NSSize(width: 14, height: 14), + tintColor: agent.assetName == nil ? .secondaryLabelColor : nil + )) + return } - .contextMenu { - if let action { - Button { - actions.perform(action) - } label: { - Label(action.label, systemImage: action.symbolName) - } - } + iconView.apply(CmuxResolvedIconRequest( + source: .systemSymbol(name: event.kind.symbolName, accessibilityDescription: event.kind.label), + size: NSSize(width: 14, height: 14), + tintColor: .secondaryLabelColor + )) + } + + private func configureAction(_ action: VaultHistoryRowAction?) { + representedAction = action + guard let action else { + actionButton.isHidden = true + actionButtonWidthConstraint.constant = 0 + actionButton.image = nil + actionButton.toolTip = nil + actionButton.setAccessibilityLabel(nil) + menu = nil + return } - .accessibilityElement(children: .combine) + actionButton.isHidden = false + actionButtonWidthConstraint.constant = 18 + actionButton.image = NSImage( + systemSymbolName: action.symbolName, + accessibilityDescription: action.label + )?.withSymbolConfiguration(.init(pointSize: 9, weight: .medium)) + actionButton.contentTintColor = .secondaryLabelColor + actionButton.toolTip = action.label + actionButton.setAccessibilityLabel(action.label) + + let contextMenu = NSMenu() + let item = NSMenuItem( + title: action.label, + action: #selector(performRepresentedAction(_:)), + keyEquivalent: "" + ) + item.target = self + item.image = NSImage(systemSymbolName: action.symbolName, accessibilityDescription: nil) + contextMenu.addItem(item) + menu = contextMenu } - private var displayTitle: String { + private func updateRelativeTimestamp(for event: VaultHistoryEvent) { + timeLabel.stringValue = Self.relativeFormatter.localizedString( + for: event.timestamp, + relativeTo: Date() + ) + timeLabel.toolTip = event.timestamp.formatted(date: .abbreviated, time: .shortened) + } + + private func updateHoverPresentation() { + hoverBackground.layer?.backgroundColor = isPointerHovering + ? NSColor.labelColor.withAlphaComponent(0.05).cgColor + : NSColor.clear.cgColor + } + + @objc private func performRepresentedAction(_ sender: Any?) { + guard let representedAction else { return } + onPerformAction?(representedAction) + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() + + static func displayTitle(for event: VaultHistoryEvent) -> String { let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty else { return trimmed } + guard trimmed.isEmpty else { return truncated(trimmed, maximumLength: 240) } switch event.kind { case .windowOpened, .windowClosed: return String(localized: "vaultHistory.window", defaultValue: "Window") @@ -120,13 +301,17 @@ struct VaultHistoryEventRow: View, Equatable { } } - private var subtitle: String { + private static func truncated(_ value: String, maximumLength: Int) -> String { + guard value.count > maximumLength else { return value } + return String(value.prefix(maximumLength)) + "…" + } + + private static func subtitle(for event: VaultHistoryEvent) -> String { var parts: [String] = [] if event.kind == .sessionActivity { if let displayName = event.subject.agentDisplayName, !displayName.isEmpty { parts.append(displayName) - } else if let raw = event.subject.agent, - let agent = SessionAgent(rawValue: raw) { + } else if let raw = event.subject.agent, let agent = SessionAgent(rawValue: raw) { parts.append(agent.displayName) } else { parts.append(event.kind.label) @@ -143,13 +328,11 @@ struct VaultHistoryEventRow: View, Equatable { )) } if let count = event.workspaceCount { - parts.append(Self.workspaceCountLabel(count)) + parts.append(workspaceCountLabel(count)) } if let directory = event.subject.directory, !directory.isEmpty { let component = (directory as NSString).lastPathComponent - if !component.isEmpty, component != "." { - parts.append(component) - } + if !component.isEmpty, component != "." { parts.append(component) } } return parts.joined(separator: " · ") } @@ -164,3 +347,7 @@ struct VaultHistoryEventRow: View, Equatable { ) } } + +private final class VaultHistoryHitTransparentView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift index c854d3ec324d..14a6deda8d71 100644 --- a/Sources/VaultHistoryGroupHeader.swift +++ b/Sources/VaultHistoryGroupHeader.swift @@ -1,25 +1,123 @@ -import SwiftUI - -/// Pinned section heading for a group of History events. -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) - .help(title) - Text(count, format: .number) - .cmuxFont(size: 10) - .foregroundColor(.secondary.opacity(0.6)) - Spacer(minLength: 0) +import AppKit +import CmuxAppKitSupportUI +import CmuxFoundation + +/// Recycled pure-AppKit History group header, floated by `NSTableView`. +@MainActor +final class VaultHistoryTableGroupCellView: NSTableCellView { + static let reuseIdentifier = NSUserInterfaceItemIdentifier("VaultHistoryTableGroupCellView") + + private let backgroundView = NSVisualEffectView() + private let iconView = CmuxResolvedIconImageView() + private let titleLabel = NSTextField(labelWithString: "") + private let countLabel = NSTextField(labelWithString: "") + private var iconWidthConstraint: NSLayoutConstraint! + private var iconToTitleConstraint: NSLayoutConstraint! + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = Self.reuseIdentifier + + backgroundView.translatesAutoresizingMaskIntoConstraints = false + backgroundView.material = .sidebar + backgroundView.blendingMode = .withinWindow + backgroundView.state = .followsWindowActiveState + addSubview(backgroundView) + + iconView.translatesAutoresizingMaskIntoConstraints = false + addSubview(iconView) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 1 + titleLabel.textColor = .secondaryLabelColor + titleLabel.setAccessibilityElement(false) + addSubview(titleLabel) + + countLabel.translatesAutoresizingMaskIntoConstraints = false + countLabel.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.6) + countLabel.setContentHuggingPriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + countLabel.setAccessibilityElement(false) + addSubview(countLabel) + + iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: 0) + iconToTitleConstraint = titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor) + NSLayoutConstraint.activate([ + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconWidthConstraint, + iconView.heightAnchor.constraint(equalToConstant: 12), + + iconToTitleConstraint, + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + countLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 4), + countLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + countLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + iconView.apply(nil) + } + + func configure( + id: String, + title: String, + count: Int, + agent: SessionAgent?, + globalFontMagnificationPercent: Int + ) { + titleLabel.stringValue = title + titleLabel.toolTip = title + titleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(11, percent: globalFontMagnificationPercent), + weight: .semibold + ) + countLabel.stringValue = count.formatted() + countLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) + ) + configureIcon(agent) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier("VaultHistoryGroup:\(id)") + setAccessibilityLabel("\(title), \(count.formatted())") + } + + private func configureIcon(_ agent: SessionAgent?) { + guard let agent else { + iconView.apply(nil) + iconWidthConstraint.constant = 0 + iconToTitleConstraint.constant = 0 + return + } + let source: CmuxResolvedIconSource + if let assetName = agent.assetName { + source = .asset(name: assetName, bundle: .main) + } else { + source = .systemSymbol( + name: agent.systemImageName ?? "person.crop.circle", + accessibilityDescription: agent.displayName + ) } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.bar) + iconWidthConstraint.constant = 12 + iconToTitleConstraint.constant = 4 + iconView.apply(CmuxResolvedIconRequest( + source: source, + size: NSSize(width: 12, height: 12), + tintColor: agent.assetName == nil ? .secondaryLabelColor : nil + )) } } diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift index dd797839d194..2f99e5e40bf9 100644 --- a/Sources/VaultHistoryTimelineList.swift +++ b/Sources/VaultHistoryTimelineList.swift @@ -1,34 +1,88 @@ +import AppKit +import CmuxAppKitSupportUI +import CmuxFoundation import SwiftUI -/// Immutable list projection kept below History's lazy layout boundary. -struct VaultHistoryTimelineList: View { +/// SwiftUI bridge for the AppKit-virtualized History timeline. +struct VaultHistoryTimelineList: NSViewRepresentable { let groups: [VaultHistoryGroup] let resumeEntriesByEventId: [String: SessionEntry] let availableClosedItemIds: Set let actions: VaultHistoryRowActions + @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontMagnificationPercent - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(groups) { group in - Section { - ForEach(group.events) { event in - VaultHistoryEventRow( - event: event, - action: rowAction(for: event), - actions: actions - ) - } - } header: { - VaultHistoryGroupHeader(title: group.title, count: group.events.count) - } - } + func makeCoordinator() -> VaultHistoryTableController { + VaultHistoryTableController() + } + + func makeNSView(context: Context) -> VaultHistoryTableContainerView { + context.coordinator.makeContainerView() + } + + func updateNSView(_ nsView: VaultHistoryTableContainerView, context: Context) { + context.coordinator.apply( + rows: Self.makeRows( + groups: groups, + resumeEntriesByEventId: resumeEntriesByEventId, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ), + actions: actions, + globalFontMagnificationPercent: globalFontMagnificationPercent + ) + } + + static func makeRows( + groups: [VaultHistoryGroup], + resumeEntriesByEventId: [String: SessionEntry], + availableClosedItemIds: Set, + actions: VaultHistoryRowActions + ) -> [VaultHistoryTableRow] { + groups.flatMap { group in + let groupAgent = group.id.hasPrefix("agent:") + ? group.events.lazy.compactMap { + resolvedAgent(for: $0, resumeEntriesByEventId: resumeEntriesByEventId) + }.first + : nil + let header = VaultHistoryTableRow.group( + id: group.id, + title: group.title, + count: group.events.count, + agent: groupAgent + ) + let events = group.events.map { event in + VaultHistoryTableRow.event( + event: event, + action: rowAction( + for: event, + resumeEntriesByEventId: resumeEntriesByEventId, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ), + agent: resolvedAgent( + for: event, + resumeEntriesByEventId: resumeEntriesByEventId + ) + ) } + return [header] + events } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func rowAction(for event: VaultHistoryEvent) -> VaultHistoryRowAction? { + private static func resolvedAgent( + for event: VaultHistoryEvent, + resumeEntriesByEventId: [String: SessionEntry] + ) -> SessionAgent? { + resumeEntriesByEventId[event.id]?.agent + ?? event.subject.agent.flatMap(SessionAgent.init(rawValue:)) + } + + private static func rowAction( + for event: VaultHistoryEvent, + resumeEntriesByEventId: [String: SessionEntry], + availableClosedItemIds: Set, + actions: VaultHistoryRowActions + ) -> VaultHistoryRowAction? { if let entry = resumeEntriesByEventId[event.id], actions.canResume { return .resumeSession(entry) } @@ -40,3 +94,211 @@ struct VaultHistoryTimelineList: View { return nil } } + +enum VaultHistoryTableRowID: Hashable { + case group(String) + case event(String) +} + +enum VaultHistoryTableRow { + case group(id: String, title: String, count: Int, agent: SessionAgent?) + case event(event: VaultHistoryEvent, action: VaultHistoryRowAction?, agent: SessionAgent?) + + var id: VaultHistoryTableRowID { + switch self { + case .group(let id, _, _, _): return .group(id) + case .event(let event, _, _): return .event(event.id) + } + } + + var isGroup: Bool { + if case .group = self { return true } + return false + } + + func hasEquivalentContent(to other: Self) -> Bool { + switch (self, other) { + case let (.group(lhsID, lhsTitle, lhsCount, lhsAgent), .group(rhsID, rhsTitle, rhsCount, rhsAgent)): + return lhsID == rhsID && lhsTitle == rhsTitle && lhsCount == rhsCount && lhsAgent == rhsAgent + case let (.event(lhsEvent, lhsAction, lhsAgent), .event(rhsEvent, rhsAction, rhsAgent)): + return lhsEvent == rhsEvent && lhsAction == rhsAction && lhsAgent == rhsAgent + default: + return false + } + } +} + +@MainActor +final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTableViewDelegate { + private static let columnIdentifier = NSUserInterfaceItemIdentifier("history") + private weak var containerView: VaultHistoryTableContainerView? + private var rows: [VaultHistoryTableRow] = [] + private var actions = VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) + private var globalFontMagnificationPercent = GlobalFontMagnification.defaultPercent + private var pendingApply: ApplyInput? + private var isFlushScheduled = false + + func makeContainerView() -> VaultHistoryTableContainerView { + let container = VaultHistoryTableContainerView() + containerView = container + let table = container.tableView + table.dataSource = self + table.delegate = self + table.headerView = nil + table.style = .plain + table.backgroundColor = .clear + table.focusRingType = .none + table.gridStyleMask = [] + table.usesAlternatingRowBackgroundColors = false + table.selectionHighlightStyle = .none + table.allowsEmptySelection = true + table.allowsMultipleSelection = false + table.allowsTypeSelect = false + table.intercellSpacing = .zero + table.usesAutomaticRowHeights = false + table.floatsGroupRows = true + table.setAccessibilityIdentifier("VaultHistoryTimeline") + table.target = self + table.doubleAction = #selector(handleDoubleClick(_:)) + + let column = NSTableColumn(identifier: Self.columnIdentifier) + column.resizingMask = .autoresizingMask + table.addTableColumn(column) + + let scrollView = container.scrollView + scrollView.documentView = table + table.frame = scrollView.contentView.bounds + table.autoresizingMask = [.width] + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.hasHorizontalScroller = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentView.drawsBackground = false + scrollView.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) + scrollView.applySidebarOverlayScrollerConfiguration() + return container + } + + func apply( + rows: [VaultHistoryTableRow], + actions: VaultHistoryRowActions, + globalFontMagnificationPercent: Int + ) { + self.actions = actions + pendingApply = ApplyInput( + rows: rows, + globalFontMagnificationPercent: globalFontMagnificationPercent + ) + guard !isFlushScheduled else { return } + isFlushScheduled = true + RunLoop.main.perform(inModes: [.common]) { [weak self] in + MainActor.assumeIsolated { self?.flushPendingApply() } + } + } + + private func flushPendingApply() { + guard let input = pendingApply, let table = containerView?.tableView else { + pendingApply = nil + isFlushScheduled = false + return + } + pendingApply = nil + isFlushScheduled = false + let previousRows = rows + let presentationChanged = globalFontMagnificationPercent != input.globalFontMagnificationPercent + let structuralChanged = previousRows.map(\.id) != input.rows.map(\.id) + rows = input.rows + globalFontMagnificationPercent = input.globalFontMagnificationPercent + + if structuralChanged || presentationChanged { + table.reloadData() + return + } + let changedRows = IndexSet(input.rows.indices.filter { + !previousRows[$0].hasEquivalentContent(to: input.rows[$0]) + }) + guard !changedRows.isEmpty else { return } + table.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0)) + } + + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + + func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { + rows.indices.contains(row) && rows[row].isGroup + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + guard rows.indices.contains(row) else { return 36 } + let base: CGFloat = rows[row].isGroup ? 26 : 38 + return max(base, GlobalFontMagnification.scaledSize( + base, + percent: globalFontMagnificationPercent + )) + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard rows.indices.contains(row) else { return nil } + switch rows[row] { + case .group(let id, let title, let count, let agent): + let cell = (tableView.makeView( + withIdentifier: VaultHistoryTableGroupCellView.reuseIdentifier, + owner: self + ) as? VaultHistoryTableGroupCellView) ?? VaultHistoryTableGroupCellView() + cell.configure( + id: id, + title: title, + count: count, + agent: agent, + globalFontMagnificationPercent: globalFontMagnificationPercent + ) + return cell + case .event(let event, let action, let agent): + let cell = (tableView.makeView( + withIdentifier: VaultHistoryTableEventCellView.reuseIdentifier, + owner: self + ) as? VaultHistoryTableEventCellView) ?? VaultHistoryTableEventCellView() + cell.configure( + event: event, + action: action, + agent: agent, + globalFontMagnificationPercent: globalFontMagnificationPercent, + onPerformAction: { [weak self] action in self?.actions.perform(action) } + ) + return cell + } + } + + @objc private func handleDoubleClick(_ sender: NSTableView) { + let row = sender.clickedRow + guard rows.indices.contains(row), case .event(_, let action?, _) = rows[row] else { return } + actions.perform(action) + } + + private struct ApplyInput { + let rows: [VaultHistoryTableRow] + let globalFontMagnificationPercent: Int + } +} + +@MainActor +final class VaultHistoryTableContainerView: NSView { + let scrollView = NSScrollView() + let tableView = NSTableView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + scrollView.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index 0e9aab9f5521..0310a38b3212 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -321,4 +322,161 @@ import Testing ) #expect(groups.first?.title == "Review Bot") } + + @Test func appKitTimelineProjectionFlattensGroupsIntoStableVirtualRows() throws { + let event = event( + id: "codex-session", + secondsAgo: 5, + kind: .sessionActivity, + title: "Implement History", + agent: "codex" + ) + let group = VaultHistoryGroup(id: "agent:codex", title: "Codex", events: [event]) + + let rows = VaultHistoryTimelineList.makeRows( + groups: [group], + resumeEntriesByEventId: [:], + availableClosedItemIds: [], + actions: VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) + ) + + #expect(rows.map(\.id) == [.group("agent:codex"), .event("codex-session")]) + guard case .group(_, _, 1, let groupAgent) = rows[0] else { + Issue.record("Expected the first virtual row to be a group header") + return + } + #expect(groupAgent == .codex) + guard case .event(_, nil, let rowAgent) = rows[1] else { + Issue.record("Expected the second virtual row to be an event") + return + } + #expect(rowAgent == .codex) + #expect(rowAgent?.assetName == "AgentIcons/Codex") + } + + @Test func appKitTimelineUsesRegisteredAgentIconMetadataFromSessionEntry() throws { + let registeredAgent = SessionAgent.registered(RegisteredSessionAgent( + id: "reviewer", + name: "Review Bot", + iconAssetName: "AgentIcons/Pi" + )) + let entry = SessionEntry( + id: "custom-icon", + agent: registeredAgent, + sessionId: "custom-icon", + title: "Review changes", + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Self.now, + fileURL: nil, + specifics: .registered(CmuxVaultAgentRegistration( + id: "reviewer", + name: "Review Bot", + iconAssetName: "AgentIcons/Pi", + detect: CmuxVaultAgentDetectRule(processName: "reviewer"), + sessionIdSource: .argvOption("--resume"), + resumeCommand: "reviewer --resume {{sessionId}}" + )) + ) + let event = try #require(VaultHistorySessionEventProjection().events(from: [entry]).first) + let group = VaultHistoryGroup(id: "agent:reviewer", title: "Review Bot", events: [event]) + + let rows = VaultHistoryTimelineList.makeRows( + groups: [group], + resumeEntriesByEventId: [event.id: entry], + availableClosedItemIds: [], + actions: VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) + ) + + guard case .event(_, nil, let rowAgent) = rows[1] else { + Issue.record("Expected a virtualized event row") + return + } + #expect(rowAgent == registeredAgent) + #expect(rowAgent?.assetName == "AgentIcons/Pi") + } +} + +@Suite(.serialized) +struct VaultHistoryAppKitViewportTests { + @MainActor + @Test + func timelineRealizesOnlyViewportRowsAtScale() async throws { + let controller = VaultHistoryTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 300), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + container.frame = window.contentView?.bounds ?? .zero + + let events = (0..<5_000).map { index in + VaultHistoryTableRow.event( + event: VaultHistoryEvent( + id: "session-\(index)", + timestamp: Date(timeIntervalSince1970: TimeInterval(10_000 - index)), + kind: .sessionActivity, + title: "Synthetic session \(index)", + subject: VaultHistorySubject( + sessionId: "session-\(index)", + agent: "codex", + agentDisplayName: "Codex", + directory: "/tmp/history-scale" + ) + ), + action: nil, + agent: .codex + ) + } + controller.apply( + rows: [.group(id: "agent:codex", title: "Codex", count: events.count, agent: .codex)] + events, + actions: VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil), + globalFontMagnificationPercent: 100 + ) + + window.contentView?.layoutSubtreeIfNeeded() + await flushStagedTableMutations() + window.contentView?.layoutSubtreeIfNeeded() + + let table = container.tableView + let visibleRows = table.rows(in: table.visibleRect) + let realizedRows = (0.. 0) + #expect(table.numberOfRows > visibleRows.length) + #expect(realizedRows.count <= visibleRows.length + 2) + } + + @MainActor + @Test + func timelineCapsRenderedTranscriptTitlesWithoutChangingStoredEvent() { + let original = String(repeating: "session output ", count: 100) + let event = VaultHistoryEvent( + timestamp: Date(), + kind: .sessionActivity, + title: original + ) + + let rendered = VaultHistoryTableEventCellView.displayTitle(for: event) + + #expect(rendered.count == 241) + #expect(rendered.hasSuffix("…")) + #expect(event.title == original) + } + + @MainActor + private func flushStagedTableMutations() async { + await withCheckedContinuation { continuation in + RunLoop.main.perform(inModes: [.common]) { + continuation.resume() + } + } + } } From 3f4c96410e5a7ada516e5b83a54c2bc7cab75bb6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:15:57 -0700 Subject: [PATCH 03/10] Stabilize right sidebar CLI integration test --- cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 9c827d8cf7d1..da3e6ae563b2 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3474,7 +3474,9 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { executablePath: cliPath, arguments: item.arguments, environment: environment, - timeout: 5 + // This verifies forwarding and quiet output, not process launch latency. + // Shared macOS runners can stall a freshly signed CLI during startup. + timeout: 20 ) wait(for: [serverHandled], timeout: 5) From 4932f209de52766b0c16dc74b8d23a6824b946fb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:40:46 -0700 Subject: [PATCH 04/10] Add History row layout regression tests --- cmuxTests/VaultHistoryGroupingTests.swift | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index 0310a38b3212..e07d27308b51 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -471,6 +471,51 @@ struct VaultHistoryAppKitViewportTests { #expect(event.title == original) } + @MainActor + @Test + func timelineCellKeepsMultilineTranscriptInsideOneFixedHeightRow() throws { + let event = VaultHistoryEvent( + id: "multiline-session", + timestamp: Date(), + kind: .sessionActivity, + title: "first line\n second line\tthird line", + subject: VaultHistorySubject( + sessionId: "multiline-session", + agent: "codex", + agentDisplayName: "Codex", + directory: "/tmp/history" + ) + ) + let cell = VaultHistoryTableEventCellView( + frame: NSRect(x: 0, y: 0, width: 320, height: 38) + ) + + cell.configure( + event: event, + action: nil, + agent: .codex, + globalFontMagnificationPercent: 100, + onPerformAction: { _ in } + ) + cell.layoutSubtreeIfNeeded() + + let labels = Self.descendants(of: cell).compactMap { $0 as? NSTextField } + #expect(labels.count == 3) + #expect(labels.allSatisfy { $0.usesSingleLineMode }) + #expect(labels.allSatisfy { !$0.stringValue.contains(where: \.isNewline) }) + #expect(VaultHistoryTableEventCellView.displayTitle(for: event) == "first line second line third line") + for label in labels { + let frameInCell = cell.convert(label.bounds, from: label) + #expect(cell.bounds.contains(frameInCell)) + } + } + + @Test + func workspaceLifecycleUsesMinimalAddRemoveSymbols() { + #expect(VaultHistoryEventKind.workspaceCreated.symbolName == "plus") + #expect(VaultHistoryEventKind.workspaceClosed.symbolName == "minus") + } + @MainActor private func flushStagedTableMutations() async { await withCheckedContinuation { continuation in @@ -479,4 +524,9 @@ struct VaultHistoryAppKitViewportTests { } } } + + @MainActor + private static func descendants(of view: NSView) -> [NSView] { + view.subviews + view.subviews.flatMap { descendants(of: $0) } + } } From decbdfe33f64e8b8561df7d90ff07747e3e08553 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:59:05 -0700 Subject: [PATCH 05/10] Make History workspace-first --- Resources/Localizable.xcstrings | 29 +++++-- Sources/VaultHistoryControls.swift | 31 +------- Sources/VaultHistoryEvent.swift | 4 +- Sources/VaultHistoryEventRow.swift | 32 ++++++-- Sources/VaultHistoryGroupHeader.swift | 64 ++++++++++++++-- Sources/VaultHistoryTimelineList.swift | 54 +++++++++++-- Sources/VaultHistoryTimelineModel.swift | 69 ++++++++++++++--- Sources/VaultHistoryView.swift | 8 +- Sources/VaultPaneView.swift | 73 ++++++------------ cmuxTests/VaultHistoryGroupingTests.swift | 93 ++++++++++++++++++++++- 10 files changed, 339 insertions(+), 118 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 9a4a02a3a30d..bd60aa897491 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -259409,36 +259409,53 @@ } } }, - "vaultPane.tab.sessions": { + "vaultPane.tab.timeline": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sessions" + "value": "Timeline" } }, "ja": { "stringUnit": { "state": "translated", - "value": "セッション" + "value": "タイムライン" } } } }, - "vaultPane.tab.history": { + "vaultPane.tab.folder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Timeline" + "value": "By Folder" } }, "ja": { "stringUnit": { "state": "translated", - "value": "タイムライン" + "value": "フォルダ別" + } + } + } + }, + "vaultPane.tab.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By Agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント別" } } } diff --git a/Sources/VaultHistoryControls.swift b/Sources/VaultHistoryControls.swift index 0fbab15ad312..a6bdc63701a9 100644 --- a/Sources/VaultHistoryControls.swift +++ b/Sources/VaultHistoryControls.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Search, time-range, grouping, sorting, and reload controls for History. +/// Search, time-range, sorting, and reload controls for History. struct VaultHistoryControls: View { @Bindable var model: VaultHistoryTimelineModel let isReloadDisabled: Bool @@ -10,7 +10,6 @@ struct VaultHistoryControls: View { VStack(spacing: 0) { HStack(spacing: 6) { timeRangeMenu - groupMenu Spacer(minLength: 4) sortMenu reloadButton @@ -80,34 +79,6 @@ struct VaultHistoryControls: View { .titlebarInteractiveControl() } - private var groupMenu: some View { - 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: { - Label(model.groupKey.label, systemImage: model.groupKey.symbolName) - .labelStyle(.iconOnly) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help(String(localized: "vaultHistory.groupPicker.tooltip", defaultValue: "Group history by")) - .accessibilityIdentifier("VaultHistoryGroupPicker") - .titlebarInteractiveControl() - } - private var sortMenu: some View { Menu { ForEach(VaultHistoryQuery.SortOrder.allCases) { order in diff --git a/Sources/VaultHistoryEvent.swift b/Sources/VaultHistoryEvent.swift index d9ae028ea4cb..f83976fdb17b 100644 --- a/Sources/VaultHistoryEvent.swift +++ b/Sources/VaultHistoryEvent.swift @@ -35,9 +35,9 @@ enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { var symbolName: String { switch self { - case .workspaceCreated: return "plus.square" + case .workspaceCreated: return "plus" case .workspaceRenamed: return "pencil" - case .workspaceClosed: return "xmark.square" + case .workspaceClosed: return "minus" case .windowOpened: return "macwindow.badge.plus" case .windowClosed: return "macwindow" case .sessionActivity: return "sparkles" diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index 6a83ed87fb80..16e366725aa5 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -62,6 +62,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { super.init(frame: frameRect) identifier = Self.reuseIdentifier wantsLayer = true + layer?.masksToBounds = true hoverBackground.translatesAutoresizingMaskIntoConstraints = false hoverBackground.wantsLayer = true @@ -75,7 +76,10 @@ final class VaultHistoryTableEventCellView: NSTableCellView { for label in [titleLabel, subtitleLabel, timeLabel] { label.lineBreakMode = .byTruncatingTail label.maximumNumberOfLines = 1 + label.usesSingleLineMode = true label.cell?.truncatesLastVisibleLine = true + label.cell?.usesSingleLineMode = true + label.cell?.wraps = false label.setAccessibilityElement(false) } titleLabel.textColor = NSColor.labelColor.withAlphaComponent(0.85) @@ -116,6 +120,8 @@ final class VaultHistoryTableEventCellView: NSTableCellView { textStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6), textStack.centerYAnchor.constraint(equalTo: centerYAnchor), + textStack.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 4), + textStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -4), actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), actionButton.centerYAnchor.constraint(equalTo: centerYAnchor), @@ -173,7 +179,9 @@ final class VaultHistoryTableEventCellView: NSTableCellView { representedFontPercent = globalFontMagnificationPercent titleLabel.stringValue = Self.displayTitle(for: event) - subtitleLabel.stringValue = Self.subtitle(for: event) + subtitleLabel.stringValue = Self.displaySubtitle(for: event) + titleLabel.toolTip = VaultHistoryDisplayText.singleLine(event.title) + subtitleLabel.toolTip = subtitleLabel.stringValue titleLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(11.5, percent: globalFontMagnificationPercent) ) @@ -291,8 +299,8 @@ final class VaultHistoryTableEventCellView: NSTableCellView { }() static func displayTitle(for event: VaultHistoryEvent) -> String { - let trimmed = event.title.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty else { return truncated(trimmed, maximumLength: 240) } + let singleLine = VaultHistoryDisplayText.singleLine(event.title) + guard singleLine.isEmpty else { return truncated(singleLine, maximumLength: 240) } switch event.kind { case .windowOpened, .windowClosed: return String(localized: "vaultHistory.window", defaultValue: "Window") @@ -306,11 +314,11 @@ final class VaultHistoryTableEventCellView: NSTableCellView { return String(value.prefix(maximumLength)) + "…" } - private static func subtitle(for event: VaultHistoryEvent) -> String { + static func displaySubtitle(for event: VaultHistoryEvent) -> String { var parts: [String] = [] if event.kind == .sessionActivity { if let displayName = event.subject.agentDisplayName, !displayName.isEmpty { - parts.append(displayName) + parts.append(VaultHistoryDisplayText.singleLine(displayName)) } else if let raw = event.subject.agent, let agent = SessionAgent(rawValue: raw) { parts.append(agent.displayName) } else { @@ -324,7 +332,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { !previousTitle.isEmpty { parts.append(String( format: String(localized: "vaultHistory.detail.renamedFrom", defaultValue: "was “%@”"), - previousTitle + VaultHistoryDisplayText.singleLine(previousTitle) )) } if let count = event.workspaceCount { @@ -332,9 +340,11 @@ final class VaultHistoryTableEventCellView: NSTableCellView { } if let directory = event.subject.directory, !directory.isEmpty { let component = (directory as NSString).lastPathComponent - if !component.isEmpty, component != "." { parts.append(component) } + if !component.isEmpty, component != "." { + parts.append(VaultHistoryDisplayText.singleLine(component)) + } } - return parts.joined(separator: " · ") + return VaultHistoryDisplayText.singleLine(parts.joined(separator: " · ")) } private static func workspaceCountLabel(_ count: Int) -> String { @@ -348,6 +358,12 @@ final class VaultHistoryTableEventCellView: NSTableCellView { } } +enum VaultHistoryDisplayText { + static func singleLine(_ value: String) -> String { + value.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } +} + private final class VaultHistoryHitTransparentView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { nil } } diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift index 14a6deda8d71..5b0a1597b828 100644 --- a/Sources/VaultHistoryGroupHeader.swift +++ b/Sources/VaultHistoryGroupHeader.swift @@ -11,8 +11,12 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { private let iconView = CmuxResolvedIconImageView() private let titleLabel = NSTextField(labelWithString: "") private let countLabel = NSTextField(labelWithString: "") + private let actionButton = NSButton() private var iconWidthConstraint: NSLayoutConstraint! private var iconToTitleConstraint: NSLayoutConstraint! + private var actionButtonWidthConstraint: NSLayoutConstraint! + private var representedAction: VaultHistoryRowAction? + private var onPerformAction: ((VaultHistoryRowAction) -> Void)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -30,6 +34,9 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.lineBreakMode = .byTruncatingTail titleLabel.maximumNumberOfLines = 1 + titleLabel.usesSingleLineMode = true + titleLabel.cell?.usesSingleLineMode = true + titleLabel.cell?.wraps = false titleLabel.textColor = .secondaryLabelColor titleLabel.setAccessibilityElement(false) addSubview(titleLabel) @@ -41,8 +48,17 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { countLabel.setAccessibilityElement(false) addSubview(countLabel) + actionButton.translatesAutoresizingMaskIntoConstraints = false + actionButton.isBordered = false + actionButton.imagePosition = .imageOnly + actionButton.focusRingType = .none + actionButton.target = self + actionButton.action = #selector(performRepresentedAction(_:)) + addSubview(actionButton) + iconWidthConstraint = iconView.widthAnchor.constraint(equalToConstant: 0) iconToTitleConstraint = titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor) + actionButtonWidthConstraint = actionButton.widthAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -58,7 +74,12 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), countLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 4), countLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - countLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), + countLabel.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -4), + + actionButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + actionButton.centerYAnchor.constraint(equalTo: centerYAnchor), + actionButtonWidthConstraint, + actionButton.heightAnchor.constraint(equalToConstant: 18), ]) } @@ -70,6 +91,8 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { override func prepareForReuse() { super.prepareForReuse() iconView.apply(nil) + configureAction(nil) + onPerformAction = nil } func configure( @@ -77,10 +100,14 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { title: String, count: Int, agent: SessionAgent?, - globalFontMagnificationPercent: Int + action: VaultHistoryRowAction?, + globalFontMagnificationPercent: Int, + onPerformAction: @escaping (VaultHistoryRowAction) -> Void ) { - titleLabel.stringValue = title - titleLabel.toolTip = title + self.onPerformAction = onPerformAction + let singleLineTitle = VaultHistoryDisplayText.singleLine(title) + titleLabel.stringValue = singleLineTitle + titleLabel.toolTip = singleLineTitle titleLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(11, percent: globalFontMagnificationPercent), weight: .semibold @@ -90,10 +117,37 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) ) configureIcon(agent) + configureAction(action) setAccessibilityElement(true) setAccessibilityRole(.group) setAccessibilityIdentifier("VaultHistoryGroup:\(id)") - setAccessibilityLabel("\(title), \(count.formatted())") + setAccessibilityLabel("\(singleLineTitle), \(count.formatted())") + } + + private func configureAction(_ action: VaultHistoryRowAction?) { + representedAction = action + guard let action else { + actionButton.isHidden = true + actionButtonWidthConstraint.constant = 0 + actionButton.image = nil + actionButton.toolTip = nil + actionButton.setAccessibilityLabel(nil) + return + } + actionButton.isHidden = false + actionButtonWidthConstraint.constant = 18 + actionButton.image = NSImage( + systemSymbolName: action.symbolName, + accessibilityDescription: action.label + )?.withSymbolConfiguration(.init(pointSize: 9, weight: .medium)) + actionButton.contentTintColor = .secondaryLabelColor + actionButton.toolTip = action.label + actionButton.setAccessibilityLabel(action.label) + } + + @objc private func performRepresentedAction(_ sender: Any?) { + guard let representedAction else { return } + onPerformAction?(representedAction) } private func configureIcon(_ agent: SessionAgent?) { diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift index 2f99e5e40bf9..e93ab2b6adca 100644 --- a/Sources/VaultHistoryTimelineList.swift +++ b/Sources/VaultHistoryTimelineList.swift @@ -44,11 +44,17 @@ struct VaultHistoryTimelineList: NSViewRepresentable { resolvedAgent(for: $0, resumeEntriesByEventId: resumeEntriesByEventId) }.first : nil + let groupAction = groupRowAction( + for: group, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ) let header = VaultHistoryTableRow.group( id: group.id, title: group.title, count: group.events.count, - agent: groupAgent + agent: groupAgent, + action: groupAction ) let events = group.events.map { event in VaultHistoryTableRow.event( @@ -69,6 +75,25 @@ struct VaultHistoryTimelineList: NSViewRepresentable { } } + private static func groupRowAction( + for group: VaultHistoryGroup, + availableClosedItemIds: Set, + actions: VaultHistoryRowActions + ) -> VaultHistoryRowAction? { + guard group.id.hasPrefix("workspace:"), actions.canReopen else { return nil } + let recoverableEvent = group.events + .filter { event in + event.kind == .workspaceClosed + && event.subject.closedItemId.map(availableClosedItemIds.contains) == true + } + .max { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.id < rhs.id + } + guard let closedItemId = recoverableEvent?.subject.closedItemId else { return nil } + return .reopenClosedItem(closedItemId) + } + private static func resolvedAgent( for event: VaultHistoryEvent, resumeEntriesByEventId: [String: SessionEntry] @@ -101,12 +126,18 @@ enum VaultHistoryTableRowID: Hashable { } enum VaultHistoryTableRow { - case group(id: String, title: String, count: Int, agent: SessionAgent?) + case group( + id: String, + title: String, + count: Int, + agent: SessionAgent?, + action: VaultHistoryRowAction? + ) case event(event: VaultHistoryEvent, action: VaultHistoryRowAction?, agent: SessionAgent?) var id: VaultHistoryTableRowID { switch self { - case .group(let id, _, _, _): return .group(id) + case .group(let id, _, _, _, _): return .group(id) case .event(let event, _, _): return .event(event.id) } } @@ -118,8 +149,15 @@ enum VaultHistoryTableRow { func hasEquivalentContent(to other: Self) -> Bool { switch (self, other) { - case let (.group(lhsID, lhsTitle, lhsCount, lhsAgent), .group(rhsID, rhsTitle, rhsCount, rhsAgent)): - return lhsID == rhsID && lhsTitle == rhsTitle && lhsCount == rhsCount && lhsAgent == rhsAgent + case let ( + .group(lhsID, lhsTitle, lhsCount, lhsAgent, lhsAction), + .group(rhsID, rhsTitle, rhsCount, rhsAgent, rhsAction) + ): + return lhsID == rhsID + && lhsTitle == rhsTitle + && lhsCount == rhsCount + && lhsAgent == rhsAgent + && lhsAction == rhsAction case let (.event(lhsEvent, lhsAction, lhsAgent), .event(rhsEvent, rhsAction, rhsAgent)): return lhsEvent == rhsEvent && lhsAction == rhsAction && lhsAgent == rhsAgent default: @@ -239,7 +277,7 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard rows.indices.contains(row) else { return nil } switch rows[row] { - case .group(let id, let title, let count, let agent): + case .group(let id, let title, let count, let agent, let action): let cell = (tableView.makeView( withIdentifier: VaultHistoryTableGroupCellView.reuseIdentifier, owner: self @@ -249,7 +287,9 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl title: title, count: count, agent: agent, - globalFontMagnificationPercent: globalFontMagnificationPercent + action: action, + globalFontMagnificationPercent: globalFontMagnificationPercent, + onPerformAction: { [weak self] action in self?.actions.perform(action) } ) return cell case .event(let event, let action, let agent): diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index c8196e30714f..57119874fc85 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -1,17 +1,69 @@ import Foundation import Observation +/// The three user-facing ways to browse History. Each mode owns its +/// primitive and filters out events that cannot belong to that primitive, +/// avoiding catch-all sections such as agent sessions under "Other" +/// in the workspace timeline. +enum VaultHistoryMode: String, CaseIterable, Identifiable, Sendable { + case timeline + case folder + case agent + + var id: String { rawValue } + + var label: String { + switch self { + case .timeline: + return String(localized: "vaultPane.tab.timeline", defaultValue: "Timeline") + case .folder: + return String(localized: "vaultPane.tab.folder", defaultValue: "By Folder") + case .agent: + return String(localized: "vaultPane.tab.agent", defaultValue: "By Agent") + } + } + + var symbolName: String { + switch self { + case .timeline: return "clock.arrow.circlepath" + case .folder: return "folder" + case .agent: return "person.2" + } + } + + var groupKey: VaultHistoryGroupKey { + switch self { + case .timeline: return .workspace + case .folder: return .directory + case .agent: return .agent + } + } + + func includedEvents(from events: [VaultHistoryEvent]) -> [VaultHistoryEvent] { + events.filter { event in + switch self { + case .timeline: + return event.subject.workspaceId != nil + case .folder: + let directory = event.subject.directory? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !directory.isEmpty && directory != "." + case .agent: + return event.kind == .sessionActivity && event.subject.agent != nil + } + } + } +} + /// View model for the History timeline: merges persisted lifecycle events /// with session events derived from the agent-session 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 { + var mode: VaultHistoryMode { didSet { - guard groupKey != oldValue else { return } - defaults.set(groupKey.rawValue, forKey: Self.groupKeyDefaultsKey) + guard mode != oldValue else { return } regroup() } } @@ -46,7 +98,6 @@ final class VaultHistoryTimelineModel { /// flash before anything loaded. private(set) var didLoad = false - private static let groupKeyDefaultsKey = "vaultHistory.groupKey" private static let timeRangeDefaultsKey = "vaultHistory.timeRange" private static let sortOrderDefaultsKey = "vaultHistory.sortOrder" /// Cap on merged timeline size handed to grouping; both inputs are @@ -69,6 +120,7 @@ final class VaultHistoryTimelineModel { init( log: VaultHistoryEventLog, + mode: VaultHistoryMode = .timeline, grouper: VaultHistoryGrouper = VaultHistoryGrouper(), defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init @@ -77,8 +129,7 @@ final class VaultHistoryTimelineModel { self.grouper = grouper self.defaults = defaults self.now = now - self.groupKey = defaults.string(forKey: Self.groupKeyDefaultsKey) - .flatMap(VaultHistoryGroupKey.init(rawValue:)) ?? .date + self.mode = mode self.timeRange = defaults.string(forKey: Self.timeRangeDefaultsKey) .flatMap(VaultHistoryQuery.TimeRange.init(rawValue:)) ?? .allTime self.sortOrder = defaults.string(forKey: Self.sortOrderDefaultsKey) @@ -132,8 +183,8 @@ final class VaultHistoryTimelineModel { searchText: searchText ) groups = grouper.groups( - events: mergedEvents, - by: groupKey, + events: mode.includedEvents(from: mergedEvents), + by: mode.groupKey, query: query, now: currentTime ) diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index 78fe9aac37c2..eb95da4dbeef 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -2,6 +2,7 @@ import SwiftUI /// The History timeline, combining workspace/window lifecycle events with agent sessions. struct VaultHistoryView: View { + let mode: VaultHistoryMode @ObservedObject private var sessionStore: SessionIndexStore @ObservedObject private var closedItemStore: ClosedItemHistoryStore private let log: VaultHistoryEventLog @@ -10,18 +11,20 @@ struct VaultHistoryView: View { @State private var model: VaultHistoryTimelineModel init( + mode: VaultHistoryMode, sessionStore: SessionIndexStore, closedItemStore: ClosedItemHistoryStore, log: VaultHistoryEventLog, onResume: ((SessionEntry) -> Void)?, onReopenClosedItem: ((UUID) -> Bool)? ) { + self.mode = mode self.sessionStore = sessionStore self.closedItemStore = closedItemStore self.log = log self.onResume = onResume self.onReopenClosedItem = onReopenClosedItem - _model = State(initialValue: VaultHistoryTimelineModel(log: log)) + _model = State(initialValue: VaultHistoryTimelineModel(log: log, mode: mode)) } var body: some View { @@ -33,6 +36,9 @@ struct VaultHistoryView: View { onResume: onResume, onReopenClosedItem: onReopenClosedItem ) + .onChange(of: mode) { _, mode in + model.mode = mode + } } } diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 13b24d157324..32446af629de 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -1,31 +1,7 @@ import CmuxFoundation import SwiftUI -/// Sub-navigation inside History: the unified timeline and agent-session index. -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: "Timeline") - } - } - - var symbolName: String { - switch self { - case .sessions: return "books.vertical" - case .history: return "clock.arrow.circlepath" - } - } -} - -/// Hosts History behind Timeline/Sessions tabs. Both +/// Hosts History behind Timeline/By Folder/By Agent views. Both /// the right-sidebar mount and the pop-out pane mount render this view so /// the two entrypoints share one implementation. struct VaultPaneView: View { @@ -33,35 +9,36 @@ struct VaultPaneView: View { @ObservedObject var closedItemStore: ClosedItemHistoryStore let onResume: ((SessionEntry) -> Void)? let onReopenClosedItem: ((UUID) -> Bool)? - @AppStorage("vaultPane.tab") private var selectedTabRawValue = VaultPaneTab.history.rawValue + @AppStorage("vaultPane.tab") private var selectedModeRawValue = VaultHistoryMode.timeline.rawValue - private var selectedTab: VaultPaneTab { - VaultPaneTab(rawValue: selectedTabRawValue) ?? .history + private var selectedMode: VaultHistoryMode { + VaultHistoryMode(rawValue: selectedModeRawValue) ?? .timeline } var body: some View { VStack(spacing: 0) { tabBar - switch selectedTab { - case .sessions: - SessionIndexView(store: store, onResume: onResume) - case .history: - VaultHistoryView( - sessionStore: store, - closedItemStore: closedItemStore, - log: .shared, - onResume: onResume, - onReopenClosedItem: onReopenClosedItem - ) + VaultHistoryView( + mode: selectedMode, + sessionStore: store, + closedItemStore: closedItemStore, + log: .shared, + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) + } + .onAppear { + if VaultHistoryMode(rawValue: selectedModeRawValue) == nil { + selectedModeRawValue = VaultHistoryMode.timeline.rawValue } } } private var tabBar: some View { - HStack(spacing: 6) { - ForEach(VaultPaneTab.allCases) { tab in - VaultPaneTabButton(tab: tab, isSelected: selectedTab == tab) { - selectedTabRawValue = tab.rawValue + HStack(spacing: 4) { + ForEach(VaultHistoryMode.allCases) { mode in + VaultPaneTabButton(mode: mode, isSelected: selectedMode == mode) { + selectedModeRawValue = mode.rawValue } } Spacer(minLength: 0) @@ -72,7 +49,7 @@ struct VaultPaneView: View { } private struct VaultPaneTabButton: View { - let tab: VaultPaneTab + let mode: VaultHistoryMode let isSelected: Bool let action: () -> Void @State private var isHovered = false @@ -80,12 +57,12 @@ private struct VaultPaneTabButton: View { var body: some View { Button(action: action) { HStack(spacing: 3) { - Image(systemName: tab.symbolName) + Image(systemName: mode.symbolName) .cmuxFont( size: RightSidebarChromeControlStyle.secondaryIconSize, weight: RightSidebarChromeControlStyle.iconWeight ) - Text(tab.label) + Text(mode.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight @@ -96,7 +73,7 @@ private struct VaultPaneTabButton: View { .buttonStyle(.plain) .titlebarInteractiveControl() .onHover { isHovered = $0 } - .help(tab.label) - .accessibilityIdentifier("VaultPaneTabButton.\(tab.rawValue)") + .help(mode.label) + .accessibilityIdentifier("VaultPaneTabButton.\(mode.rawValue)") } } diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index e07d27308b51..c48ed6070f2b 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -195,6 +195,36 @@ import Testing #expect(groups[0].events.map(\.id) == ["c2", "c1"]) } + @Test func historyModesExposeWorkspaceFolderAndAgentPrimitives() { + let workspaceId = UUID() + let events = [ + event( + id: "workspace", + secondsAgo: 30, + kind: .workspaceCreated, + title: "History", + workspaceId: workspaceId, + directory: "/tmp/repo" + ), + event( + id: "session", + secondsAgo: 20, + kind: .sessionActivity, + title: "Implement History", + agent: "codex", + directory: "/tmp/repo" + ), + event(id: "window", secondsAgo: 10, kind: .windowOpened, windowId: UUID()), + ] + + #expect(VaultHistoryMode.timeline.groupKey == .workspace) + #expect(VaultHistoryMode.folder.groupKey == .directory) + #expect(VaultHistoryMode.agent.groupKey == .agent) + #expect(VaultHistoryMode.timeline.includedEvents(from: events).map(\.id) == ["workspace"]) + #expect(VaultHistoryMode.folder.includedEvents(from: events).map(\.id) == ["workspace", "session"]) + #expect(VaultHistoryMode.agent.includedEvents(from: events).map(\.id) == ["session"]) + } + // MARK: - Filtering, search, and sorting @Test func timeRangesUseRollingBoundaries() { @@ -341,7 +371,7 @@ import Testing ) #expect(rows.map(\.id) == [.group("agent:codex"), .event("codex-session")]) - guard case .group(_, _, 1, let groupAgent) = rows[0] else { + guard case .group(_, _, 1, let groupAgent, nil) = rows[0] else { Issue.record("Expected the first virtual row to be a group header") return } @@ -396,6 +426,57 @@ import Testing #expect(rowAgent == registeredAgent) #expect(rowAgent?.assetName == "AgentIcons/Pi") } + + @Test func workspaceGroupExposesNewestAvailableRecoveryAction() throws { + let workspaceId = UUID() + let olderClosedItemId = UUID() + let newestClosedItemId = UUID() + let events = [ + VaultHistoryEvent( + id: "older-close", + timestamp: Self.now.addingTimeInterval(-30), + kind: .workspaceClosed, + title: "History", + subject: VaultHistorySubject( + workspaceId: workspaceId, + closedItemId: olderClosedItemId, + directory: "/tmp/repo" + ) + ), + VaultHistoryEvent( + id: "newest-close", + timestamp: Self.now.addingTimeInterval(-10), + kind: .workspaceClosed, + title: "History", + subject: VaultHistorySubject( + workspaceId: workspaceId, + closedItemId: newestClosedItemId, + directory: "/tmp/repo" + ) + ), + ] + let group = VaultHistoryGroup( + id: "workspace:\(workspaceId.uuidString)", + title: "History", + events: events + ) + + let rows = VaultHistoryTimelineList.makeRows( + groups: [group], + resumeEntriesByEventId: [:], + availableClosedItemIds: [olderClosedItemId, newestClosedItemId], + actions: VaultHistoryRowActions( + onResume: nil, + onReopenClosedItem: { _ in true } + ) + ) + + guard case .group(_, _, 2, nil, let action) = rows[0] else { + Issue.record("Expected a workspace group row with a recovery action") + return + } + #expect(action == .reopenClosedItem(newestClosedItemId)) + } } @Suite(.serialized) @@ -433,7 +514,15 @@ struct VaultHistoryAppKitViewportTests { ) } controller.apply( - rows: [.group(id: "agent:codex", title: "Codex", count: events.count, agent: .codex)] + events, + rows: [ + .group( + id: "agent:codex", + title: "Codex", + count: events.count, + agent: .codex, + action: nil + ), + ] + events, actions: VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil), globalFontMagnificationPercent: 100 ) From 870781ae9a86a17e211cffa5ee84797f50eccf26 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:20:10 -0700 Subject: [PATCH 06/10] Show live workspace topology in History --- Resources/Localizable.xcstrings | 204 +++++++++ Sources/ClosedItemHistory.swift | 5 + Sources/CmuxLifecycleEventPublishing.swift | 22 + Sources/RightSidebarPanelView.swift | 1 + Sources/RightSidebarToolPanel.swift | 1 + Sources/VaultHistoryAgentBindingStore.swift | 107 +++++ Sources/VaultHistoryEvent.swift | 12 + Sources/VaultHistoryEventLog.swift | 12 +- Sources/VaultHistoryEventRow.swift | 148 +++++- Sources/VaultHistoryGroupHeader.swift | 51 ++- Sources/VaultHistoryLifecycleRecording.swift | 3 + Sources/VaultHistoryQuery.swift | 3 + Sources/VaultHistorySessionEvents.swift | 13 +- Sources/VaultHistoryTimelineList.swift | 253 ++++++++++- Sources/VaultHistoryTimelineModel.swift | 45 +- Sources/VaultHistoryView.swift | 108 ++++- ...ltHistoryWorkspaceTimelineProjection.swift | 426 ++++++++++++++++++ Sources/VaultHistoryWorkspaceTopology.swift | 319 +++++++++++++ Sources/VaultPaneView.swift | 2 + cmux.xcodeproj/project.pbxproj | 12 + cmuxTests/VaultHistoryGroupingTests.swift | 377 ++++++++++++++++ 21 files changed, 2077 insertions(+), 47 deletions(-) create mode 100644 Sources/VaultHistoryAgentBindingStore.swift create mode 100644 Sources/VaultHistoryWorkspaceTimelineProjection.swift create mode 100644 Sources/VaultHistoryWorkspaceTopology.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bd60aa897491..17ced8a4ee88 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -258984,6 +258984,176 @@ } } }, + "vaultHistory.window.numbered": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ウインドウ %d" + } + } + } + }, + "vaultHistory.workspace.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブ" + } + } + } + }, + "vaultHistory.workspace.closed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Closed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了済み" + } + } + } + }, + "vaultHistory.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "vaultHistory.terminalCount.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 個のターミナル" + } + } + } + }, + "vaultHistory.terminalCount.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のターミナル" + } + } + } + }, + "vaultHistory.agentState.running": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + } + } + }, + "vaultHistory.agentState.restoring": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restoring" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元中" + } + } + } + }, + "vaultHistory.agentState.hibernated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hibernated" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "休止中" + } + } + } + }, + "vaultHistory.agentState.saved": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済み" + } + } + } + }, "vaultHistory.groupPicker.tooltip": { "extractionState": "manual", "localizations": { @@ -259120,6 +259290,40 @@ } } }, + "vaultHistory.action.goToWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースへ移動" + } + } + } + }, + "vaultHistory.action.goToTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルへ移動" + } + } + } + }, "vaultHistory.action.reopen": { "extractionState": "manual", "localizations": { diff --git a/Sources/ClosedItemHistory.swift b/Sources/ClosedItemHistory.swift index 92d67d1b8405..c9efbbb69e9e 100644 --- a/Sources/ClosedItemHistory.swift +++ b/Sources/ClosedItemHistory.swift @@ -189,6 +189,11 @@ final class ClosedItemHistoryStore: ObservableObject { Set(records.lazy.map(\.id)) } + /// Immutable records used to render closed workspace topology in History. + var recordsSnapshot: [ClosedItemHistoryRecord] { + records + } + func push(_ entry: ClosedItemHistoryEntry) { push(ClosedItemHistoryRecord(entry: entry)) } diff --git a/Sources/CmuxLifecycleEventPublishing.swift b/Sources/CmuxLifecycleEventPublishing.swift index d0a3728c5b1c..7c9d729e669b 100644 --- a/Sources/CmuxLifecycleEventPublishing.swift +++ b/Sources/CmuxLifecycleEventPublishing.swift @@ -46,6 +46,10 @@ extension TabManager { index: tabs.firstIndex(where: { $0.id == workspace.id }), tabCount: tabs.count ) + NotificationCenter.default.post( + name: .vaultHistoryLiveTopologyDidChange, + object: self + ) } func publishCmuxInitialSurfaceCreated(_ workspace: Workspace, selected: Bool) { @@ -69,6 +73,10 @@ extension TabManager { remainingTabCount: tabs.count ) CmuxSelectionEventState.clearWorkspace(workspace.id) + NotificationCenter.default.post( + name: .vaultHistoryLiveTopologyDidChange, + object: self + ) } func publishCmuxWorkspaceSelected(_ workspace: Workspace) { @@ -140,6 +148,10 @@ extension Workspace { origin: origin, focused: focused ) + NotificationCenter.default.post( + name: .vaultHistoryLiveTopologyDidChange, + object: self + ) } func publishCmuxSurfaceClosed(_ surfaceId: UUID, paneId: PaneID?, panel: (any Panel)?, origin: String) { @@ -151,6 +163,10 @@ extension Workspace { origin: origin ) CmuxSelectionEventState.clearSurface(workspaceId: id, surfaceId: surfaceId) + NotificationCenter.default.post( + name: .vaultHistoryLiveTopologyDidChange, + object: self + ) } func publishCmuxPaneClosed(_ paneId: PaneID, closedPanelIds: [UUID], origin: String) { @@ -258,6 +274,12 @@ private enum MainWindowKeyRegainRefresh { } } +extension Notification.Name { + static let vaultHistoryLiveTopologyDidChange = Notification.Name( + "cmux.vaultHistoryLiveTopologyDidChange" + ) +} + extension AppDelegate { func handleCmuxWindowBecameKey(_ note: Notification) { guard let window = note.object as? NSWindow else { return } diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index f0a715151b49..05c127f88396 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -394,6 +394,7 @@ struct RightSidebarPanelView: View { ) case .sessions: VaultPaneView( + tabManager: tabManager, store: sessionIndexStore, closedItemStore: .shared, onResume: onResumeSession, diff --git a/Sources/RightSidebarToolPanel.swift b/Sources/RightSidebarToolPanel.swift index 8b1a27260b57..4dd5b4acfd46 100644 --- a/Sources/RightSidebarToolPanel.swift +++ b/Sources/RightSidebarToolPanel.swift @@ -285,6 +285,7 @@ struct RightSidebarToolPanelView: View { ) case .sessions: VaultPaneView( + tabManager: tabManager, store: panel.sessionIndexStore, closedItemStore: .shared, onResume: { entry in diff --git a/Sources/VaultHistoryAgentBindingStore.swift b/Sources/VaultHistoryAgentBindingStore.swift new file mode 100644 index 000000000000..8f6d2d13a01f --- /dev/null +++ b/Sources/VaultHistoryAgentBindingStore.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Reads durable agent hook bindings without process inspection. +/// +/// The session index owns titles and resume commands. This store only supplies +/// the latest workspace and terminal identity recorded for each session so +/// History can place that session in the topology tree. +actor VaultHistoryAgentBindingStore { + struct Key: Hashable, Sendable { + let agentId: String + let sessionId: String + } + + struct Binding: Equatable, Sendable { + let key: Key + let workspaceId: UUID + let surfaceId: UUID + let updatedAt: Date + } + + private static let maximumBindings = 5_000 + + private let homeDirectory: String + private let fileManager: FileManager + private let environment: [String: String] + private var cachedFingerprint: [String: Date?] = [:] + private var cachedBindings: [Key: Binding] = [:] + + init( + homeDirectory: String = NSHomeDirectory(), + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + self.homeDirectory = homeDirectory + self.fileManager = fileManager + self.environment = environment + } + + func load() -> [Key: Binding] { + let registry = CmuxVaultAgentRegistry.load( + homeDirectory: homeDirectory, + fileManager: fileManager + ) + let builtInIds = Set(RestorableAgentKind.allCases.map(\.rawValue)) + let agentIds = RestorableAgentKind.allCases.map(\.rawValue) + + registry.registrations.map(\.id).filter { !builtInIds.contains($0) } + let storeURLs = agentIds.compactMap { RestorableAgentKind(rawValue: $0) }.map { + ( + agentId: $0.rawValue, + url: $0.hookStoreFileURL( + homeDirectory: homeDirectory, + environment: environment + ) + ) + } + let fingerprint = Dictionary(uniqueKeysWithValues: storeURLs.map { item in + ( + item.url.path, + try? item.url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + ) + }) + if fingerprint == cachedFingerprint { + return cachedBindings + } + + let decoder = JSONDecoder() + var bindings: [Key: Binding] = [:] + for item in storeURLs { + guard fileManager.fileExists(atPath: item.url.path), + let data = try? Data(contentsOf: item.url), + let state = try? decoder.decode( + RestorableAgentHookSessionStoreFile.self, + from: data + ) else { + continue + } + for record in state.sessions.values { + let sessionId = record.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sessionId.isEmpty, + let workspaceId = UUID(uuidString: record.workspaceId), + let surfaceId = UUID(uuidString: record.surfaceId) else { + continue + } + let key = Key(agentId: item.agentId, sessionId: sessionId) + let binding = Binding( + key: key, + workspaceId: workspaceId, + surfaceId: surfaceId, + updatedAt: Date(timeIntervalSince1970: record.updatedAt) + ) + if bindings[key]?.updatedAt ?? .distantPast <= binding.updatedAt { + bindings[key] = binding + } + } + } + if bindings.count > Self.maximumBindings { + let newest = bindings.values + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(Self.maximumBindings) + bindings = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0) }) + } + cachedFingerprint = fingerprint + cachedBindings = bindings + return bindings + } +} diff --git a/Sources/VaultHistoryEvent.swift b/Sources/VaultHistoryEvent.swift index f83976fdb17b..98ffae21171c 100644 --- a/Sources/VaultHistoryEvent.swift +++ b/Sources/VaultHistoryEvent.swift @@ -51,7 +51,13 @@ enum VaultHistoryEventKind: String, Codable, CaseIterable, Sendable { /// "Other" bucket for that grouping. struct VaultHistorySubject: Hashable, Codable, Sendable { var workspaceId: UUID? + /// Restart-stable workspace identity used when a restored runtime UUID changes. + var workspaceStableId: UUID? var windowId: UUID? + /// Runtime terminal/panel identity for agent-session nesting. + var surfaceId: UUID? + /// Restart-stable terminal identity, when captured from a live or closed snapshot. + var surfaceStableId: UUID? /// Recently-closed record that can restore this workspace or window. var closedItemId: UUID? /// Native agent session identifier for `sessionActivity` events. @@ -65,7 +71,10 @@ struct VaultHistorySubject: Hashable, Codable, Sendable { init( workspaceId: UUID? = nil, + workspaceStableId: UUID? = nil, windowId: UUID? = nil, + surfaceId: UUID? = nil, + surfaceStableId: UUID? = nil, closedItemId: UUID? = nil, sessionId: String? = nil, agent: String? = nil, @@ -73,7 +82,10 @@ struct VaultHistorySubject: Hashable, Codable, Sendable { directory: String? = nil ) { self.workspaceId = workspaceId + self.workspaceStableId = workspaceStableId self.windowId = windowId + self.surfaceId = surfaceId + self.surfaceStableId = surfaceStableId self.closedItemId = closedItemId self.sessionId = sessionId self.agent = agent diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index cc38899ddaa6..d494dc3ec0ed 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -34,7 +34,17 @@ final class VaultHistoryEventLog { /// 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 + return shouldSuppressRecording( + isApplyingSessionRestore: appDelegate.isApplyingSessionRestore, + isTerminatingApp: appDelegate.isTerminatingApp + ) + } + + nonisolated static func shouldSuppressRecording( + isApplyingSessionRestore: Bool, + isTerminatingApp: Bool + ) -> Bool { + isApplyingSessionRestore || isTerminatingApp } func record(_ event: VaultHistoryEvent) { diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index 16e366725aa5..9ad73aa60fce 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -5,6 +5,8 @@ import CmuxFoundation enum VaultHistoryRowAction: Equatable { case resumeSession(SessionEntry) case reopenClosedItem(UUID) + case activateWorkspace(UUID) + case activateTerminal(workspaceId: UUID, terminalId: UUID) var label: String { switch self { @@ -12,6 +14,16 @@ enum VaultHistoryRowAction: Equatable { return String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab") case .reopenClosedItem: return String(localized: "vaultHistory.action.reopen", defaultValue: "Reopen") + case .activateWorkspace: + return String( + localized: "vaultHistory.action.goToWorkspace", + defaultValue: "Go to Workspace" + ) + case .activateTerminal: + return String( + localized: "vaultHistory.action.goToTerminal", + defaultValue: "Go to Terminal" + ) } } @@ -19,6 +31,7 @@ enum VaultHistoryRowAction: Equatable { switch self { case .resumeSession: return "play.fill" case .reopenClosedItem: return "arrow.uturn.backward" + case .activateWorkspace, .activateTerminal: return "arrow.right" } } } @@ -26,14 +39,33 @@ enum VaultHistoryRowAction: Equatable { struct VaultHistoryRowActions { let onResume: ((SessionEntry) -> Void)? let onReopenClosedItem: ((UUID) -> Bool)? + let onActivateWorkspace: ((UUID) -> Bool)? + let onActivateTerminal: ((UUID, UUID) -> Bool)? + + init( + onResume: ((SessionEntry) -> Void)?, + onReopenClosedItem: ((UUID) -> Bool)?, + onActivateWorkspace: ((UUID) -> Bool)? = nil, + onActivateTerminal: ((UUID, UUID) -> Bool)? = nil + ) { + self.onResume = onResume + self.onReopenClosedItem = onReopenClosedItem + self.onActivateWorkspace = onActivateWorkspace + self.onActivateTerminal = onActivateTerminal + } var canResume: Bool { onResume != nil } var canReopen: Bool { onReopenClosedItem != nil } + var canActivateWorkspace: Bool { onActivateWorkspace != nil } + var canActivateTerminal: Bool { onActivateTerminal != nil } func perform(_ action: VaultHistoryRowAction) { switch action { case .resumeSession(let entry): onResume?(entry) case .reopenClosedItem(let id): _ = onReopenClosedItem?(id) + case .activateWorkspace(let id): _ = onActivateWorkspace?(id) + case .activateTerminal(let workspaceId, let terminalId): + _ = onActivateTerminal?(workspaceId, terminalId) } } } @@ -50,8 +82,10 @@ final class VaultHistoryTableEventCellView: NSTableCellView { private let timeLabel = NSTextField(labelWithString: "") private let actionButton = NSButton() private lazy var textStack = NSStackView(views: [titleLabel, subtitleLabel]) + private var iconLeadingConstraint: NSLayoutConstraint! private var actionButtonWidthConstraint: NSLayoutConstraint! private var representedEvent: VaultHistoryEvent? + private var representedTopologyItem: VaultHistoryTableRow.TopologyItem? private var representedAction: VaultHistoryRowAction? private var representedAgent: SessionAgent? private var representedFontPercent = GlobalFontMagnification.defaultPercent @@ -107,13 +141,14 @@ final class VaultHistoryTableEventCellView: NSTableCellView { timeLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(timeLabel) + iconLeadingConstraint = iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10) NSLayoutConstraint.activate([ hoverBackground.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), hoverBackground.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), hoverBackground.topAnchor.constraint(equalTo: topAnchor, constant: 1), hoverBackground.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), - iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + iconLeadingConstraint, iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 14), iconView.heightAnchor.constraint(equalToConstant: 14), @@ -151,6 +186,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { override func prepareForReuse() { super.prepareForReuse() representedEvent = nil + representedTopologyItem = nil representedAction = nil representedAgent = nil onPerformAction = nil @@ -166,6 +202,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { onPerformAction: @escaping (VaultHistoryRowAction) -> Void ) { self.onPerformAction = onPerformAction + representedTopologyItem = nil guard representedEvent != event || representedAction != action || representedAgent != agent @@ -177,6 +214,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { representedAction = action representedAgent = agent representedFontPercent = globalFontMagnificationPercent + iconLeadingConstraint.constant = 10 titleLabel.stringValue = Self.displayTitle(for: event) subtitleLabel.stringValue = Self.displaySubtitle(for: event) @@ -204,6 +242,50 @@ final class VaultHistoryTableEventCellView: NSTableCellView { ].filter { !$0.isEmpty }.joined(separator: ", ")) } + func configure( + topologyItem: VaultHistoryTableRow.TopologyItem, + globalFontMagnificationPercent: Int, + onPerformAction: @escaping (VaultHistoryRowAction) -> Void + ) { + self.onPerformAction = onPerformAction + representedEvent = nil + guard representedTopologyItem != topologyItem + || representedFontPercent != globalFontMagnificationPercent else { + updateRelativeTimestamp(topologyItem.timestamp) + return + } + representedTopologyItem = topologyItem + representedAction = topologyItem.action + representedFontPercent = globalFontMagnificationPercent + iconLeadingConstraint.constant = 10 + CGFloat(max(0, topologyItem.indentationLevel)) * 16 + + let title = VaultHistoryDisplayText.singleLine(topologyItem.title) + titleLabel.stringValue = Self.truncated(title, maximumLength: 240) + subtitleLabel.stringValue = VaultHistoryDisplayText.singleLine(topologyItem.subtitle) + titleLabel.toolTip = title + subtitleLabel.toolTip = subtitleLabel.stringValue + titleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(11.5, percent: globalFontMagnificationPercent) + ) + subtitleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) + ) + timeLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) + ) + configureIcon(topologyItem.icon) + configureAction(topologyItem.action) + updateRelativeTimestamp(topologyItem.timestamp) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier(topologyItem.accessibilityIdentifier) + setAccessibilityLabel([ + titleLabel.stringValue, + subtitleLabel.stringValue, + timeLabel.stringValue, + ].filter { !$0.isEmpty }.joined(separator: ", ")) + } + override func mouseEntered(with event: NSEvent) { isPointerHovering = true updateHoverPresentation() @@ -216,21 +298,10 @@ final class VaultHistoryTableEventCellView: NSTableCellView { private func configureIcon(event: VaultHistoryEvent, agent: SessionAgent?) { if event.kind == .sessionActivity, let agent { - let description = event.subject.agentDisplayName ?? agent.displayName - let source: CmuxResolvedIconSource - if let assetName = agent.assetName { - source = .asset(name: assetName, bundle: .main) - } else { - source = .systemSymbol( - name: agent.systemImageName ?? "person.crop.circle", - accessibilityDescription: description - ) - } - iconView.apply(CmuxResolvedIconRequest( - source: source, - size: NSSize(width: 14, height: 14), - tintColor: agent.assetName == nil ? .secondaryLabelColor : nil - )) + configureAgentIcon( + agent, + description: event.subject.agentDisplayName ?? agent.displayName + ) return } iconView.apply(CmuxResolvedIconRequest( @@ -240,6 +311,36 @@ final class VaultHistoryTableEventCellView: NSTableCellView { )) } + private func configureIcon(_ icon: VaultHistoryTableRow.Icon) { + switch icon { + case .agent(let agent): + configureAgentIcon(agent, description: agent.displayName) + case .system(let name, let style): + iconView.apply(CmuxResolvedIconRequest( + source: .systemSymbol(name: name, accessibilityDescription: nil), + size: NSSize(width: 14, height: 14), + tintColor: style == .active ? .systemGreen : .secondaryLabelColor + )) + } + } + + private func configureAgentIcon(_ agent: SessionAgent, description: String) { + let source: CmuxResolvedIconSource + if let assetName = agent.assetName { + source = .asset(name: assetName, bundle: .main) + } else { + source = .systemSymbol( + name: agent.systemImageName ?? "person.crop.circle", + accessibilityDescription: description + ) + } + iconView.apply(CmuxResolvedIconRequest( + source: source, + size: NSSize(width: 14, height: 14), + tintColor: agent.assetName == nil ? .secondaryLabelColor : nil + )) + } + private func configureAction(_ action: VaultHistoryRowAction?) { representedAction = action guard let action else { @@ -274,11 +375,22 @@ final class VaultHistoryTableEventCellView: NSTableCellView { } private func updateRelativeTimestamp(for event: VaultHistoryEvent) { + updateRelativeTimestamp(event.timestamp) + } + + private func updateRelativeTimestamp(_ timestamp: Date?) { + guard let timestamp else { + timeLabel.stringValue = "" + timeLabel.toolTip = nil + timeLabel.isHidden = true + return + } + timeLabel.isHidden = false timeLabel.stringValue = Self.relativeFormatter.localizedString( - for: event.timestamp, + for: timestamp, relativeTo: Date() ) - timeLabel.toolTip = event.timestamp.formatted(date: .abbreviated, time: .shortened) + timeLabel.toolTip = timestamp.formatted(date: .abbreviated, time: .shortened) } private func updateHoverPresentation() { diff --git a/Sources/VaultHistoryGroupHeader.swift b/Sources/VaultHistoryGroupHeader.swift index 5b0a1597b828..98b03a862719 100644 --- a/Sources/VaultHistoryGroupHeader.swift +++ b/Sources/VaultHistoryGroupHeader.swift @@ -42,9 +42,14 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { addSubview(titleLabel) countLabel.translatesAutoresizingMaskIntoConstraints = false + countLabel.lineBreakMode = .byTruncatingTail + countLabel.maximumNumberOfLines = 1 + countLabel.usesSingleLineMode = true + countLabel.cell?.usesSingleLineMode = true + countLabel.cell?.wraps = false countLabel.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.6) countLabel.setContentHuggingPriority(.required, for: .horizontal) - countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) countLabel.setAccessibilityElement(false) addSubview(countLabel) @@ -113,6 +118,7 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { weight: .semibold ) countLabel.stringValue = count.formatted() + countLabel.toolTip = countLabel.stringValue countLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) ) @@ -124,6 +130,35 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { setAccessibilityLabel("\(singleLineTitle), \(count.formatted())") } + func configureWorkspace( + _ header: VaultHistoryTableRow.WorkspaceHeader, + globalFontMagnificationPercent: Int, + onPerformAction: @escaping (VaultHistoryRowAction) -> Void + ) { + self.onPerformAction = onPerformAction + let singleLineTitle = VaultHistoryDisplayText.singleLine(header.title) + let resolvedTitle = singleLineTitle.isEmpty + ? String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + : singleLineTitle + titleLabel.stringValue = resolvedTitle + titleLabel.toolTip = resolvedTitle + titleLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(11, percent: globalFontMagnificationPercent), + weight: .semibold + ) + countLabel.stringValue = VaultHistoryDisplayText.singleLine(header.detail) + countLabel.toolTip = countLabel.stringValue + countLabel.font = .systemFont( + ofSize: GlobalFontMagnification.scaledSize(9.5, percent: globalFontMagnificationPercent) + ) + configureWorkspaceIcon(isActive: header.isActive) + configureAction(header.action) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityIdentifier("VaultHistoryWorkspace:\(header.id)") + setAccessibilityLabel("\(resolvedTitle), \(countLabel.stringValue)") + } + private func configureAction(_ action: VaultHistoryRowAction?) { representedAction = action guard let action else { @@ -174,4 +209,18 @@ final class VaultHistoryTableGroupCellView: NSTableCellView { tintColor: agent.assetName == nil ? .secondaryLabelColor : nil )) } + + private func configureWorkspaceIcon(isActive: Bool) { + let symbolName = isActive ? "circle.fill" : "clock.arrow.circlepath" + let description = isActive + ? String(localized: "vaultHistory.workspace.active", defaultValue: "Active") + : String(localized: "vaultHistory.workspace.closed", defaultValue: "Closed") + iconWidthConstraint.constant = 12 + iconToTitleConstraint.constant = 4 + iconView.apply(CmuxResolvedIconRequest( + source: .systemSymbol(name: symbolName, accessibilityDescription: description), + size: NSSize(width: isActive ? 7 : 11, height: isActive ? 7 : 11), + tintColor: isActive ? .systemGreen : .secondaryLabelColor + )) + } } diff --git a/Sources/VaultHistoryLifecycleRecording.swift b/Sources/VaultHistoryLifecycleRecording.swift index fbbafaa5403f..88ae73983983 100644 --- a/Sources/VaultHistoryLifecycleRecording.swift +++ b/Sources/VaultHistoryLifecycleRecording.swift @@ -12,6 +12,7 @@ extension TabManager { title: resolvedWorkspaceDisplayTitle(for: workspace), subject: VaultHistorySubject( workspaceId: workspace.id, + workspaceStableId: workspace.stableId, windowId: AppDelegate.shared?.windowId(for: self), directory: workspace.currentDirectory ) @@ -31,6 +32,7 @@ extension TabManager { previousTitle: previousTitle, subject: VaultHistorySubject( workspaceId: workspace.id, + workspaceStableId: workspace.stableId, windowId: AppDelegate.shared?.windowId(for: self), directory: workspace.currentDirectory ) @@ -45,6 +47,7 @@ extension TabManager { title: resolvedWorkspaceDisplayTitle(for: workspace), subject: VaultHistorySubject( workspaceId: workspace.id, + workspaceStableId: workspace.stableId, windowId: AppDelegate.shared?.windowId(for: self), closedItemId: closedItemId, directory: workspace.currentDirectory diff --git a/Sources/VaultHistoryQuery.swift b/Sources/VaultHistoryQuery.swift index 90e610595277..31d88795aa20 100644 --- a/Sources/VaultHistoryQuery.swift +++ b/Sources/VaultHistoryQuery.swift @@ -112,7 +112,10 @@ struct VaultHistoryQuery: Equatable, Sendable { event.subject.sessionId ?? "", event.subject.agentDisplayName ?? "", event.subject.workspaceId?.uuidString ?? "", + event.subject.workspaceStableId?.uuidString ?? "", event.subject.windowId?.uuidString ?? "", + event.subject.surfaceId?.uuidString ?? "", + event.subject.surfaceStableId?.uuidString ?? "", ] if let rawAgent = event.subject.agent { fields.append(rawAgent) diff --git a/Sources/VaultHistorySessionEvents.swift b/Sources/VaultHistorySessionEvents.swift index 01147b6dccb8..325ac35230d6 100644 --- a/Sources/VaultHistorySessionEvents.swift +++ b/Sources/VaultHistorySessionEvents.swift @@ -6,14 +6,23 @@ 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 { - func events(from entries: [SessionEntry]) -> [VaultHistoryEvent] { + func events( + from entries: [SessionEntry], + bindings: [VaultHistoryAgentBindingStore.Key: VaultHistoryAgentBindingStore.Binding] = [:] + ) -> [VaultHistoryEvent] { entries.map { entry in - VaultHistoryEvent( + let binding = bindings[VaultHistoryAgentBindingStore.Key( + agentId: entry.agent.rawValue, + sessionId: entry.sessionId + )] + return VaultHistoryEvent( id: "session:\(entry.agent.rawValue):\(entry.id)", timestamp: entry.modified, kind: .sessionActivity, title: entry.title, subject: VaultHistorySubject( + workspaceId: binding?.workspaceId, + surfaceId: binding?.surfaceId, sessionId: entry.sessionId, agent: entry.agent.rawValue, agentDisplayName: entry.agent.displayName, diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift index e93ab2b6adca..52053959294d 100644 --- a/Sources/VaultHistoryTimelineList.swift +++ b/Sources/VaultHistoryTimelineList.swift @@ -6,6 +6,7 @@ import SwiftUI /// SwiftUI bridge for the AppKit-virtualized History timeline. struct VaultHistoryTimelineList: NSViewRepresentable { let groups: [VaultHistoryGroup] + let workspaceSections: [VaultHistoryWorkspaceTimelineProjection.Section] let resumeEntriesByEventId: [String: SessionEntry] let availableClosedItemIds: Set let actions: VaultHistoryRowActions @@ -21,12 +22,19 @@ struct VaultHistoryTimelineList: NSViewRepresentable { func updateNSView(_ nsView: VaultHistoryTableContainerView, context: Context) { context.coordinator.apply( - rows: Self.makeRows( - groups: groups, - resumeEntriesByEventId: resumeEntriesByEventId, - availableClosedItemIds: availableClosedItemIds, - actions: actions - ), + rows: workspaceSections.isEmpty + ? Self.makeRows( + groups: groups, + resumeEntriesByEventId: resumeEntriesByEventId, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ) + : Self.makeWorkspaceRows( + sections: workspaceSections, + resumeEntriesByEventId: resumeEntriesByEventId, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ), actions: actions, globalFontMagnificationPercent: globalFontMagnificationPercent ) @@ -75,6 +83,127 @@ struct VaultHistoryTimelineList: NSViewRepresentable { } } + static func makeWorkspaceRows( + sections: [VaultHistoryWorkspaceTimelineProjection.Section], + resumeEntriesByEventId: [String: SessionEntry], + availableClosedItemIds: Set, + actions: VaultHistoryRowActions + ) -> [VaultHistoryTableRow] { + sections.flatMap { section in + var detailParts = [ + section.state == .active + ? String(localized: "vaultHistory.workspace.active", defaultValue: "Active") + : String(localized: "vaultHistory.workspace.closed", defaultValue: "Closed"), + ] + if let windowLabel = section.windowLabel { + detailParts.append(windowLabel) + } + detailParts.append(terminalCountLabel(section.terminals.count)) + let headerAction: VaultHistoryRowAction? = { + if section.state == .active, + let workspaceId = section.workspaceId, + actions.canActivateWorkspace { + return .activateWorkspace(workspaceId) + } + return section.closedItemId.flatMap { id in + guard actions.canReopen, availableClosedItemIds.contains(id) else { + return nil + } + return .reopenClosedItem(id) + } + }() + let header = VaultHistoryTableRow.workspace( + VaultHistoryTableRow.WorkspaceHeader( + id: section.id, + title: section.title, + detail: detailParts.joined(separator: " · "), + isActive: section.state == .active, + action: headerAction + ) + ) + let topologyRows = section.terminals.flatMap { terminal -> [VaultHistoryTableRow] in + let activeTerminalAction: VaultHistoryRowAction? = { + guard section.state == .active, + let workspaceId = section.workspaceId, + let terminalId = terminal.runtimeId, + actions.canActivateTerminal else { + return nil + } + return .activateTerminal( + workspaceId: workspaceId, + terminalId: terminalId + ) + }() + let terminalTitle = VaultHistoryDisplayText.singleLine(terminal.title) + let directory = terminal.directory.flatMap { rawValue -> String? in + let component = (rawValue as NSString).lastPathComponent + return component.isEmpty || component == "." ? nil : component + } + let terminalSubtitle = [ + String(localized: "vaultHistory.terminal", defaultValue: "Terminal"), + directory, + ].compactMap { $0 }.joined(separator: " · ") + let terminalRow = VaultHistoryTableRow.topologyItem( + VaultHistoryTableRow.TopologyItem( + id: "terminal-row:\(section.id):\(terminal.id)", + title: terminalTitle.isEmpty + ? String(localized: "vaultHistory.terminal", defaultValue: "Terminal") + : terminalTitle, + subtitle: terminalSubtitle, + timestamp: nil, + icon: .system(name: "terminal", style: .secondary), + indentationLevel: 0, + action: activeTerminalAction, + accessibilityIdentifier: "VaultHistoryTerminalRow:\(terminal.id)" + ) + ) + let agentRows = terminal.agents.map { agent -> VaultHistoryTableRow in + let entry = agent.event.flatMap { resumeEntriesByEventId[$0.id] } + let action: VaultHistoryRowAction? = { + if let activeTerminalAction { + return activeTerminalAction + } + guard agent.state == .saved, let entry, actions.canResume else { + return nil + } + return .resumeSession(entry) + }() + let title = VaultHistoryDisplayText.singleLine(agent.title) + return .topologyItem(VaultHistoryTableRow.TopologyItem( + id: "agent-row:\(section.id):\(terminal.id):\(agent.id)", + title: title.isEmpty ? agent.agent.displayName : title, + subtitle: [ + agent.agent.displayName, + agentStateLabel(agent.state), + ].joined(separator: " · "), + timestamp: agent.updatedAt, + icon: .agent(agent.agent), + indentationLevel: 1, + action: action, + accessibilityIdentifier: "VaultHistoryAgentRow:\(agent.id)" + )) + } + return [terminalRow] + agentRows + } + let activityRows = section.activityEvents.map { event in + VaultHistoryTableRow.event( + event: event, + action: rowAction( + for: event, + resumeEntriesByEventId: resumeEntriesByEventId, + availableClosedItemIds: availableClosedItemIds, + actions: actions + ), + agent: resolvedAgent( + for: event, + resumeEntriesByEventId: resumeEntriesByEventId + ) + ) + } + return [header] + topologyRows + activityRows + } + } + private static func groupRowAction( for group: VaultHistoryGroup, availableClosedItemIds: Set, @@ -118,14 +247,73 @@ struct VaultHistoryTimelineList: NSViewRepresentable { } return nil } + + private static func terminalCountLabel(_ count: Int) -> String { + if count == 1 { + return String(localized: "vaultHistory.terminalCount.one", defaultValue: "1 terminal") + } + return String.localizedStringWithFormat( + String( + localized: "vaultHistory.terminalCount.other", + defaultValue: "%d terminals" + ), + count + ) + } + + private static func agentStateLabel( + _ state: VaultHistoryWorkspaceTopology.AgentState + ) -> String { + switch state { + case .running: + return String(localized: "vaultHistory.agentState.running", defaultValue: "Running") + case .restoring: + return String(localized: "vaultHistory.agentState.restoring", defaultValue: "Restoring") + case .hibernated: + return String(localized: "vaultHistory.agentState.hibernated", defaultValue: "Hibernated") + case .saved: + return String(localized: "vaultHistory.agentState.saved", defaultValue: "Saved") + } + } } enum VaultHistoryTableRowID: Hashable { case group(String) + case workspace(String) + case topologyItem(String) case event(String) } enum VaultHistoryTableRow { + enum IconStyle: Equatable { + case secondary + case active + } + + enum Icon: Equatable { + case system(name: String, style: IconStyle) + case agent(SessionAgent) + } + + struct WorkspaceHeader: Equatable { + let id: String + let title: String + let detail: String + let isActive: Bool + let action: VaultHistoryRowAction? + } + + struct TopologyItem: Equatable { + let id: String + let title: String + let subtitle: String + let timestamp: Date? + let icon: Icon + let indentationLevel: Int + let action: VaultHistoryRowAction? + let accessibilityIdentifier: String + } + case group( id: String, title: String, @@ -133,18 +321,24 @@ enum VaultHistoryTableRow { agent: SessionAgent?, action: VaultHistoryRowAction? ) + case workspace(WorkspaceHeader) + case topologyItem(TopologyItem) case event(event: VaultHistoryEvent, action: VaultHistoryRowAction?, agent: SessionAgent?) var id: VaultHistoryTableRowID { switch self { case .group(let id, _, _, _, _): return .group(id) + case .workspace(let header): return .workspace(header.id) + case .topologyItem(let item): return .topologyItem(item.id) case .event(let event, _, _): return .event(event.id) } } var isGroup: Bool { - if case .group = self { return true } - return false + switch self { + case .group, .workspace: return true + case .topologyItem, .event: return false + } } func hasEquivalentContent(to other: Self) -> Bool { @@ -160,6 +354,10 @@ enum VaultHistoryTableRow { && lhsAction == rhsAction case let (.event(lhsEvent, lhsAction, lhsAgent), .event(rhsEvent, rhsAction, rhsAgent)): return lhsEvent == rhsEvent && lhsAction == rhsAction && lhsAgent == rhsAgent + case let (.workspace(lhs), .workspace(rhs)): + return lhs == rhs + case let (.topologyItem(lhs), .topologyItem(rhs)): + return lhs == rhs default: return false } @@ -292,6 +490,28 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl onPerformAction: { [weak self] action in self?.actions.perform(action) } ) return cell + case .workspace(let header): + let cell = (tableView.makeView( + withIdentifier: VaultHistoryTableGroupCellView.reuseIdentifier, + owner: self + ) as? VaultHistoryTableGroupCellView) ?? VaultHistoryTableGroupCellView() + cell.configureWorkspace( + header, + globalFontMagnificationPercent: globalFontMagnificationPercent, + onPerformAction: { [weak self] action in self?.actions.perform(action) } + ) + return cell + case .topologyItem(let item): + let cell = (tableView.makeView( + withIdentifier: VaultHistoryTableEventCellView.reuseIdentifier, + owner: self + ) as? VaultHistoryTableEventCellView) ?? VaultHistoryTableEventCellView() + cell.configure( + topologyItem: item, + globalFontMagnificationPercent: globalFontMagnificationPercent, + onPerformAction: { [weak self] action in self?.actions.perform(action) } + ) + return cell case .event(let event, let action, let agent): let cell = (tableView.makeView( withIdentifier: VaultHistoryTableEventCellView.reuseIdentifier, @@ -310,8 +530,21 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl @objc private func handleDoubleClick(_ sender: NSTableView) { let row = sender.clickedRow - guard rows.indices.contains(row), case .event(_, let action?, _) = rows[row] else { return } - actions.perform(action) + guard rows.indices.contains(row) else { return } + switch rows[row] { + case .event(_, let action?, _): + actions.perform(action) + case .topologyItem(let item): + guard let action = item.action else { return } + actions.perform(action) + case .workspace(let header): + guard let action = header.action else { return } + actions.perform(action) + case .group(_, _, _, _, let action?) : + actions.perform(action) + default: + return + } } private struct ApplyInput { diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 57119874fc85..2adc15e71764 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -92,6 +92,7 @@ final class VaultHistoryTimelineModel { } private(set) var groups: [VaultHistoryGroup] = [] + private(set) var workspaceSections: [VaultHistoryWorkspaceTimelineProjection.Section] = [] private(set) var resumeEntriesByEventId: [String: SessionEntry] = [:] private(set) var isLoading = false /// True once the first refresh completed, so the empty state does not @@ -108,9 +109,12 @@ final class VaultHistoryTimelineModel { private let log: VaultHistoryEventLog private let grouper: VaultHistoryGrouper private let projection = VaultHistorySessionEventProjection() + private let workspaceProjection = VaultHistoryWorkspaceTimelineProjection() + private let agentBindingStore: VaultHistoryAgentBindingStore private let defaults: UserDefaults private let now: () -> Date private var mergedEvents: [VaultHistoryEvent] = [] + private var topology = VaultHistoryWorkspaceTopology(workspaces: []) private var refreshTask: Task? var hasActiveFilters: Bool { @@ -122,11 +126,13 @@ final class VaultHistoryTimelineModel { log: VaultHistoryEventLog, mode: VaultHistoryMode = .timeline, grouper: VaultHistoryGrouper = VaultHistoryGrouper(), + agentBindingStore: VaultHistoryAgentBindingStore = VaultHistoryAgentBindingStore(), defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init ) { self.log = log self.grouper = grouper + self.agentBindingStore = agentBindingStore self.defaults = defaults self.now = now self.mode = mode @@ -139,15 +145,24 @@ final class VaultHistoryTimelineModel { /// 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]) { + func refresh( + sessionEntries: [SessionEntry], + topology: VaultHistoryWorkspaceTopology + ) { refreshTask?.cancel() isLoading = true let log = log + let agentBindingStore = agentBindingStore refreshTask = Task { [weak self] in - let recorded = await log.recentEvents() + async let recordedEvents = log.recentEvents() + async let agentBindings = agentBindingStore.load() + let (recorded, bindings) = await (recordedEvents, agentBindings) guard !Task.isCancelled, let self else { return } var merged = recorded - merged.append(contentsOf: self.projection.events(from: sessionEntries)) + merged.append(contentsOf: self.projection.events( + from: sessionEntries, + bindings: bindings + )) // 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. @@ -159,6 +174,7 @@ final class VaultHistoryTimelineModel { merged.removeLast(merged.count - Self.maxTimelineEvents) } self.mergedEvents = merged + self.topology = topology var resumeEntries: [String: SessionEntry] = [:] for entry in sessionEntries { resumeEntries["session:\(entry.agent.rawValue):\(entry.id)"] = entry @@ -182,11 +198,22 @@ final class VaultHistoryTimelineModel { sortOrder: sortOrder, searchText: searchText ) - groups = grouper.groups( - events: mode.includedEvents(from: mergedEvents), - by: mode.groupKey, - query: query, - now: currentTime - ) + if mode == .timeline { + workspaceSections = workspaceProjection.sections( + topology: topology, + events: mode.includedEvents(from: mergedEvents), + query: query, + now: currentTime + ) + groups = [] + } else { + workspaceSections = [] + groups = grouper.groups( + events: mode.includedEvents(from: mergedEvents), + by: mode.groupKey, + query: query, + now: currentTime + ) + } } } diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index eb95da4dbeef..b71ffb6511d6 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -3,6 +3,7 @@ import SwiftUI /// The History timeline, combining workspace/window lifecycle events with agent sessions. struct VaultHistoryView: View { let mode: VaultHistoryMode + @ObservedObject private var tabManager: TabManager @ObservedObject private var sessionStore: SessionIndexStore @ObservedObject private var closedItemStore: ClosedItemHistoryStore private let log: VaultHistoryEventLog @@ -12,6 +13,7 @@ struct VaultHistoryView: View { init( mode: VaultHistoryMode, + tabManager: TabManager, sessionStore: SessionIndexStore, closedItemStore: ClosedItemHistoryStore, log: VaultHistoryEventLog, @@ -19,6 +21,7 @@ struct VaultHistoryView: View { onReopenClosedItem: ((UUID) -> Bool)? ) { self.mode = mode + self.tabManager = tabManager self.sessionStore = sessionStore self.closedItemStore = closedItemStore self.log = log @@ -29,6 +32,7 @@ struct VaultHistoryView: View { var body: some View { VaultHistoryContentView( + tabManager: tabManager, sessionStore: sessionStore, closedItemStore: closedItemStore, log: log, @@ -43,6 +47,7 @@ struct VaultHistoryView: View { } private struct VaultHistoryContentView: View { + @ObservedObject var tabManager: TabManager @ObservedObject var sessionStore: SessionIndexStore @ObservedObject var closedItemStore: ClosedItemHistoryStore let log: VaultHistoryEventLog @@ -59,7 +64,7 @@ private struct VaultHistoryContentView: View { ) if !model.didLoad { VaultHistoryLoadingView() - } else if model.groups.isEmpty { + } else if model.groups.isEmpty && model.workspaceSections.isEmpty { VaultHistoryEmptyView( hasActiveFilters: model.hasActiveFilters, onClearFilters: model.clearFilters @@ -67,11 +72,14 @@ private struct VaultHistoryContentView: View { } else { VaultHistoryTimelineList( groups: model.groups, + workspaceSections: model.workspaceSections, resumeEntriesByEventId: model.resumeEntriesByEventId, availableClosedItemIds: closedItemStore.recordIdsSnapshot, actions: VaultHistoryRowActions( onResume: onResume, - onReopenClosedItem: onReopenClosedItem + onReopenClosedItem: onReopenClosedItem, + onActivateWorkspace: activateWorkspace, + onActivateTerminal: activateTerminal ) ) } @@ -80,19 +88,107 @@ private struct VaultHistoryContentView: View { if sessionStore.entries.isEmpty && !sessionStore.isLoading { sessionStore.reload() } - model.refresh(sessionEntries: sessionStore.entries) + refresh() } .onChange(of: sessionStore.entries) { _, entries in - model.refresh(sessionEntries: entries) + refresh(sessionEntries: entries) } .onChange(of: log.revision) { _, _ in - model.refresh(sessionEntries: sessionStore.entries) + refresh() + } + .onChange(of: closedItemStore.revision) { _, _ in + refresh() + } + .onChange(of: tabManager.tabs.map(\.id)) { _, _ in + refresh() + } + .task { + for await _ in NotificationCenter.default.notifications( + named: .vaultHistoryLiveTopologyDidChange + ) { + guard !Task.isCancelled else { return } + refresh() + } + } + .task { + for await _ in NotificationCenter.default.notifications( + named: .sharedLiveAgentIndexDidChange + ) { + guard !Task.isCancelled else { return } + refresh() + } + } + .task { + for await _ in NotificationCenter.default.notifications( + named: .workspaceTitleDidChange + ) { + guard !Task.isCancelled else { return } + refresh() + } + } + .task { + for await _ in NotificationCenter.default.notifications( + named: .workspaceCurrentDirectoryDidChange + ) { + guard !Task.isCancelled else { return } + refresh() + } } } private func reload() { sessionStore.reload() - model.refresh(sessionEntries: sessionStore.entries) + refresh() + } + + @discardableResult + private func activateWorkspace(_ workspaceId: UUID) -> Bool { + guard let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) ?? ( + tabManager.workspacesById[workspaceId] == nil ? nil : tabManager + ), + let workspace = manager.workspacesById[workspaceId] else { + return false + } + focusWindow(for: manager) + manager.selectWorkspace(workspace) + return true + } + + @discardableResult + private func activateTerminal(_ workspaceId: UUID, _ terminalId: UUID) -> Bool { + guard let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) ?? ( + tabManager.workspacesById[workspaceId] == nil ? nil : tabManager + ), + let workspace = manager.workspacesById[workspaceId], + workspace.panels[terminalId] != nil else { + return false + } + focusWindow(for: manager) + manager.selectWorkspace(workspace) + workspace.focusPanel(terminalId) + return true + } + + private func focusWindow(for manager: TabManager) { + guard let appDelegate = AppDelegate.shared, + let windowId = appDelegate.windowId(for: manager) else { + return + } + _ = appDelegate.focusScriptableMainWindow( + windowId: windowId, + bringToFront: true + ) + } + + private func refresh(sessionEntries: [SessionEntry]? = nil) { + let topology = VaultHistoryWorkspaceTopology.Snapshotter().capture( + fallbackTabManager: tabManager, + closedRecords: closedItemStore.recordsSnapshot + ) + model.refresh( + sessionEntries: sessionEntries ?? sessionStore.entries, + topology: topology + ) } } diff --git a/Sources/VaultHistoryWorkspaceTimelineProjection.swift b/Sources/VaultHistoryWorkspaceTimelineProjection.swift new file mode 100644 index 000000000000..cf1a9c881b0c --- /dev/null +++ b/Sources/VaultHistoryWorkspaceTimelineProjection.swift @@ -0,0 +1,426 @@ +import Foundation + +/// Joins durable events to live and closed workspace topology in linear time. +struct VaultHistoryWorkspaceTimelineProjection: Sendable { + struct Agent: Equatable, Sendable { + let id: String + let agent: SessionAgent + let sessionId: String + let title: String + let updatedAt: Date + let state: VaultHistoryWorkspaceTopology.AgentState + let event: VaultHistoryEvent? + } + + struct Terminal: Equatable, Sendable { + let id: String + let runtimeId: UUID? + let title: String + let directory: String? + let agents: [Agent] + } + + struct Section: Identifiable, Equatable, Sendable { + let id: String + let workspaceId: UUID? + let title: String + let directory: String? + let windowLabel: String? + let timestamp: Date + let state: VaultHistoryWorkspaceTopology.WorkspaceState + let isSelected: Bool + let closedItemId: UUID? + let terminals: [Terminal] + let activityEvents: [VaultHistoryEvent] + } + + func sections( + topology: VaultHistoryWorkspaceTopology, + events: [VaultHistoryEvent], + query: VaultHistoryQuery, + now: Date + ) -> [Section] { + let visibleEvents = query.visibleEvents(from: events, now: now) + var workspaceIndexByIdentity: [String: Int] = [:] + for (index, workspace) in topology.workspaces.enumerated() { + for identity in Self.workspaceIdentities(workspace) { + if workspaceIndexByIdentity[identity] == nil { + workspaceIndexByIdentity[identity] = index + } + } + } + + var matchedEvents = Array(repeating: [VaultHistoryEvent](), count: topology.workspaces.count) + var orphanEventsByIdentity: [String: [VaultHistoryEvent]] = [:] + var orphanOrder: [String] = [] + for event in visibleEvents { + let identities = Self.workspaceIdentities(event) + if let index = identities.lazy.compactMap({ workspaceIndexByIdentity[$0] }).first { + matchedEvents[index].append(event) + } else if let identity = identities.first { + if orphanEventsByIdentity[identity] == nil { + orphanOrder.append(identity) + } + orphanEventsByIdentity[identity, default: []].append(event) + } + } + + let searchTokens = query.searchText + .split(whereSeparator: \.isWhitespace) + .map(String.init) + var projected: [Section] = [] + for (index, workspace) in topology.workspaces.enumerated() { + let eventsForWorkspace = matchedEvents[index] + let searchMatches = searchTokens.isEmpty + || !eventsForWorkspace.isEmpty + || Self.topologyMatches(workspace, tokens: searchTokens) + guard searchMatches else { continue } + if workspace.state == .closed, + !query.timeRange.includes(workspace.timestamp, relativeTo: now), + eventsForWorkspace.isEmpty { + continue + } + projected.append(section( + workspace: workspace, + events: eventsForWorkspace, + sortOrder: query.sortOrder + )) + } + + for identity in orphanOrder { + guard let orphanEvents = orphanEventsByIdentity[identity], !orphanEvents.isEmpty else { + continue + } + projected.append(orphanSection( + identity: identity, + events: orphanEvents, + sortOrder: query.sortOrder + )) + } + return sortSections(projected, order: query.sortOrder) + } + + private func section( + workspace: VaultHistoryWorkspaceTopology.Workspace, + events: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> Section { + let terminalNodes = terminals( + snapshots: workspace.terminals, + sessionEvents: events.filter { $0.kind == .sessionActivity }, + sortOrder: sortOrder + ) + let activityEvents = events.filter { $0.kind != .sessionActivity } + let newestTimestamp = ( + terminalNodes.flatMap(\.agents).map(\.updatedAt) + + events.map(\.timestamp) + + [workspace.timestamp] + ).max() ?? workspace.timestamp + let latestTitle = activityEvents + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .max { $0.timestamp < $1.timestamp }? + .title + return Section( + id: workspace.id, + workspaceId: workspace.workspaceId, + title: Self.normalized(workspace.title) + ?? Self.normalized(latestTitle) + ?? "", + directory: workspace.directory, + windowLabel: workspace.windowLabel, + timestamp: newestTimestamp, + state: workspace.state, + isSelected: workspace.isSelected, + closedItemId: workspace.closedItemId ?? Self.latestClosedItemId(in: activityEvents), + terminals: terminalNodes, + activityEvents: activityEvents + ) + } + + private func orphanSection( + identity: String, + events: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> Section { + let title = events + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .max { $0.timestamp < $1.timestamp }? + .title ?? "" + return Section( + id: identity, + workspaceId: nil, + title: title, + directory: events.lazy.compactMap(\.subject.directory).first, + windowLabel: nil, + timestamp: events.map(\.timestamp).max() ?? .distantPast, + state: .closed, + isSelected: false, + closedItemId: Self.latestClosedItemId(in: events), + terminals: terminals( + snapshots: [], + sessionEvents: events.filter { $0.kind == .sessionActivity }, + sortOrder: sortOrder + ), + activityEvents: events.filter { $0.kind != .sessionActivity } + ) + } + + private func terminals( + snapshots: [VaultHistoryWorkspaceTopology.Terminal], + sessionEvents: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> [Terminal] { + var nodes = snapshots.map { snapshot in + TerminalBuilder( + id: Self.terminalIdentity(id: snapshot.id, stableId: snapshot.stableId), + runtimeId: snapshot.id, + title: snapshot.title, + directory: snapshot.directory, + snapshots: snapshot.agentSessions, + events: [] + ) + } + var indexByIdentity: [String: Int] = [:] + for (index, snapshot) in snapshots.enumerated() { + indexByIdentity["surface:\(snapshot.id.uuidString)"] = index + if let stableId = snapshot.stableId { + indexByIdentity["surface-stable:\(stableId.uuidString)"] = index + } + } + var syntheticIndexByIdentity: [String: Int] = [:] + for event in sessionEvents { + let identities = Self.surfaceIdentities(event) + if let index = identities.lazy.compactMap({ indexByIdentity[$0] }).first { + nodes[index].events.append(event) + continue + } + let identity = identities.first ?? "session-terminal:\(event.id)" + if let index = syntheticIndexByIdentity[identity] { + nodes[index].events.append(event) + continue + } + syntheticIndexByIdentity[identity] = nodes.count + nodes.append(TerminalBuilder( + id: identity, + runtimeId: nil, + title: "", + directory: event.subject.directory, + snapshots: [], + events: [event] + )) + } + return nodes.map { builder in + Terminal( + id: builder.id, + runtimeId: builder.runtimeId, + title: builder.title, + directory: builder.directory, + agents: agents( + snapshots: builder.snapshots, + events: builder.events, + sortOrder: sortOrder + ) + ) + } + } + + private func agents( + snapshots: [VaultHistoryWorkspaceTopology.AgentSession], + events: [VaultHistoryEvent], + sortOrder: VaultHistoryQuery.SortOrder + ) -> [Agent] { + var builders: [String: AgentBuilder] = [:] + var order: [String] = [] + for snapshot in snapshots { + let key = Self.agentIdentity( + agentId: snapshot.agent.rawValue, + sessionId: snapshot.sessionId + ) + order.append(key) + builders[key] = AgentBuilder( + agent: snapshot.agent, + sessionId: snapshot.sessionId, + title: snapshot.title ?? "", + updatedAt: snapshot.updatedAt, + state: snapshot.state, + event: nil + ) + } + for event in events { + guard let agentId = event.subject.agent, + let sessionId = event.subject.sessionId else { + continue + } + let key = Self.agentIdentity(agentId: agentId, sessionId: sessionId) + let resolvedAgent = SessionAgent(rawValue: agentId) + ?? .registered(RegisteredSessionAgent( + id: agentId, + name: event.subject.agentDisplayName + )) + if var existing = builders[key] { + existing.title = Self.normalized(event.title) ?? existing.title + existing.updatedAt = max(existing.updatedAt, event.timestamp) + existing.event = event + builders[key] = existing + } else { + order.append(key) + builders[key] = AgentBuilder( + agent: resolvedAgent, + sessionId: sessionId, + title: event.title, + updatedAt: event.timestamp, + state: .saved, + event: event + ) + } + } + let result = order.compactMap { key -> Agent? in + guard let builder = builders[key] else { return nil } + return Agent( + id: key, + agent: builder.agent, + sessionId: builder.sessionId, + title: builder.title, + updatedAt: builder.updatedAt, + state: builder.state, + event: builder.event + ) + } + return result.sorted { lhs, rhs in + let lhsPriority = Self.agentStatePriority(lhs.state) + let rhsPriority = Self.agentStatePriority(rhs.state) + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + switch sortOrder { + case .oldestFirst: + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt < rhs.updatedAt } + case .newestFirst, .titleAscending: + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + } + return lhs.id < rhs.id + } + } + + private func sortSections( + _ sections: [Section], + order: VaultHistoryQuery.SortOrder + ) -> [Section] { + sections.sorted { lhs, rhs in + if lhs.state != rhs.state { return lhs.state == .active } + if lhs.isSelected != rhs.isSelected { return lhs.isSelected } + switch order { + case .newestFirst: + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + case .oldestFirst: + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + case .titleAscending: + let comparison = lhs.title.localizedStandardCompare(rhs.title) + if comparison != .orderedSame { return comparison == .orderedAscending } + } + return lhs.id < rhs.id + } + } + + private static func topologyMatches( + _ workspace: VaultHistoryWorkspaceTopology.Workspace, + tokens: [String] + ) -> Bool { + var fields = [ + workspace.title, + workspace.directory ?? "", + workspace.windowLabel ?? "", + workspace.workspaceId?.uuidString ?? "", + workspace.stableId?.uuidString ?? "", + ] + for terminal in workspace.terminals { + fields.append(terminal.title) + fields.append(terminal.directory ?? "") + fields.append(terminal.id.uuidString) + fields.append(terminal.stableId?.uuidString ?? "") + for agent in terminal.agentSessions { + fields.append(agent.title ?? "") + fields.append(agent.agent.rawValue) + fields.append(agent.agent.displayName) + fields.append(agent.sessionId) + } + } + return tokens.allSatisfy { token in + fields.contains { $0.localizedCaseInsensitiveContains(token) } + } + } + + private static func workspaceIdentities( + _ workspace: VaultHistoryWorkspaceTopology.Workspace + ) -> [String] { + [ + workspace.stableId.map { "workspace-stable:\($0.uuidString)" }, + workspace.workspaceId.map { "workspace:\($0.uuidString)" }, + ].compactMap { $0 } + } + + private static func workspaceIdentities(_ event: VaultHistoryEvent) -> [String] { + [ + event.subject.workspaceStableId.map { "workspace-stable:\($0.uuidString)" }, + event.subject.workspaceId.map { "workspace:\($0.uuidString)" }, + ].compactMap { $0 } + } + + private static func surfaceIdentities(_ event: VaultHistoryEvent) -> [String] { + [ + event.subject.surfaceStableId.map { "surface-stable:\($0.uuidString)" }, + event.subject.surfaceId.map { "surface:\($0.uuidString)" }, + ].compactMap { $0 } + } + + private static func terminalIdentity(id: UUID, stableId: UUID?) -> String { + stableId.map { "surface-stable:\($0.uuidString)" } ?? "surface:\(id.uuidString)" + } + + private static func agentIdentity(agentId: String, sessionId: String) -> String { + "agent-session:\(agentId):\(sessionId)" + } + + private static func latestClosedItemId(in events: [VaultHistoryEvent]) -> UUID? { + events + .filter { $0.kind == .workspaceClosed && $0.subject.closedItemId != nil } + .max { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.id < rhs.id + }? + .subject + .closedItemId + } + + private static func agentStatePriority( + _ state: VaultHistoryWorkspaceTopology.AgentState + ) -> Int { + switch state { + case .running: return 0 + case .restoring: return 1 + case .hibernated: return 2 + case .saved: return 3 + } + } + + private static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty || trimmed == "." ? nil : trimmed + } + + private struct TerminalBuilder { + let id: String + let runtimeId: UUID? + let title: String + let directory: String? + let snapshots: [VaultHistoryWorkspaceTopology.AgentSession] + var events: [VaultHistoryEvent] + } + + private struct AgentBuilder { + let agent: SessionAgent + let sessionId: String + var title: String + var updatedAt: Date + let state: VaultHistoryWorkspaceTopology.AgentState + var event: VaultHistoryEvent? + } +} diff --git a/Sources/VaultHistoryWorkspaceTopology.swift b/Sources/VaultHistoryWorkspaceTopology.swift new file mode 100644 index 000000000000..287a01eb3aa3 --- /dev/null +++ b/Sources/VaultHistoryWorkspaceTopology.swift @@ -0,0 +1,319 @@ +import Foundation + +/// Immutable workspace, terminal, and agent-session topology rendered by History. +/// +/// Live values are captured on the main actor, then the History model and AppKit +/// table receive only value snapshots. A restored workspace therefore has the +/// same shape as a workspace that never left the current process. +struct VaultHistoryWorkspaceTopology: Equatable, Sendable { + enum WorkspaceState: Equatable, Sendable { + case active + case closed + } + + enum AgentState: Equatable, Sendable { + case running + case restoring + case hibernated + case saved + } + + struct AgentSession: Equatable, Sendable { + let agent: SessionAgent + let sessionId: String + let title: String? + let updatedAt: Date + let state: AgentState + } + + struct Terminal: Equatable, Sendable { + let id: UUID + let stableId: UUID? + let title: String + let directory: String? + let agentSessions: [AgentSession] + } + + struct Workspace: Equatable, Sendable { + let id: String + let workspaceId: UUID? + let stableId: UUID? + let windowId: UUID? + let windowLabel: String? + let title: String + let directory: String? + let timestamp: Date + let state: WorkspaceState + let isSelected: Bool + let closedItemId: UUID? + let terminals: [Terminal] + } + + let workspaces: [Workspace] + + @MainActor + struct Snapshotter { + func capture( + fallbackTabManager: TabManager, + closedRecords: [ClosedItemHistoryRecord] + ) -> VaultHistoryWorkspaceTopology { + let active = activeWorkspaces(fallbackTabManager: fallbackTabManager) + let activeIdentities = Set(active.flatMap(Self.workspaceIdentityCandidates)) + let closed = closedWorkspaces(from: closedRecords).filter { workspace in + activeIdentities.isDisjoint(with: Self.workspaceIdentityCandidates(workspace)) + } + return VaultHistoryWorkspaceTopology(workspaces: active + closed) + } + + private func activeWorkspaces(fallbackTabManager: TabManager) -> [Workspace] { + var windowSources: [(windowId: UUID?, manager: TabManager, isKey: Bool)] = [] + if let appDelegate = AppDelegate.shared { + windowSources = appDelegate.listMainWindowSummaries().compactMap { summary in + appDelegate.tabManagerFor(windowId: summary.windowId).map { + (windowId: summary.windowId, manager: $0, isKey: summary.isKeyWindow) + } + } + } + if !windowSources.contains(where: { $0.manager === fallbackTabManager }) { + windowSources.append(( + windowId: AppDelegate.shared?.windowId(for: fallbackTabManager), + manager: fallbackTabManager, + isKey: fallbackTabManager.window?.isKeyWindow ?? false + )) + } + windowSources.sort { lhs, rhs in + if lhs.isKey != rhs.isKey { return lhs.isKey } + return (lhs.windowId?.uuidString ?? "") < (rhs.windowId?.uuidString ?? "") + } + + var seenManagers: Set = [] + let uniqueSources = windowSources.filter { + seenManagers.insert(ObjectIdentifier($0.manager)).inserted + } + let showsWindowLabels = uniqueSources.count > 1 + let liveIndex = SharedLiveAgentIndex.shared.index + + return uniqueSources.enumerated().flatMap { windowIndex, source in + let windowLabel = showsWindowLabels + ? String( + format: String(localized: "vaultHistory.window.numbered", defaultValue: "Window %d"), + windowIndex + 1 + ) + : nil + return source.manager.tabs.map { workspace in + let terminals = workspace.sidebarOrderedPanelIds().compactMap { panelId -> Terminal? in + guard let terminal = workspace.panels[panelId] as? TerminalPanel else { + return nil + } + let directory = Self.normalized(workspace.panelDirectories[panelId]) + ?? Self.normalized(terminal.directory) + ?? Self.normalized(terminal.requestedWorkingDirectory) + let title = Self.normalized( + workspace.panelTitle(panelId: panelId) ?? terminal.displayTitle + ) ?? "" + let indexedEntry = liveIndex?.entry( + workspaceId: workspace.id, + panelId: panelId + ) + let hibernatedSnapshot = terminal.agentHibernationState?.agent + let restoredSnapshot = workspace.restoredAgentSnapshotsByPanelId[panelId] + let agentSnapshot = indexedEntry?.snapshot + ?? hibernatedSnapshot + ?? restoredSnapshot + let restoredState: AgentState? = switch workspace + .restoredAgentResumeStatesByPanelId[panelId] { + case .autoResumeCommandRunning, .awaitingAutoResumeCommand: + .restoring + case .observedAgentCommandRunning: + .running + case .manualResumeAvailable, .completedAgentExit: + .saved + case nil: + nil + } + let agentSessions = agentSnapshot.map { snapshot in + [ + AgentSession( + agent: Self.sessionAgent(for: snapshot), + sessionId: snapshot.sessionId, + title: nil, + updatedAt: indexedEntry.map { + Date(timeIntervalSince1970: $0.updatedAt) + } ?? workspace.createdAt, + state: Self.agentState( + indexedEntry: indexedEntry, + hasHibernatedSnapshot: hibernatedSnapshot != nil, + restoredState: restoredState, + hasRestoredSnapshot: restoredSnapshot != nil + ) + ), + ] + } ?? [] + return Terminal( + id: panelId, + stableId: terminal.stableSurfaceId, + title: title, + directory: directory, + agentSessions: agentSessions + ) + } + let title = Self.normalized( + source.manager.resolvedWorkspaceDisplayTitle(for: workspace) + ) ?? "" + return Workspace( + id: Self.workspaceIdentity( + workspaceId: workspace.id, + stableId: workspace.stableId + ), + workspaceId: workspace.id, + stableId: workspace.stableId, + windowId: source.windowId, + windowLabel: windowLabel, + title: title, + directory: Self.normalized(workspace.currentDirectory), + timestamp: workspace.createdAt, + state: .active, + isSelected: source.manager.selectedTabId == workspace.id, + closedItemId: nil, + terminals: terminals + ) + } + } + } + + private func closedWorkspaces(from records: [ClosedItemHistoryRecord]) -> [Workspace] { + var result: [Workspace] = [] + var seen: Set = [] + for record in records.sorted(by: { $0.closedAt > $1.closedAt }) { + switch record.entry { + case .panel: + continue + case .workspace(let entry): + let workspace = closedWorkspace( + snapshot: entry.snapshot, + windowId: entry.windowId, + closedAt: record.closedAt, + closedItemId: record.id, + fallbackId: record.id.uuidString + ) + if seen.insert(workspace.id).inserted { + result.append(workspace) + } + case .window(let entry): + for (index, snapshot) in entry.snapshot.tabManager.workspaces.enumerated() { + let workspace = closedWorkspace( + snapshot: snapshot, + windowId: entry.windowId ?? entry.snapshot.windowId, + closedAt: record.closedAt, + closedItemId: record.id, + fallbackId: "\(record.id.uuidString):\(index)" + ) + if seen.insert(workspace.id).inserted { + result.append(workspace) + } + } + } + } + return result + } + + private func closedWorkspace( + snapshot: SessionWorkspaceSnapshot, + windowId: UUID?, + closedAt: Date, + closedItemId: UUID, + fallbackId: String + ) -> Workspace { + let terminals = snapshot.panels.compactMap { panel -> Terminal? in + guard panel.type == .terminal else { return nil } + let agentSessions = panel.terminal?.agent.map { agent in + [ + AgentSession( + agent: Self.sessionAgent(for: agent), + sessionId: agent.sessionId, + title: nil, + updatedAt: closedAt, + state: .saved + ), + ] + } ?? [] + return Terminal( + id: panel.id, + stableId: panel.stableSurfaceId, + title: Self.normalized(panel.customTitle ?? panel.title) ?? "", + directory: Self.normalized(panel.directory ?? panel.terminal?.workingDirectory), + agentSessions: agentSessions + ) + } + let title = Self.normalized(snapshot.customTitle) + ?? Self.normalized(snapshot.processTitle) + ?? Self.normalized((snapshot.currentDirectory as NSString).lastPathComponent) + ?? "" + return Workspace( + id: snapshot.workspaceId.map { + Self.workspaceIdentity(workspaceId: $0, stableId: snapshot.stableId) + } ?? snapshot.stableId.map { "workspace-stable:\($0.uuidString)" } + ?? "closed-workspace:\(fallbackId)", + workspaceId: snapshot.workspaceId, + stableId: snapshot.stableId, + windowId: windowId, + windowLabel: nil, + title: title, + directory: Self.normalized(snapshot.currentDirectory), + timestamp: closedAt, + state: .closed, + isSelected: false, + closedItemId: closedItemId, + terminals: terminals + ) + } + + static func agentState( + indexedEntry: RestorableAgentSessionIndex.Entry?, + hasHibernatedSnapshot: Bool, + restoredState: AgentState?, + hasRestoredSnapshot: Bool + ) -> AgentState { + if indexedEntry?.processIDs.isEmpty == false { return .running } + if hasHibernatedSnapshot { return .hibernated } + if let restoredState { return restoredState } + if hasRestoredSnapshot { return .saved } + return .saved + } + + private static func sessionAgent( + for snapshot: SessionRestorableAgentSnapshot + ) -> SessionAgent { + if let registration = snapshot.registration { + return .registered(RegisteredSessionAgent(registration: registration)) + } + if let builtIn = SessionAgent(rawValue: snapshot.kind.rawValue), + SessionAgent.builtInCases.contains(builtIn) { + return builtIn + } + return .registered(RegisteredSessionAgent( + id: snapshot.kind.rawValue, + name: snapshot.kind.displayName + )) + } + + private static func workspaceIdentity(workspaceId: UUID, stableId: UUID?) -> String { + if let stableId { + return "workspace-stable:\(stableId.uuidString)" + } + return "workspace:\(workspaceId.uuidString)" + } + + private static func workspaceIdentityCandidates(_ workspace: Workspace) -> [String] { + [ + workspace.workspaceId.map { "workspace:\($0.uuidString)" }, + workspace.stableId.map { "workspace-stable:\($0.uuidString)" }, + ].compactMap { $0 } + } + + private static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty || trimmed == "." ? nil : trimmed + } + } +} diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 32446af629de..0f76be6b979b 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -5,6 +5,7 @@ import SwiftUI /// 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 tabManager: TabManager @ObservedObject var store: SessionIndexStore @ObservedObject var closedItemStore: ClosedItemHistoryStore let onResume: ((SessionEntry) -> Void)? @@ -20,6 +21,7 @@ struct VaultPaneView: View { tabBar VaultHistoryView( mode: selectedMode, + tabManager: tabManager, sessionStore: store, closedItemStore: closedItemStore, log: .shared, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e418d714dd61..08769892eb2c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2251,6 +2251,7 @@ 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 */; }; + 2A372B93540345F9B9B4507E /* VaultHistoryAgentBindingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04335AC5127241C096D12FA2 /* VaultHistoryAgentBindingStore.swift */; }; AE727B475E634C9BA35472F6 /* VaultHistoryControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */; }; 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */; }; 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */; }; @@ -2267,6 +2268,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 4A1150B7AB574080BB627944 /* VaultHistoryTimelineList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.swift */; }; 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */; }; 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED5FD01E3829315521B1BF5 /* VaultHistoryView.swift */; }; + DFCBA05B0A7B42B9B9699665 /* VaultHistoryWorkspaceTimelineProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD905F84DD4A78A8A8C7E7 /* VaultHistoryWorkspaceTimelineProjection.swift */; }; + D5F58A2D64AE41DCADEC461D /* VaultHistoryWorkspaceTopology.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE923F1D68E4120AAE7898B /* VaultHistoryWorkspaceTopology.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 */; }; @@ -4667,6 +4670,7 @@ 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 = ""; }; + 04335AC5127241C096D12FA2 /* VaultHistoryAgentBindingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryAgentBindingStore.swift; sourceTree = ""; }; 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryControls.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 = ""; }; @@ -4683,6 +4687,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryTimelineList.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 = ""; }; + 3CAD905F84DD4A78A8A8C7E7 /* VaultHistoryWorkspaceTimelineProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryWorkspaceTimelineProjection.swift; sourceTree = ""; }; + BFE923F1D68E4120AAE7898B /* VaultHistoryWorkspaceTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultHistoryWorkspaceTopology.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 = ""; }; @@ -6535,12 +6541,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 850000000000000000000017 /* SessionIndexSnapshotLoader.swift */, 850000000000000000000018 /* SessionIndexJSONLReader.swift */, A5ABE7DEEF21FA1E25C7BD76 /* VaultHistoryEvent.swift */, + 04335AC5127241C096D12FA2 /* VaultHistoryAgentBindingStore.swift */, 21472B37A3BB6D24E6CD212C /* VaultHistoryRetentionPolicy.swift */, EB3D13D3F850934E17DBB1BD /* VaultHistoryEventStore.swift */, BA30FDCC55E285C85299D5C9 /* VaultHistoryEventLog.swift */, 6CE6B2C8825FE514FE0D947B /* VaultHistoryGrouping.swift */, C9A7D805E6B00757D85E0F11 /* VaultHistorySessionEvents.swift */, EBCF94F6D952287FECE4FA60 /* VaultHistoryTimelineModel.swift */, + BFE923F1D68E4120AAE7898B /* VaultHistoryWorkspaceTopology.swift */, + 3CAD905F84DD4A78A8A8C7E7 /* VaultHistoryWorkspaceTimelineProjection.swift */, 1F9ACCCA76B04B2D8225691F /* VaultHistoryQuery.swift */, 9C7FD25F0FDF476B9DD1C601 /* VaultHistoryControls.swift */, C4C1EC668AD64AF5B2CFF402 /* VaultHistoryTimelineList.swift */, @@ -9312,6 +9321,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B3575000000000000000000E /* VaultAgentRegistry+Campfire.swift in Sources */, 858200000000000000000004 /* VaultAgentRegistry+Kimi.swift in Sources */, B35750000000000000000002 /* VaultAgentRegistry.swift in Sources */, + 2A372B93540345F9B9B4507E /* VaultHistoryAgentBindingStore.swift in Sources */, AE727B475E634C9BA35472F6 /* VaultHistoryControls.swift in Sources */, 6EFF5D9FB0205B42DFC0BF5A /* VaultHistoryEvent.swift in Sources */, 5671C166351F759D86ED8DB6 /* VaultHistoryEventLog.swift in Sources */, @@ -9326,6 +9336,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4A1150B7AB574080BB627944 /* VaultHistoryTimelineList.swift in Sources */, 23AE7787371F29AEED31AB94 /* VaultHistoryTimelineModel.swift in Sources */, 9103E0870B14E013442F4FB4 /* VaultHistoryView.swift in Sources */, + DFCBA05B0A7B42B9B9699665 /* VaultHistoryWorkspaceTimelineProjection.swift in Sources */, + D5F58A2D64AE41DCADEC461D /* VaultHistoryWorkspaceTopology.swift in Sources */, F76550060000000000000001 /* VaultObservedAgentProcess.swift in Sources */, 437E021ADABFA29ADC68547C /* VaultPaneView.swift in Sources */, F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */, diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index c48ed6070f2b..6b785066c1a2 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -26,7 +26,10 @@ import Testing kind: VaultHistoryEventKind = .workspaceCreated, title: String = "", workspaceId: UUID? = nil, + workspaceStableId: UUID? = nil, windowId: UUID? = nil, + surfaceId: UUID? = nil, + surfaceStableId: UUID? = nil, agent: String? = nil, directory: String? = nil ) -> VaultHistoryEvent { @@ -37,7 +40,10 @@ import Testing title: title, subject: VaultHistorySubject( workspaceId: workspaceId, + workspaceStableId: workspaceStableId, windowId: windowId, + surfaceId: surfaceId, + surfaceStableId: surfaceStableId, sessionId: agent == nil ? nil : "session-\(id)", agent: agent, directory: directory @@ -353,6 +359,291 @@ import Testing #expect(groups.first?.title == "Review Bot") } + @Test func sessionProjectionUsesDurableTerminalBinding() throws { + let workspaceId = UUID() + let surfaceId = UUID() + let entry = SessionEntry( + id: "bound-session", + agent: .codex, + sessionId: "native-session", + title: "Implement topology", + cwd: "/tmp/repo", + gitBranch: nil, + pullRequest: nil, + modified: Self.now, + fileURL: nil, + specifics: .codex(model: nil, approvalPolicy: nil, sandboxMode: nil, effort: nil) + ) + let key = VaultHistoryAgentBindingStore.Key( + agentId: "codex", + sessionId: "native-session" + ) + let binding = VaultHistoryAgentBindingStore.Binding( + key: key, + workspaceId: workspaceId, + surfaceId: surfaceId, + updatedAt: Self.now + ) + + let projected = VaultHistorySessionEventProjection().events( + from: [entry], + bindings: [key: binding] + ) + let event = try #require(projected.first) + + #expect(event.subject.workspaceId == workspaceId) + #expect(event.subject.surfaceId == surfaceId) + } + + @Test func timelineIncludesEveryActiveWorkspaceAndNestsSessionsByTerminal() throws { + let workspaceId = UUID() + let stableWorkspaceId = UUID() + let firstTerminalId = UUID() + let secondTerminalId = UUID() + let topology = VaultHistoryWorkspaceTopology(workspaces: [ + VaultHistoryWorkspaceTopology.Workspace( + id: "workspace-stable:\(stableWorkspaceId.uuidString)", + workspaceId: workspaceId, + stableId: stableWorkspaceId, + windowId: UUID(), + windowLabel: nil, + title: "History", + directory: "/tmp/repo", + timestamp: Self.now.addingTimeInterval(-60 * 24 * 3600), + state: .active, + isSelected: true, + closedItemId: nil, + terminals: [ + VaultHistoryWorkspaceTopology.Terminal( + id: firstTerminalId, + stableId: UUID(), + title: "Backend", + directory: "/tmp/repo", + agentSessions: [ + VaultHistoryWorkspaceTopology.AgentSession( + agent: .claude, + sessionId: "claude-live", + title: "Fix restore", + updatedAt: Self.now.addingTimeInterval(-30), + state: .running + ), + ] + ), + VaultHistoryWorkspaceTopology.Terminal( + id: secondTerminalId, + stableId: UUID(), + title: "Frontend", + directory: "/tmp/repo", + agentSessions: [] + ), + ] + ), + ]) + let codexEvent = event( + id: "codex-bound", + secondsAgo: 10, + kind: .sessionActivity, + title: "Implement tree", + workspaceId: workspaceId, + surfaceId: secondTerminalId, + agent: "codex" + ) + + let sections = VaultHistoryWorkspaceTimelineProjection().sections( + topology: topology, + events: [codexEvent], + query: VaultHistoryQuery(timeRange: .last24Hours), + now: Self.now + ) + let section = try #require(sections.first) + + #expect(sections.count == 1) + #expect(section.terminals.map(\.title) == ["Backend", "Frontend"]) + #expect(section.terminals[0].agents.map(\.sessionId) == ["claude-live"]) + #expect(section.terminals[1].agents.map(\.sessionId) == ["session-codex-bound"]) + + let rows = VaultHistoryTimelineList.makeWorkspaceRows( + sections: sections, + resumeEntriesByEventId: [:], + availableClosedItemIds: [], + actions: VaultHistoryRowActions( + onResume: nil, + onReopenClosedItem: nil, + onActivateWorkspace: { _ in true }, + onActivateTerminal: { _, _ in true } + ) + ) + guard case .workspace(let header) = rows[0], + case .topologyItem(let firstTerminal) = rows[1], + case .topologyItem(let liveAgent) = rows[2] else { + Issue.record("Expected actionable workspace, terminal, and agent rows") + return + } + #expect(header.action == .activateWorkspace(workspaceId)) + let firstTerminalAction = VaultHistoryRowAction.activateTerminal( + workspaceId: workspaceId, + terminalId: firstTerminalId + ) + #expect(firstTerminal.action == firstTerminalAction) + #expect(liveAgent.action == firstTerminalAction) + } + + @Test func restoredWorkspaceReconnectsHistoryByStableIdentity() throws { + let previousWorkspaceId = UUID() + let restoredWorkspaceId = UUID() + let stableWorkspaceId = UUID() + let terminalId = UUID() + let topology = VaultHistoryWorkspaceTopology(workspaces: [ + VaultHistoryWorkspaceTopology.Workspace( + id: "workspace-stable:\(stableWorkspaceId.uuidString)", + workspaceId: restoredWorkspaceId, + stableId: stableWorkspaceId, + windowId: nil, + windowLabel: nil, + title: "Restored", + directory: "/tmp/restored", + timestamp: Self.now, + state: .active, + isSelected: true, + closedItemId: nil, + terminals: [ + VaultHistoryWorkspaceTopology.Terminal( + id: terminalId, + stableId: UUID(), + title: "Terminal", + directory: "/tmp/restored", + agentSessions: [ + VaultHistoryWorkspaceTopology.AgentSession( + agent: .opencode, + sessionId: "restored-opencode", + title: nil, + updatedAt: Self.now, + state: .restoring + ), + ] + ), + ] + ), + ]) + let preQuitEvent = event( + id: "before-quit", + secondsAgo: 60, + kind: .workspaceRenamed, + title: "Restored", + workspaceId: previousWorkspaceId, + workspaceStableId: stableWorkspaceId + ) + + let section = try #require( + VaultHistoryWorkspaceTimelineProjection().sections( + topology: topology, + events: [preQuitEvent], + query: VaultHistoryQuery(), + now: Self.now + ).first + ) + + #expect(section.activityEvents.map(\.id) == ["before-quit"]) + #expect(section.terminals.first?.agents.first?.sessionId == "restored-opencode") + #expect(section.terminals.first?.agents.first?.state == .restoring) + } + + @Test func normalQuitAndStartupRestoreDoNotCreateLifecycleHistory() { + #expect(VaultHistoryEventLog.shouldSuppressRecording( + isApplyingSessionRestore: false, + isTerminatingApp: true + )) + #expect(VaultHistoryEventLog.shouldSuppressRecording( + isApplyingSessionRestore: true, + isTerminatingApp: false + )) + #expect(!VaultHistoryEventLog.shouldSuppressRecording( + isApplyingSessionRestore: false, + isTerminatingApp: false + )) + } + + @MainActor + @Test func restoredAgentStatusDistinguishesAutoResumeFromManualResume() { + let restoring = VaultHistoryWorkspaceTopology.Snapshotter.agentState( + indexedEntry: nil, + hasHibernatedSnapshot: false, + restoredState: .restoring, + hasRestoredSnapshot: true + ) + let saved = VaultHistoryWorkspaceTopology.Snapshotter.agentState( + indexedEntry: nil, + hasHibernatedSnapshot: false, + restoredState: .saved, + hasRestoredSnapshot: true + ) + + #expect(restoring == .restoring) + #expect(saved == .saved) + } + + @Test func closedWorkspaceTreeKeepsRecoveryAtWorkspaceHeader() throws { + let closedItemId = UUID() + let workspaceId = UUID() + let terminalId = UUID() + let topology = VaultHistoryWorkspaceTopology(workspaces: [ + VaultHistoryWorkspaceTopology.Workspace( + id: "workspace:\(workspaceId.uuidString)", + workspaceId: workspaceId, + stableId: nil, + windowId: nil, + windowLabel: nil, + title: "Closed workspace", + directory: "/tmp/closed", + timestamp: Self.now, + state: .closed, + isSelected: false, + closedItemId: closedItemId, + terminals: [ + VaultHistoryWorkspaceTopology.Terminal( + id: terminalId, + stableId: nil, + title: "Agent terminal", + directory: "/tmp/closed", + agentSessions: [ + VaultHistoryWorkspaceTopology.AgentSession( + agent: .codex, + sessionId: "saved-codex", + title: "Saved task", + updatedAt: Self.now, + state: .saved + ), + ] + ), + ] + ), + ]) + let sections = VaultHistoryWorkspaceTimelineProjection().sections( + topology: topology, + events: [], + query: VaultHistoryQuery(), + now: Self.now + ) + let rows = VaultHistoryTimelineList.makeWorkspaceRows( + sections: sections, + resumeEntriesByEventId: [:], + availableClosedItemIds: [closedItemId], + actions: VaultHistoryRowActions( + onResume: nil, + onReopenClosedItem: { _ in true } + ) + ) + + guard case .workspace(let header) = rows.first else { + Issue.record("Expected a closed workspace header") + return + } + #expect(header.action == .reopenClosedItem(closedItemId)) + #expect(rows.map(\.id).contains(.topologyItem( + "terminal-row:workspace:\(workspaceId.uuidString):surface:\(terminalId.uuidString)" + ))) + } + @Test func appKitTimelineProjectionFlattensGroupsIntoStableVirtualRows() throws { let event = event( id: "codex-session", @@ -543,6 +834,92 @@ struct VaultHistoryAppKitViewportTests { #expect(realizedRows.count <= visibleRows.length + 2) } + @MainActor + @Test + func workspaceTreeRealizesOnlyViewportRowsAtScale() async { + let controller = VaultHistoryTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 300), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + container.frame = window.contentView?.bounds ?? .zero + + let sections = (0..<1_000).map { index in + let workspaceId = UUID() + let terminalId = UUID() + return VaultHistoryWorkspaceTimelineProjection.Section( + id: "workspace:\(workspaceId.uuidString)", + workspaceId: workspaceId, + title: "Workspace \(index)", + directory: "/tmp/history-\(index)", + windowLabel: nil, + timestamp: Date(timeIntervalSince1970: TimeInterval(10_000 - index)), + state: .active, + isSelected: index == 0, + closedItemId: nil, + terminals: [ + VaultHistoryWorkspaceTimelineProjection.Terminal( + id: "surface:\(terminalId.uuidString)", + runtimeId: terminalId, + title: "Terminal \(index)", + directory: "/tmp/history-\(index)", + agents: [ + VaultHistoryWorkspaceTimelineProjection.Agent( + id: "agent-session:codex:\(index)", + agent: .codex, + sessionId: "session-\(index)", + title: "Task \(index)", + updatedAt: Date(timeIntervalSince1970: TimeInterval(10_000 - index)), + state: .running, + event: nil + ), + ] + ), + ], + activityEvents: [] + ) + } + let rows = VaultHistoryTimelineList.makeWorkspaceRows( + sections: sections, + resumeEntriesByEventId: [:], + availableClosedItemIds: [], + actions: VaultHistoryRowActions( + onResume: nil, + onReopenClosedItem: nil, + onActivateWorkspace: { _ in true }, + onActivateTerminal: { _, _ in true } + ) + ) + controller.apply( + rows: rows, + actions: VaultHistoryRowActions( + onResume: nil, + onReopenClosedItem: nil, + onActivateWorkspace: { _ in true }, + onActivateTerminal: { _, _ in true } + ), + globalFontMagnificationPercent: 100 + ) + + window.contentView?.layoutSubtreeIfNeeded() + await flushStagedTableMutations() + window.contentView?.layoutSubtreeIfNeeded() + + let table = container.tableView + let visibleRows = table.rows(in: table.visibleRect) + let realizedRows = (0.. 0) + #expect(realizedRows.count <= visibleRows.length + 2) + } + @MainActor @Test func timelineCapsRenderedTranscriptTitlesWithoutChangingStoredEvent() { From 6404aaced8390f90d9479a1e4b2b5ef0115fbb6d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:16:50 -0700 Subject: [PATCH 07/10] Move History shell to AppKit --- Sources/RightSidebarPanelView.swift | 2 +- Sources/VaultHistoryControls.swift | 431 ++++++++++++----- Sources/VaultHistoryEventLog.swift | 13 +- Sources/VaultHistoryTimelineList.swift | 40 +- Sources/VaultHistoryTimelineModel.swift | 22 +- Sources/VaultHistoryView.swift | 545 +++++++++++++++------- Sources/VaultPaneView.swift | 103 ++-- cmuxTests/VaultHistoryGroupingTests.swift | 121 +++++ 8 files changed, 885 insertions(+), 392 deletions(-) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 05c127f88396..e0036f6e2bb0 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -109,7 +109,7 @@ struct RightSidebarPanelView: View { @ObservedObject var tabManager: TabManager @ObservedObject var fileExplorerStore: FileExplorerStore @ObservedObject var fileExplorerState: FileExplorerState - @ObservedObject var sessionIndexStore: SessionIndexStore + let sessionIndexStore: SessionIndexStore let titlebarHeight: CGFloat let windowAppearance: WindowAppearanceSnapshot let workspaceId: UUID? diff --git a/Sources/VaultHistoryControls.swift b/Sources/VaultHistoryControls.swift index a6bdc63701a9..f5b666a5af44 100644 --- a/Sources/VaultHistoryControls.swift +++ b/Sources/VaultHistoryControls.swift @@ -1,123 +1,320 @@ -import SwiftUI - -/// Search, time-range, sorting, and reload controls for History. -struct VaultHistoryControls: View { - @Bindable var model: VaultHistoryTimelineModel - let isReloadDisabled: Bool - let onReload: () -> Void - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 6) { - timeRangeMenu - Spacer(minLength: 4) - sortMenu - reloadButton - } - .rightSidebarChromeBar() - - HStack(spacing: 5) { - Image(systemName: "magnifyingglass") - .cmuxFont(size: 10) - .foregroundColor(.secondary) - TextField( - String(localized: "vaultHistory.search.placeholder", defaultValue: "Search history"), - text: $model.searchText - ) - .textFieldStyle(.plain) - .cmuxFont(size: 11) - .accessibilityIdentifier("VaultHistorySearchField") - if !model.searchText.isEmpty { - Button { - model.searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .cmuxFont(size: 10) - .foregroundColor(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel(String( - localized: "vaultHistory.search.clear.tooltip", - defaultValue: "Clear history search" - )) - .help(String( - localized: "vaultHistory.search.clear.tooltip", - defaultValue: "Clear history search" - )) - } - } - .padding(.horizontal, 10) - .frame(height: RightSidebarChromeMetrics.secondaryBarHeight) +import AppKit +import CmuxFoundation + +@MainActor +final class VaultHistoryControlsView: NSView, NSSearchFieldDelegate { + let modeControl = NSSegmentedControl() + let timeRangePopUpButton = NSPopUpButton(frame: .zero, pullsDown: false) + let sortOrderPopUpButton = NSPopUpButton(frame: .zero, pullsDown: false) + let reloadButton = NSButton() + let searchField = NSSearchField() + + var onModeChange: ((VaultHistoryMode) -> Void)? + var onTimeRangeChange: ((VaultHistoryQuery.TimeRange) -> Void)? + var onSortOrderChange: ((VaultHistoryQuery.SortOrder) -> Void)? + var onSearchChange: ((String) -> Void)? + var onReload: (() -> Void)? + + private let stackView = NSStackView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureView() + configureModeControl() + configureMenus() + configureReloadButton() + configureSearchField() + buildLayout() + applyFontMagnification() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update( + mode: VaultHistoryMode, + timeRange: VaultHistoryQuery.TimeRange, + sortOrder: VaultHistoryQuery.SortOrder, + searchText: String, + isReloadDisabled: Bool + ) { + modeControl.selectedSegment = VaultHistoryMode.allCases.firstIndex(of: mode) ?? 0 + selectItem(representing: timeRange.rawValue, in: timeRangePopUpButton) + selectItem(representing: sortOrder.rawValue, in: sortOrderPopUpButton) + updateMenuStates(selectedRawValue: timeRange.rawValue, in: timeRangePopUpButton) + updateMenuStates(selectedRawValue: sortOrder.rawValue, in: sortOrderPopUpButton) + if searchField.stringValue != searchText { + searchField.stringValue = searchText + } + reloadButton.isEnabled = !isReloadDisabled + } + + func applyFontMagnification() { + modeControl.font = NSFont.systemFont( + ofSize: GlobalFontMagnification.scaledSize( + RightSidebarChromeControlStyle.labelSize + ) + ) + let controlFont = NSFont.systemFont( + ofSize: GlobalFontMagnification.scaledSize( + RightSidebarChromeControlStyle.labelSize + ) + ) + timeRangePopUpButton.font = controlFont + sortOrderPopUpButton.font = controlFont + searchField.font = controlFont + invalidateIntrinsicContentSize() + } + + private func configureView() { + translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.orientation = .vertical + stackView.alignment = .width + stackView.spacing = 0 + stackView.distribution = .fill + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func configureModeControl() { + modeControl.translatesAutoresizingMaskIntoConstraints = false + modeControl.segmentCount = VaultHistoryMode.allCases.count + modeControl.trackingMode = .selectOne + modeControl.segmentStyle = .texturedRounded + modeControl.controlSize = .small + modeControl.target = self + modeControl.action = #selector(modeChanged(_:)) + modeControl.setAccessibilityIdentifier("VaultHistoryModeControl") + + for (index, mode) in VaultHistoryMode.allCases.enumerated() { + modeControl.setLabel(mode.label, forSegment: index) + modeControl.setImage( + NSImage( + systemSymbolName: mode.symbolName, + accessibilityDescription: mode.label + ), + forSegment: index + ) + modeControl.setWidth(0, forSegment: index) + modeControl.setToolTip(mode.label, forSegment: index) + } + } + + private func configureMenus() { + configure( + timeRangePopUpButton, + items: VaultHistoryQuery.TimeRange.allCases.map { + (rawValue: $0.rawValue, title: $0.label, symbolName: $0.symbolName) + }, + action: #selector(timeRangeChanged(_:)), + accessibilityIdentifier: "VaultHistoryTimeRangePicker", + toolTip: String( + localized: "vaultHistory.rangePicker.tooltip", + defaultValue: "Filter history by time" + ) + ) + configure( + sortOrderPopUpButton, + items: VaultHistoryQuery.SortOrder.allCases.map { + (rawValue: $0.rawValue, title: $0.label, symbolName: $0.symbolName) + }, + action: #selector(sortOrderChanged(_:)), + accessibilityIdentifier: "VaultHistorySortPicker", + toolTip: String( + localized: "vaultHistory.sortPicker.tooltip", + defaultValue: "Sort history" + ) + ) + } + + private func configure( + _ popUpButton: NSPopUpButton, + items: [(rawValue: String, title: String, symbolName: String)], + action: Selector, + accessibilityIdentifier: String, + toolTip: String + ) { + popUpButton.translatesAutoresizingMaskIntoConstraints = false + popUpButton.controlSize = .small + popUpButton.target = self + popUpButton.action = action + popUpButton.toolTip = toolTip + popUpButton.setAccessibilityIdentifier(accessibilityIdentifier) + popUpButton.removeAllItems() + for item in items { + popUpButton.addItem(withTitle: item.title) + guard let menuItem = popUpButton.lastItem else { continue } + menuItem.representedObject = item.rawValue + menuItem.image = NSImage( + systemSymbolName: item.symbolName, + accessibilityDescription: item.title + ) + } + popUpButton.setContentHuggingPriority(.required, for: .horizontal) + popUpButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func configureReloadButton() { + reloadButton.translatesAutoresizingMaskIntoConstraints = false + reloadButton.isBordered = false + reloadButton.bezelStyle = .accessoryBarAction + reloadButton.image = NSImage( + systemSymbolName: "arrow.clockwise", + accessibilityDescription: String( + localized: "vaultHistory.reload.tooltip", + defaultValue: "Reload History" + ) + ) + reloadButton.target = self + reloadButton.action = #selector(reload(_:)) + reloadButton.toolTip = String( + localized: "vaultHistory.reload.tooltip", + defaultValue: "Reload History" + ) + reloadButton.setAccessibilityIdentifier("VaultHistoryReloadButton") + NSLayoutConstraint.activate([ + reloadButton.widthAnchor.constraint(equalToConstant: RightSidebarChromeMetrics.controlHeight), + reloadButton.heightAnchor.constraint(equalToConstant: RightSidebarChromeMetrics.controlHeight), + ]) + } + + private func configureSearchField() { + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.delegate = self + searchField.sendsSearchStringImmediately = true + searchField.sendsWholeSearchString = false + searchField.isBordered = false + searchField.drawsBackground = false + searchField.focusRingType = .none + searchField.placeholderString = String( + localized: "vaultHistory.search.placeholder", + defaultValue: "Search history" + ) + searchField.toolTip = searchField.placeholderString + searchField.setAccessibilityIdentifier("VaultHistorySearchField") + } + + private func buildLayout() { + let modeRow = NSStackView(views: [modeControl]) + modeRow.orientation = .horizontal + modeRow.alignment = .centerY + modeRow.distribution = .fill + + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let filterRow = NSStackView(views: [ + timeRangePopUpButton, + spacer, + sortOrderPopUpButton, + reloadButton, + ]) + filterRow.orientation = .horizontal + filterRow.alignment = .centerY + filterRow.spacing = 4 + + let searchRow = NSStackView(views: [searchField]) + searchRow.orientation = .horizontal + searchRow.alignment = .centerY + searchRow.distribution = .fill + + stackView.addArrangedSubview(VaultHistoryChromeBarView(contentView: modeRow)) + stackView.addArrangedSubview(VaultHistoryChromeBarView(contentView: filterRow)) + stackView.addArrangedSubview(VaultHistoryChromeBarView(contentView: searchRow)) + } + + private func selectItem(representing rawValue: String, in popUpButton: NSPopUpButton) { + guard popUpButton.selectedItem?.representedObject as? String != rawValue, + let item = popUpButton.itemArray.first(where: { + $0.representedObject as? String == rawValue + }) else { + return } - .rightSidebarChromeBottomBorder() - } - - private var timeRangeMenu: some View { - Menu { - ForEach(VaultHistoryQuery.TimeRange.allCases) { range in - Button { - model.timeRange = range - } label: { - if model.timeRange == range { - Label(range.label, systemImage: "checkmark") - } else { - Text(range.label) - } - } - } - } label: { - Label(model.timeRange.label, systemImage: model.timeRange.symbolName) - .cmuxFont( - size: RightSidebarChromeControlStyle.labelSize, - weight: RightSidebarChromeControlStyle.labelWeight - ) + popUpButton.select(item) + } + + private func updateMenuStates(selectedRawValue: String, in popUpButton: NSPopUpButton) { + for item in popUpButton.itemArray { + item.state = (item.representedObject as? String == selectedRawValue) ? .on : .off } - .menuStyle(.borderlessButton) - .fixedSize() - .help(String(localized: "vaultHistory.rangePicker.tooltip", defaultValue: "Filter history by time")) - .accessibilityIdentifier("VaultHistoryTimeRangePicker") - .titlebarInteractiveControl() - } - - private var sortMenu: some View { - Menu { - ForEach(VaultHistoryQuery.SortOrder.allCases) { order in - Button { - model.sortOrder = order - } label: { - if model.sortOrder == order { - Label(order.label, systemImage: "checkmark") - } else { - Text(order.label) - } - } - } - } label: { - Label(model.sortOrder.label, systemImage: model.sortOrder.symbolName) - .labelStyle(.iconOnly) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) + } + + @objc private func modeChanged(_ sender: NSSegmentedControl) { + guard VaultHistoryMode.allCases.indices.contains(sender.selectedSegment) else { return } + onModeChange?(VaultHistoryMode.allCases[sender.selectedSegment]) + } + + @objc private func timeRangeChanged(_ sender: NSPopUpButton) { + guard let rawValue = sender.selectedItem?.representedObject as? String, + let range = VaultHistoryQuery.TimeRange(rawValue: rawValue) else { + return } - .menuStyle(.borderlessButton) - .fixedSize() - .help(String(localized: "vaultHistory.sortPicker.tooltip", defaultValue: "Sort history")) - .accessibilityIdentifier("VaultHistorySortPicker") - .titlebarInteractiveControl() - } - - private var reloadButton: some View { - Button { - onReload() - } label: { - Image(systemName: "arrow.clockwise") - .cmuxFont(size: 10, weight: .medium) + onTimeRangeChange?(range) + } + + @objc private func sortOrderChanged(_ sender: NSPopUpButton) { + guard let rawValue = sender.selectedItem?.representedObject as? String, + let order = VaultHistoryQuery.SortOrder(rawValue: rawValue) else { + return } - .buttonStyle(.borderless) - .accessibilityLabel(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) - .help(String(localized: "vaultHistory.reload.tooltip", defaultValue: "Reload History")) - .disabled(isReloadDisabled) - .titlebarInteractiveControl() + onSortOrderChange?(order) + } + + @objc private func reload(_ sender: NSButton) { + _ = sender + onReload?() + } + + func controlTextDidChange(_ notification: Notification) { + guard notification.object as? NSSearchField === searchField else { return } + onSearchChange?(searchField.stringValue) + } +} + +@MainActor +private final class VaultHistoryChromeBarView: NSView { + init(contentView: NSView) { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + NSLayoutConstraint.activate([ + heightAnchor.constraint(greaterThanOrEqualToConstant: RightSidebarChromeMetrics.secondaryBarHeight), + contentView.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: RightSidebarChromeMetrics.barHorizontalPadding + ), + contentView.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -RightSidebarChromeMetrics.barHorizontalPadding + ), + contentView.topAnchor.constraint( + equalTo: topAnchor, + constant: RightSidebarChromeMetrics.barVerticalPadding + ), + contentView.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -RightSidebarChromeMetrics.barVerticalPadding + ), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + NSColor.separatorColor.withAlphaComponent(0.45).setFill() + NSRect(x: 0, y: 0, width: bounds.width, height: 1).fill() } } diff --git a/Sources/VaultHistoryEventLog.swift b/Sources/VaultHistoryEventLog.swift index d494dc3ec0ed..e3325ed60db6 100644 --- a/Sources/VaultHistoryEventLog.swift +++ b/Sources/VaultHistoryEventLog.swift @@ -53,7 +53,12 @@ final class VaultHistoryEventLog { pendingRecordTask = Task(priority: .utility) { [weak self] in await previous?.value await store.append(event) - self?.revision &+= 1 + guard let self else { return } + self.revision &+= 1 + NotificationCenter.default.post( + name: .vaultHistoryEventLogDidChange, + object: self + ) } } @@ -86,3 +91,9 @@ final class VaultHistoryEventLog { .appendingPathComponent("vault-history-\(safeBundleId).jsonl", isDirectory: false) } } + +extension Notification.Name { + static let vaultHistoryEventLogDidChange = Notification.Name( + "cmux.vaultHistoryEventLogDidChange" + ) +} diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift index 52053959294d..ebd150c14cda 100644 --- a/Sources/VaultHistoryTimelineList.swift +++ b/Sources/VaultHistoryTimelineList.swift @@ -1,45 +1,9 @@ import AppKit import CmuxAppKitSupportUI import CmuxFoundation -import SwiftUI - -/// SwiftUI bridge for the AppKit-virtualized History timeline. -struct VaultHistoryTimelineList: NSViewRepresentable { - let groups: [VaultHistoryGroup] - let workspaceSections: [VaultHistoryWorkspaceTimelineProjection.Section] - let resumeEntriesByEventId: [String: SessionEntry] - let availableClosedItemIds: Set - let actions: VaultHistoryRowActions - @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontMagnificationPercent - - func makeCoordinator() -> VaultHistoryTableController { - VaultHistoryTableController() - } - - func makeNSView(context: Context) -> VaultHistoryTableContainerView { - context.coordinator.makeContainerView() - } - - func updateNSView(_ nsView: VaultHistoryTableContainerView, context: Context) { - context.coordinator.apply( - rows: workspaceSections.isEmpty - ? Self.makeRows( - groups: groups, - resumeEntriesByEventId: resumeEntriesByEventId, - availableClosedItemIds: availableClosedItemIds, - actions: actions - ) - : Self.makeWorkspaceRows( - sections: workspaceSections, - resumeEntriesByEventId: resumeEntriesByEventId, - availableClosedItemIds: availableClosedItemIds, - actions: actions - ), - actions: actions, - globalFontMagnificationPercent: globalFontMagnificationPercent - ) - } +/// Pure row projection used directly by the AppKit History controller. +enum VaultHistoryTimelineList { static func makeRows( groups: [VaultHistoryGroup], resumeEntriesByEventId: [String: SessionEntry], diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 2adc15e71764..705dc890f8b7 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -1,5 +1,4 @@ import Foundation -import Observation /// The three user-facing ways to browse History. Each mode owns its /// primitive and filters out events that cannot belong to that primitive, @@ -59,8 +58,14 @@ enum VaultHistoryMode: String, CaseIterable, Identifiable, Sendable { /// with session events derived from the agent-session index, and exposes grouped /// sections computed by the pure ``VaultHistoryGrouper``. @MainActor -@Observable final class VaultHistoryTimelineModel { + enum Update { + case loading + case content + } + + var onUpdate: ((Update) -> Void)? + var mode: VaultHistoryMode { didSet { guard mode != oldValue else { return } @@ -116,6 +121,7 @@ final class VaultHistoryTimelineModel { private var mergedEvents: [VaultHistoryEvent] = [] private var topology = VaultHistoryWorkspaceTopology(workspaces: []) private var refreshTask: Task? + private var isBatchingQueryChanges = false var hasActiveFilters: Bool { timeRange != .allTime @@ -151,6 +157,7 @@ final class VaultHistoryTimelineModel { ) { refreshTask?.cancel() isLoading = true + onUpdate?(.loading) let log = log let agentBindingStore = agentBindingStore refreshTask = Task { [weak self] in @@ -187,11 +194,21 @@ final class VaultHistoryTimelineModel { } func clearFilters() { + guard hasActiveFilters else { return } + isBatchingQueryChanges = true searchText = "" timeRange = .allTime + isBatchingQueryChanges = false + regroup() + } + + func cancelRefresh() { + refreshTask?.cancel() + refreshTask = nil } private func regroup() { + guard !isBatchingQueryChanges else { return } let currentTime = now() let query = VaultHistoryQuery( timeRange: timeRange, @@ -215,5 +232,6 @@ final class VaultHistoryTimelineModel { now: currentTime ) } + onUpdate?(.content) } } diff --git a/Sources/VaultHistoryView.swift b/Sources/VaultHistoryView.swift index b71ffb6511d6..4c3452b32d83 100644 --- a/Sources/VaultHistoryView.swift +++ b/Sources/VaultHistoryView.swift @@ -1,144 +1,316 @@ -import SwiftUI - -/// The History timeline, combining workspace/window lifecycle events with agent sessions. -struct VaultHistoryView: View { - let mode: VaultHistoryMode - @ObservedObject private var tabManager: TabManager - @ObservedObject private var sessionStore: SessionIndexStore - @ObservedObject private var closedItemStore: ClosedItemHistoryStore +import AppKit +import Combine +import CmuxFoundation + +/// Owns the complete History surface in AppKit. SwiftUI mounts this controller +/// as one opaque view and does not participate in filtering, refresh, layout, +/// or table updates. +@MainActor +final class VaultHistoryViewController: NSViewController { + private let tabManager: TabManager + private let sessionStore: SessionIndexStore + private let closedItemStore: ClosedItemHistoryStore private let log: VaultHistoryEventLog - private let onResume: ((SessionEntry) -> Void)? - private let onReopenClosedItem: ((UUID) -> Bool)? - @State private var model: VaultHistoryTimelineModel + private let defaults: UserDefaults + private let model: VaultHistoryTimelineModel + + private var onResume: ((SessionEntry) -> Void)? + private var onReopenClosedItem: ((UUID) -> Bool)? + + let controlsView = VaultHistoryControlsView() + let tableController = VaultHistoryTableController() + private lazy var tableContainer = tableController.makeContainerView() + private let loadingView = VaultHistoryLoadingView() + private let emptyView = VaultHistoryEmptyView() + private let contentContainer = NSView() + + private var cancellables = Set() + private var refreshDebounceTask: Task? + private var isStarted = false + + private lazy var rowActions = VaultHistoryRowActions( + onResume: { [weak self] entry in + self?.onResume?(entry) + }, + onReopenClosedItem: { [weak self] id in + self?.onReopenClosedItem?(id) ?? false + }, + onActivateWorkspace: { [weak self] workspaceId in + self?.activateWorkspace(workspaceId) ?? false + }, + onActivateTerminal: { [weak self] workspaceId, terminalId in + self?.activateTerminal(workspaceId, terminalId) ?? false + } + ) init( - mode: VaultHistoryMode, tabManager: TabManager, sessionStore: SessionIndexStore, closedItemStore: ClosedItemHistoryStore, log: VaultHistoryEventLog, onResume: ((SessionEntry) -> Void)?, - onReopenClosedItem: ((UUID) -> Bool)? + onReopenClosedItem: ((UUID) -> Bool)?, + defaults: UserDefaults = .standard ) { - self.mode = mode self.tabManager = tabManager self.sessionStore = sessionStore self.closedItemStore = closedItemStore self.log = log self.onResume = onResume self.onReopenClosedItem = onReopenClosedItem - _model = State(initialValue: VaultHistoryTimelineModel(log: log, mode: mode)) - } - - var body: some View { - VaultHistoryContentView( - tabManager: tabManager, - sessionStore: sessionStore, - closedItemStore: closedItemStore, + self.defaults = defaults + let selectedMode = defaults.string(forKey: "vaultPane.tab") + .flatMap(VaultHistoryMode.init(rawValue:)) ?? .timeline + self.model = VaultHistoryTimelineModel( log: log, - model: model, - onResume: onResume, - onReopenClosedItem: onReopenClosedItem + mode: selectedMode, + defaults: defaults ) - .onChange(of: mode) { _, mode in - model.mode = mode - } + super.init(nibName: nil, bundle: nil) } -} -private struct VaultHistoryContentView: View { - @ObservedObject var tabManager: TabManager - @ObservedObject var sessionStore: SessionIndexStore - @ObservedObject var closedItemStore: ClosedItemHistoryStore - let log: VaultHistoryEventLog - let model: VaultHistoryTimelineModel - let onResume: ((SessionEntry) -> Void)? - let onReopenClosedItem: ((UUID) -> Bool)? - - var body: some View { - VStack(spacing: 0) { - VaultHistoryControls( - model: model, - isReloadDisabled: model.isLoading || sessionStore.isLoading, - onReload: reload - ) - if !model.didLoad { - VaultHistoryLoadingView() - } else if model.groups.isEmpty && model.workspaceSections.isEmpty { - VaultHistoryEmptyView( - hasActiveFilters: model.hasActiveFilters, - onClearFilters: model.clearFilters - ) - } else { - VaultHistoryTimelineList( - groups: model.groups, - workspaceSections: model.workspaceSections, - resumeEntriesByEventId: model.resumeEntriesByEventId, - availableClosedItemIds: closedItemStore.recordIdsSnapshot, - actions: VaultHistoryRowActions( - onResume: onResume, - onReopenClosedItem: onReopenClosedItem, - onActivateWorkspace: activateWorkspace, - onActivateTerminal: activateTerminal - ) - ) - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let rootView = NSView() + contentContainer.translatesAutoresizingMaskIntoConstraints = false + tableContainer.translatesAutoresizingMaskIntoConstraints = false + loadingView.translatesAutoresizingMaskIntoConstraints = false + emptyView.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(controlsView) + rootView.addSubview(contentContainer) + for contentView in [tableContainer, loadingView, emptyView] { + contentContainer.addSubview(contentView) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor), + contentView.topAnchor.constraint(equalTo: contentContainer.topAnchor), + contentView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor), + ]) } - .onAppear { - if sessionStore.entries.isEmpty && !sessionStore.isLoading { - sessionStore.reload() + NSLayoutConstraint.activate([ + controlsView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + controlsView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + controlsView.topAnchor.constraint(equalTo: rootView.topAnchor), + contentContainer.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + contentContainer.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + contentContainer.topAnchor.constraint(equalTo: controlsView.bottomAnchor), + contentContainer.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + ]) + view = rootView + wireControls() + model.onUpdate = { [weak self] update in + guard let self else { return } + switch update { + case .loading: + self.renderControls() + if !self.model.didLoad { + self.show(.loading) + } + case .content: + self.renderContent() } - refresh() } - .onChange(of: sessionStore.entries) { _, entries in - refresh(sessionEntries: entries) + emptyView.onClearFilters = { [weak self] in + self?.model.clearFilters() + } + renderContent() + } + + func start() { + guard !isStarted else { return } + isStarted = true + loadViewIfNeeded() + installObservers() + if sessionStore.entries.isEmpty && !sessionStore.isLoading { + sessionStore.reload() + } + refresh() + } + + func stop() { + guard isStarted else { return } + isStarted = false + refreshDebounceTask?.cancel() + refreshDebounceTask = nil + model.cancelRefresh() + cancellables.removeAll() + } + + func updateCallbacks( + onResume: ((SessionEntry) -> Void)?, + onReopenClosedItem: ((UUID) -> Bool)? + ) { + self.onResume = onResume + self.onReopenClosedItem = onReopenClosedItem + } + + private func wireControls() { + controlsView.onModeChange = { [weak self] mode in + guard let self else { return } + self.defaults.set(mode.rawValue, forKey: "vaultPane.tab") + self.model.mode = mode + } + controlsView.onTimeRangeChange = { [weak self] range in + self?.model.timeRange = range } - .onChange(of: log.revision) { _, _ in - refresh() + controlsView.onSortOrderChange = { [weak self] order in + self?.model.sortOrder = order } - .onChange(of: closedItemStore.revision) { _, _ in - refresh() + controlsView.onSearchChange = { [weak self] searchText in + self?.model.searchText = searchText } - .onChange(of: tabManager.tabs.map(\.id)) { _, _ in - refresh() + controlsView.onReload = { [weak self] in + guard let self else { return } + self.sessionStore.reload() + self.refresh() } - .task { - for await _ in NotificationCenter.default.notifications( - named: .vaultHistoryLiveTopologyDidChange - ) { - guard !Task.isCancelled else { return } - refresh() + } + + private func installObservers() { + sessionStore.$entries + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleRefresh() } } - } - .task { - for await _ in NotificationCenter.default.notifications( - named: .sharedLiveAgentIndexDidChange - ) { - guard !Task.isCancelled else { return } - refresh() + .store(in: &cancellables) + + sessionStore.$isLoading + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.renderControls() } } - } - .task { - for await _ in NotificationCenter.default.notifications( - named: .workspaceTitleDidChange - ) { - guard !Task.isCancelled else { return } - refresh() + .store(in: &cancellables) + + closedItemStore.$revision + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleRefresh() } } + .store(in: &cancellables) + + tabManager.tabsPublisher + .map { $0.map(\.id) } + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleRefresh() } + } + .store(in: &cancellables) + + let topologyNotifications: [Notification.Name] = [ + .vaultHistoryEventLogDidChange, + .vaultHistoryLiveTopologyDidChange, + .sharedLiveAgentIndexDidChange, + .workspaceTitleDidChange, + .workspaceCurrentDirectoryDidChange, + ] + for name in topologyNotifications { + NotificationCenter.default.publisher(for: name) + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleRefresh() } + } + .store(in: &cancellables) } - .task { - for await _ in NotificationCenter.default.notifications( - named: .workspaceCurrentDirectoryDidChange - ) { - guard !Task.isCancelled else { return } - refresh() + + NotificationCenter.default.publisher(for: GlobalFontMagnification.didChangeNotification) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.controlsView.applyFontMagnification() + self.loadingView.applyFontMagnification() + self.emptyView.applyFontMagnification() + self.renderContent() + } } + .store(in: &cancellables) + } + + private func scheduleRefresh() { + guard isStarted else { return } + refreshDebounceTask?.cancel() + refreshDebounceTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(75)) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.refresh() } } - private func reload() { - sessionStore.reload() - refresh() + private func refresh() { + let topology = VaultHistoryWorkspaceTopology.Snapshotter().capture( + fallbackTabManager: tabManager, + closedRecords: closedItemStore.recordsSnapshot + ) + model.refresh( + sessionEntries: sessionStore.entries, + topology: topology + ) + } + + private func renderControls() { + guard isViewLoaded else { return } + controlsView.update( + mode: model.mode, + timeRange: model.timeRange, + sortOrder: model.sortOrder, + searchText: model.searchText, + isReloadDisabled: model.isLoading || sessionStore.isLoading + ) + } + + private func renderContent() { + guard isViewLoaded else { return } + renderControls() + guard model.didLoad else { + show(.loading) + return + } + + let rows = model.workspaceSections.isEmpty + ? VaultHistoryTimelineList.makeRows( + groups: model.groups, + resumeEntriesByEventId: model.resumeEntriesByEventId, + availableClosedItemIds: closedItemStore.recordIdsSnapshot, + actions: rowActions + ) + : VaultHistoryTimelineList.makeWorkspaceRows( + sections: model.workspaceSections, + resumeEntriesByEventId: model.resumeEntriesByEventId, + availableClosedItemIds: closedItemStore.recordIdsSnapshot, + actions: rowActions + ) + + guard !rows.isEmpty else { + emptyView.update(hasActiveFilters: model.hasActiveFilters) + show(.empty) + return + } + show(.table) + tableController.apply( + rows: rows, + actions: rowActions, + globalFontMagnificationPercent: GlobalFontMagnification.storedPercent + ) + } + + private enum ContentState { + case loading + case empty + case table + } + + private func show(_ state: ContentState) { + loadingView.isHidden = state != .loading + emptyView.isHidden = state != .empty + tableContainer.isHidden = state != .table } @discardableResult @@ -179,76 +351,123 @@ private struct VaultHistoryContentView: View { bringToFront: true ) } +} - private func refresh(sessionEntries: [SessionEntry]? = nil) { - let topology = VaultHistoryWorkspaceTopology.Snapshotter().capture( - fallbackTabManager: tabManager, - closedRecords: closedItemStore.recordsSnapshot - ) - model.refresh( - sessionEntries: sessionEntries ?? sessionStore.entries, - topology: topology - ) +@MainActor +private final class VaultHistoryLoadingView: NSView { + private let progressIndicator = NSProgressIndicator() + private let label = NSTextField(labelWithString: String( + localized: "vaultHistory.loading", + defaultValue: "Loading history…" + )) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + progressIndicator.style = .spinning + progressIndicator.controlSize = .small + progressIndicator.startAnimation(nil) + label.textColor = .secondaryLabelColor + applyFontMagnification() + + let stack = NSStackView(views: [progressIndicator, label]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 6 + addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) } -} -private struct VaultHistoryLoadingView: View { - var body: 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) + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyFontMagnification() { + label.font = GlobalFontMagnification.systemFont(ofSize: 11) } } -private struct VaultHistoryEmptyView: View { - let hasActiveFilters: Bool - let onClearFilters: () -> Void - - var body: some View { - VStack(spacing: 6) { - Text(title) - .cmuxFont(size: 12) - .foregroundColor(.secondary) - Text(subtitle) - .cmuxFont(size: 11) - .foregroundColor(.secondary.opacity(0.7)) - .multilineTextAlignment(.center) - .padding(.horizontal, 16) - if hasActiveFilters { - Button { - onClearFilters() - } label: { - Text(String(localized: "vaultHistory.filters.clear", defaultValue: "Clear filters")) - } - .buttonStyle(.link) - .controlSize(.small) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) +@MainActor +private final class VaultHistoryEmptyView: NSView { + var onClearFilters: (() -> Void)? + + private let titleLabel = NSTextField(labelWithString: "") + private let subtitleLabel = NSTextField(wrappingLabelWithString: "") + private let clearButton = NSButton() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + titleLabel.textColor = .secondaryLabelColor + titleLabel.alignment = .center + subtitleLabel.textColor = .tertiaryLabelColor + subtitleLabel.alignment = .center + subtitleLabel.maximumNumberOfLines = 3 + applyFontMagnification() + + clearButton.title = String( + localized: "vaultHistory.filters.clear", + defaultValue: "Clear filters" + ) + clearButton.isBordered = false + clearButton.contentTintColor = .linkColor + clearButton.target = self + clearButton.action = #selector(clearFilters(_:)) + clearButton.setAccessibilityIdentifier("VaultHistoryClearFiltersButton") + + let stack = NSStackView(views: [titleLabel, subtitleLabel, clearButton]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.orientation = .vertical + stack.alignment = .centerX + stack.spacing = 6 + addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + subtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 280), + ]) } - private var title: String { - if hasActiveFilters { - return String(localized: "vaultHistory.empty.filtered.title", defaultValue: "No matching history") - } - return String(localized: "vaultHistory.empty.title", defaultValue: "No history yet") + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyFontMagnification() { + titleLabel.font = GlobalFontMagnification.systemFont(ofSize: 12) + subtitleLabel.font = GlobalFontMagnification.systemFont(ofSize: 11) } - private var subtitle: String { + func update(hasActiveFilters: Bool) { if hasActiveFilters { - return String( + titleLabel.stringValue = String( + localized: "vaultHistory.empty.filtered.title", + defaultValue: "No matching history" + ) + subtitleLabel.stringValue = String( localized: "vaultHistory.empty.filtered.subtitle", defaultValue: "Try another search or time range." ) + } else { + titleLabel.stringValue = String( + localized: "vaultHistory.empty.title", + defaultValue: "No history yet" + ) + subtitleLabel.stringValue = String( + localized: "vaultHistory.empty.subtitle", + defaultValue: "Workspace, window, and agent session activity will appear here." + ) } - return String( - localized: "vaultHistory.empty.subtitle", - defaultValue: "Workspace, window, and agent session activity will appear here." - ) + clearButton.isHidden = !hasActiveFilters + } + + @objc private func clearFilters(_ sender: NSButton) { + _ = sender + onClearFilters?() } } diff --git a/Sources/VaultPaneView.swift b/Sources/VaultPaneView.swift index 0f76be6b979b..03bac66dcf2e 100644 --- a/Sources/VaultPaneView.swift +++ b/Sources/VaultPaneView.swift @@ -1,81 +1,44 @@ -import CmuxFoundation import SwiftUI -/// Hosts History behind Timeline/By Folder/By Agent views. 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 tabManager: TabManager - @ObservedObject var store: SessionIndexStore - @ObservedObject var closedItemStore: ClosedItemHistoryStore +/// Thin mount adapter. The complete History surface, including its mode, +/// filter, search, loading, empty, and virtualized list UI, lives in AppKit. +struct VaultPaneView: NSViewControllerRepresentable { + let tabManager: TabManager + let store: SessionIndexStore + let closedItemStore: ClosedItemHistoryStore let onResume: ((SessionEntry) -> Void)? let onReopenClosedItem: ((UUID) -> Bool)? - @AppStorage("vaultPane.tab") private var selectedModeRawValue = VaultHistoryMode.timeline.rawValue - private var selectedMode: VaultHistoryMode { - VaultHistoryMode(rawValue: selectedModeRawValue) ?? .timeline + func makeNSViewController(context: Context) -> VaultHistoryViewController { + _ = context + let controller = VaultHistoryViewController( + tabManager: tabManager, + sessionStore: store, + closedItemStore: closedItemStore, + log: .shared, + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) + controller.start() + return controller } - var body: some View { - VStack(spacing: 0) { - tabBar - VaultHistoryView( - mode: selectedMode, - tabManager: tabManager, - sessionStore: store, - closedItemStore: closedItemStore, - log: .shared, - onResume: onResume, - onReopenClosedItem: onReopenClosedItem - ) - } - .onAppear { - if VaultHistoryMode(rawValue: selectedModeRawValue) == nil { - selectedModeRawValue = VaultHistoryMode.timeline.rawValue - } - } + func updateNSViewController( + _ controller: VaultHistoryViewController, + context: Context + ) { + _ = context + controller.updateCallbacks( + onResume: onResume, + onReopenClosedItem: onReopenClosedItem + ) } - private var tabBar: some View { - HStack(spacing: 4) { - ForEach(VaultHistoryMode.allCases) { mode in - VaultPaneTabButton(mode: mode, isSelected: selectedMode == mode) { - selectedModeRawValue = mode.rawValue - } - } - Spacer(minLength: 0) - } - .rightSidebarChromeBar() - .rightSidebarChromeBottomBorder() - } -} - -private struct VaultPaneTabButton: View { - let mode: VaultHistoryMode - let isSelected: Bool - let action: () -> Void - @State private var isHovered = false - - var body: some View { - Button(action: action) { - HStack(spacing: 3) { - Image(systemName: mode.symbolName) - .cmuxFont( - size: RightSidebarChromeControlStyle.secondaryIconSize, - weight: RightSidebarChromeControlStyle.iconWeight - ) - Text(mode.label) - .cmuxFont( - size: RightSidebarChromeControlStyle.labelSize, - weight: RightSidebarChromeControlStyle.labelWeight - ) - } - .rightSidebarChromePill(isSelected: isSelected, isHovered: isHovered) - } - .buttonStyle(.plain) - .titlebarInteractiveControl() - .onHover { isHovered = $0 } - .help(mode.label) - .accessibilityIdentifier("VaultPaneTabButton.\(mode.rawValue)") + static func dismantleNSViewController( + _ controller: VaultHistoryViewController, + coordinator: Void + ) { + _ = coordinator + controller.stop() } } diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index 6b785066c1a2..452c939f53b4 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -976,6 +976,127 @@ struct VaultHistoryAppKitViewportTests { } } + @MainActor + @Test + func historyControlsAreNativeAppKitAndRouteChangesWithoutRebuilding() { + let controls = VaultHistoryControlsView() + var selectedMode: VaultHistoryMode? + var selectedRange: VaultHistoryQuery.TimeRange? + var selectedOrder: VaultHistoryQuery.SortOrder? + var searchText: String? + var reloadCount = 0 + controls.onModeChange = { selectedMode = $0 } + controls.onTimeRangeChange = { selectedRange = $0 } + controls.onSortOrderChange = { selectedOrder = $0 } + controls.onSearchChange = { searchText = $0 } + controls.onReload = { reloadCount += 1 } + + controls.update( + mode: .folder, + timeRange: .last7Days, + sortOrder: .titleAscending, + searchText: "codex restore", + isReloadDisabled: false + ) + + #expect(controls.modeControl.selectedSegment == 1) + #expect(controls.timeRangePopUpButton.selectedItem?.representedObject as? String + == VaultHistoryQuery.TimeRange.last7Days.rawValue) + #expect(controls.sortOrderPopUpButton.selectedItem?.representedObject as? String + == VaultHistoryQuery.SortOrder.titleAscending.rawValue) + #expect(controls.searchField.stringValue == "codex restore") + #expect(controls.reloadButton.isEnabled) + #expect(controls.modeControl.image(forSegment: 0) != nil) + + controls.modeControl.selectedSegment = 2 + if let action = controls.modeControl.action { + _ = controls.modeControl.sendAction(action, to: controls.modeControl.target) + } + controls.timeRangePopUpButton.selectItem(at: 1) + if let action = controls.timeRangePopUpButton.action { + _ = controls.timeRangePopUpButton.sendAction( + action, + to: controls.timeRangePopUpButton.target + ) + } + controls.sortOrderPopUpButton.selectItem(at: 1) + if let action = controls.sortOrderPopUpButton.action { + _ = controls.sortOrderPopUpButton.sendAction( + action, + to: controls.sortOrderPopUpButton.target + ) + } + controls.searchField.stringValue = "claude" + controls.controlTextDidChange(Notification( + name: Notification.Name("VaultHistoryControlsViewTest"), + object: controls.searchField + )) + if let action = controls.reloadButton.action { + _ = controls.reloadButton.sendAction(action, to: controls.reloadButton.target) + } + + #expect(selectedMode == .agent) + #expect(selectedRange == .last24Hours) + #expect(selectedOrder == .oldestFirst) + #expect(searchText == "claude") + #expect(reloadCount == 1) + } + + @MainActor + @Test + func equivalentAppKitRowsKeepTheirRealizedCell() async throws { + let controller = VaultHistoryTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + container.frame = window.contentView?.bounds ?? .zero + let row = VaultHistoryTableRow.event( + event: VaultHistoryEvent( + id: "stable-row", + timestamp: Date(timeIntervalSince1970: 1_000), + kind: .sessionActivity, + title: "Stable session", + subject: VaultHistorySubject( + sessionId: "stable-row", + agent: "codex", + agentDisplayName: "Codex" + ) + ), + action: nil, + agent: .codex + ) + let actions = VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) + + controller.apply( + rows: [row], + actions: actions, + globalFontMagnificationPercent: 100 + ) + window.contentView?.layoutSubtreeIfNeeded() + await flushStagedTableMutations() + window.contentView?.layoutSubtreeIfNeeded() + let firstCell = try #require( + container.tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) + ) + + controller.apply( + rows: [row], + actions: actions, + globalFontMagnificationPercent: 100 + ) + await flushStagedTableMutations() + let secondCell = try #require( + container.tableView.view(atColumn: 0, row: 0, makeIfNecessary: false) + ) + + #expect(firstCell === secondCell) + } + @Test func workspaceLifecycleUsesMinimalAddRemoveSymbols() { #expect(VaultHistoryEventKind.workspaceCreated.symbolName == "plus") From 691a9f55cc01228ecb69bb59a5c7f36ef9ff0af3 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:09:39 -0700 Subject: [PATCH 08/10] Add History update lag regressions --- cmuxTests/VaultHistoryGroupingTests.swift | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index 452c939f53b4..e8d5241aa357 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -937,6 +937,16 @@ struct VaultHistoryAppKitViewportTests { #expect(event.title == original) } + @Test + func timelineBoundsTextBeforeNormalizingHugeTranscriptTitles() { + let rendered = VaultHistoryDisplayText.singleLine( + String(repeating: "session output ", count: 100_000) + ) + + #expect(rendered.count == 241) + #expect(rendered.hasSuffix("…")) + } + @MainActor @Test func timelineCellKeepsMultilineTranscriptInsideOneFixedHeightRow() throws { @@ -1097,6 +1107,77 @@ struct VaultHistoryAppKitViewportTests { #expect(firstCell === secondCell) } + @MainActor + @Test + func structuralAppKitUpdateKeepsSharedRealizedCell() async throws { + let controller = VaultHistoryTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + container.frame = window.contentView?.bounds ?? .zero + let stableRow = VaultHistoryTableRow.event( + event: VaultHistoryEvent( + id: "stable-row", + timestamp: Date(timeIntervalSince1970: 1_000), + kind: .sessionActivity, + title: "Stable session", + subject: VaultHistorySubject( + sessionId: "stable-row", + agent: "codex", + agentDisplayName: "Codex" + ) + ), + action: nil, + agent: .codex + ) + let insertedRow = VaultHistoryTableRow.event( + event: VaultHistoryEvent( + id: "inserted-row", + timestamp: Date(timeIntervalSince1970: 2_000), + kind: .sessionActivity, + title: "Inserted session", + subject: VaultHistorySubject( + sessionId: "inserted-row", + agent: "claude", + agentDisplayName: "Claude" + ) + ), + action: nil, + agent: .claude + ) + let actions = VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) + + controller.apply( + rows: [stableRow], + actions: actions, + globalFontMagnificationPercent: 100 + ) + window.contentView?.layoutSubtreeIfNeeded() + await flushStagedTableMutations() + window.contentView?.layoutSubtreeIfNeeded() + let stableCell = try #require( + container.tableView.view(atColumn: 0, row: 0, makeIfNecessary: true) + ) + + controller.apply( + rows: [insertedRow, stableRow], + actions: actions, + globalFontMagnificationPercent: 100 + ) + await flushStagedTableMutations() + window.contentView?.layoutSubtreeIfNeeded() + let movedStableCell = try #require( + container.tableView.view(atColumn: 0, row: 1, makeIfNecessary: true) + ) + + #expect(stableCell === movedStableCell) + } + @Test func workspaceLifecycleUsesMinimalAddRemoveSymbols() { #expect(VaultHistoryEventKind.workspaceCreated.symbolName == "plus") From a87464797929699370b64646257aee2258adc27e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:48:45 -0700 Subject: [PATCH 09/10] Keep History updates incremental --- Sources/VaultHistoryEventRow.swift | 232 ++++++++-------------- Sources/VaultHistoryTimelineList.swift | 209 +++++++++++++++---- cmuxTests/VaultHistoryGroupingTests.swift | 45 ++++- 3 files changed, 304 insertions(+), 182 deletions(-) diff --git a/Sources/VaultHistoryEventRow.swift b/Sources/VaultHistoryEventRow.swift index 9ad73aa60fce..a7bdc275bd2d 100644 --- a/Sources/VaultHistoryEventRow.swift +++ b/Sources/VaultHistoryEventRow.swift @@ -75,7 +75,6 @@ struct VaultHistoryRowActions { final class VaultHistoryTableEventCellView: NSTableCellView { static let reuseIdentifier = NSUserInterfaceItemIdentifier("VaultHistoryTableEventCellView") - private let hoverBackground = VaultHistoryHitTransparentView() private let iconView = CmuxResolvedIconImageView() private let titleLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") @@ -84,13 +83,11 @@ final class VaultHistoryTableEventCellView: NSTableCellView { private lazy var textStack = NSStackView(views: [titleLabel, subtitleLabel]) private var iconLeadingConstraint: NSLayoutConstraint! private var actionButtonWidthConstraint: NSLayoutConstraint! - private var representedEvent: VaultHistoryEvent? + private var representedEventItem: VaultHistoryTableRow.EventItem? private var representedTopologyItem: VaultHistoryTableRow.TopologyItem? private var representedAction: VaultHistoryRowAction? - private var representedAgent: SessionAgent? private var representedFontPercent = GlobalFontMagnification.defaultPercent private var onPerformAction: ((VaultHistoryRowAction) -> Void)? - private var isPointerHovering = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -98,12 +95,6 @@ final class VaultHistoryTableEventCellView: NSTableCellView { wantsLayer = true layer?.masksToBounds = true - hoverBackground.translatesAutoresizingMaskIntoConstraints = false - hoverBackground.wantsLayer = true - hoverBackground.layer?.cornerRadius = 4 - hoverBackground.layer?.cornerCurve = .continuous - addSubview(hoverBackground) - iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(iconView) @@ -143,11 +134,6 @@ final class VaultHistoryTableEventCellView: NSTableCellView { addSubview(timeLabel) iconLeadingConstraint = iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10) NSLayoutConstraint.activate([ - hoverBackground.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), - hoverBackground.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), - hoverBackground.topAnchor.constraint(equalTo: topAnchor, constant: 1), - hoverBackground.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), - iconLeadingConstraint, iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 14), @@ -168,14 +154,6 @@ final class VaultHistoryTableEventCellView: NSTableCellView { timeLabel.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 4), textStack.trailingAnchor.constraint(lessThanOrEqualTo: timeLabel.leadingAnchor, constant: -4), ]) - - addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.activeInActiveApp, .inVisibleRect, .mouseEnteredAndExited], - owner: self, - userInfo: nil - )) - updateHoverPresentation() } @available(*, unavailable) @@ -185,13 +163,11 @@ final class VaultHistoryTableEventCellView: NSTableCellView { override func prepareForReuse() { super.prepareForReuse() - representedEvent = nil + representedEventItem = nil representedTopologyItem = nil representedAction = nil - representedAgent = nil onPerformAction = nil iconView.apply(nil) - menu = nil } func configure( @@ -200,26 +176,39 @@ final class VaultHistoryTableEventCellView: NSTableCellView { agent: SessionAgent?, globalFontMagnificationPercent: Int, onPerformAction: @escaping (VaultHistoryRowAction) -> Void + ) { + configure( + eventItem: VaultHistoryTableRow.EventItem( + event: event, + action: action, + agent: agent + ), + globalFontMagnificationPercent: globalFontMagnificationPercent, + onPerformAction: onPerformAction + ) + } + + func configure( + eventItem: VaultHistoryTableRow.EventItem, + globalFontMagnificationPercent: Int, + onPerformAction: @escaping (VaultHistoryRowAction) -> Void ) { self.onPerformAction = onPerformAction representedTopologyItem = nil - guard representedEvent != event - || representedAction != action - || representedAgent != agent + guard representedEventItem != eventItem || representedFontPercent != globalFontMagnificationPercent else { - updateRelativeTimestamp(for: event) + updateRelativeTimestamp(eventItem.timestamp) return } - representedEvent = event - representedAction = action - representedAgent = agent + representedEventItem = eventItem + representedAction = eventItem.action representedFontPercent = globalFontMagnificationPercent iconLeadingConstraint.constant = 10 - titleLabel.stringValue = Self.displayTitle(for: event) - subtitleLabel.stringValue = Self.displaySubtitle(for: event) - titleLabel.toolTip = VaultHistoryDisplayText.singleLine(event.title) - subtitleLabel.toolTip = subtitleLabel.stringValue + titleLabel.stringValue = eventItem.title + subtitleLabel.stringValue = eventItem.subtitle + titleLabel.toolTip = eventItem.titleTooltip.isEmpty ? nil : eventItem.titleTooltip + subtitleLabel.toolTip = eventItem.subtitle.isEmpty ? nil : eventItem.subtitle titleLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(11.5, percent: globalFontMagnificationPercent) ) @@ -229,12 +218,12 @@ final class VaultHistoryTableEventCellView: NSTableCellView { timeLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(10, percent: globalFontMagnificationPercent) ) - configureIcon(event: event, agent: agent) - configureAction(action) - updateRelativeTimestamp(for: event) + configureIcon(eventItem.icon) + configureAction(eventItem.action) + updateRelativeTimestamp(eventItem.timestamp) setAccessibilityElement(true) setAccessibilityRole(.group) - setAccessibilityIdentifier("VaultHistoryEventRow:\(event.id)") + setAccessibilityIdentifier(eventItem.accessibilityIdentifier) setAccessibilityLabel([ titleLabel.stringValue, subtitleLabel.stringValue, @@ -248,7 +237,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { onPerformAction: @escaping (VaultHistoryRowAction) -> Void ) { self.onPerformAction = onPerformAction - representedEvent = nil + representedEventItem = nil guard representedTopologyItem != topologyItem || representedFontPercent != globalFontMagnificationPercent else { updateRelativeTimestamp(topologyItem.timestamp) @@ -259,10 +248,9 @@ final class VaultHistoryTableEventCellView: NSTableCellView { representedFontPercent = globalFontMagnificationPercent iconLeadingConstraint.constant = 10 + CGFloat(max(0, topologyItem.indentationLevel)) * 16 - let title = VaultHistoryDisplayText.singleLine(topologyItem.title) - titleLabel.stringValue = Self.truncated(title, maximumLength: 240) - subtitleLabel.stringValue = VaultHistoryDisplayText.singleLine(topologyItem.subtitle) - titleLabel.toolTip = title + titleLabel.stringValue = topologyItem.title + subtitleLabel.stringValue = topologyItem.subtitle + titleLabel.toolTip = topologyItem.title subtitleLabel.toolTip = subtitleLabel.stringValue titleLabel.font = .systemFont( ofSize: GlobalFontMagnification.scaledSize(11.5, percent: globalFontMagnificationPercent) @@ -286,31 +274,6 @@ final class VaultHistoryTableEventCellView: NSTableCellView { ].filter { !$0.isEmpty }.joined(separator: ", ")) } - override func mouseEntered(with event: NSEvent) { - isPointerHovering = true - updateHoverPresentation() - } - - override func mouseExited(with event: NSEvent) { - isPointerHovering = false - updateHoverPresentation() - } - - private func configureIcon(event: VaultHistoryEvent, agent: SessionAgent?) { - if event.kind == .sessionActivity, let agent { - configureAgentIcon( - agent, - description: event.subject.agentDisplayName ?? agent.displayName - ) - return - } - iconView.apply(CmuxResolvedIconRequest( - source: .systemSymbol(name: event.kind.symbolName, accessibilityDescription: event.kind.label), - size: NSSize(width: 14, height: 14), - tintColor: .secondaryLabelColor - )) - } - private func configureIcon(_ icon: VaultHistoryTableRow.Icon) { switch icon { case .agent(let agent): @@ -349,19 +312,18 @@ final class VaultHistoryTableEventCellView: NSTableCellView { actionButton.image = nil actionButton.toolTip = nil actionButton.setAccessibilityLabel(nil) - menu = nil return } actionButton.isHidden = false actionButtonWidthConstraint.constant = 18 - actionButton.image = NSImage( - systemSymbolName: action.symbolName, - accessibilityDescription: action.label - )?.withSymbolConfiguration(.init(pointSize: 9, weight: .medium)) + actionButton.image = Self.actionButtonImage(symbolName: action.symbolName) actionButton.contentTintColor = .secondaryLabelColor actionButton.toolTip = action.label actionButton.setAccessibilityLabel(action.label) + } + override func menu(for event: NSEvent) -> NSMenu? { + guard let action = representedAction else { return nil } let contextMenu = NSMenu() let item = NSMenuItem( title: action.label, @@ -371,11 +333,7 @@ final class VaultHistoryTableEventCellView: NSTableCellView { item.target = self item.image = NSImage(systemSymbolName: action.symbolName, accessibilityDescription: nil) contextMenu.addItem(item) - menu = contextMenu - } - - private func updateRelativeTimestamp(for event: VaultHistoryEvent) { - updateRelativeTimestamp(event.timestamp) + return contextMenu } private func updateRelativeTimestamp(_ timestamp: Date?) { @@ -393,12 +351,6 @@ final class VaultHistoryTableEventCellView: NSTableCellView { timeLabel.toolTip = timestamp.formatted(date: .abbreviated, time: .shortened) } - private func updateHoverPresentation() { - hoverBackground.layer?.backgroundColor = isPointerHovering - ? NSColor.labelColor.withAlphaComponent(0.05).cgColor - : NSColor.clear.cgColor - } - @objc private func performRepresentedAction(_ sender: Any?) { guard let representedAction else { return } onPerformAction?(representedAction) @@ -410,72 +362,64 @@ final class VaultHistoryTableEventCellView: NSTableCellView { return formatter }() - static func displayTitle(for event: VaultHistoryEvent) -> String { - let singleLine = VaultHistoryDisplayText.singleLine(event.title) - guard singleLine.isEmpty else { return truncated(singleLine, maximumLength: 240) } - switch event.kind { - case .windowOpened, .windowClosed: - return String(localized: "vaultHistory.window", defaultValue: "Window") - default: - return String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + private static var actionButtonImages: [String: NSImage] = [:] + + private static func actionButtonImage(symbolName: String) -> NSImage? { + if let image = actionButtonImages[symbolName] { return image } + guard let image = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: nil + )?.withSymbolConfiguration(.init(pointSize: 9, weight: .medium)) else { + return nil } + actionButtonImages[symbolName] = image + return image } - private static func truncated(_ value: String, maximumLength: Int) -> String { - guard value.count > maximumLength else { return value } - return String(value.prefix(maximumLength)) + "…" + static func displayTitle(for event: VaultHistoryEvent) -> String { + VaultHistoryTableRow.EventItem(event: event, action: nil, agent: nil).title } static func displaySubtitle(for event: VaultHistoryEvent) -> String { - var parts: [String] = [] - if event.kind == .sessionActivity { - if let displayName = event.subject.agentDisplayName, !displayName.isEmpty { - parts.append(VaultHistoryDisplayText.singleLine(displayName)) - } else if let raw = event.subject.agent, let agent = SessionAgent(rawValue: raw) { - parts.append(agent.displayName) - } else { - parts.append(event.kind.label) - } - } 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 “%@”"), - VaultHistoryDisplayText.singleLine(previousTitle) - )) - } - if let count = event.workspaceCount { - parts.append(workspaceCountLabel(count)) - } - if let directory = event.subject.directory, !directory.isEmpty { - let component = (directory as NSString).lastPathComponent - if !component.isEmpty, component != "." { - parts.append(VaultHistoryDisplayText.singleLine(component)) - } - } - return VaultHistoryDisplayText.singleLine(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 - ) + VaultHistoryTableRow.EventItem(event: event, action: nil, agent: nil).subtitle } } enum VaultHistoryDisplayText { - static func singleLine(_ value: String) -> String { - value.split(whereSeparator: \.isWhitespace).joined(separator: " ") + static let rowMaximumLength = 240 + static let tooltipMaximumLength = 480 + + static func singleLine( + _ value: String, + maximumLength: Int = rowMaximumLength + ) -> String { + guard maximumLength > 0 else { return "" } + var result = "" + result.reserveCapacity(maximumLength + 1) + var visibleLength = 0 + var inspectedLength = 0 + var hasPendingSpace = false + let inspectionLimit = max(4_096, maximumLength * 16) + + for scalar in value.unicodeScalars { + inspectedLength += 1 + if inspectedLength > inspectionLimit { + return result + "…" + } + if scalar.properties.isWhitespace { + hasPendingSpace = !result.isEmpty + continue + } + if hasPendingSpace { + guard visibleLength < maximumLength else { return result + "…" } + result.append(" ") + visibleLength += 1 + hasPendingSpace = false + } + guard visibleLength < maximumLength else { return result + "…" } + result.unicodeScalars.append(scalar) + visibleLength += 1 + } + return result } } - -private final class VaultHistoryHitTransparentView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} diff --git a/Sources/VaultHistoryTimelineList.swift b/Sources/VaultHistoryTimelineList.swift index ebd150c14cda..c3b038cacc19 100644 --- a/Sources/VaultHistoryTimelineList.swift +++ b/Sources/VaultHistoryTimelineList.swift @@ -278,6 +278,103 @@ enum VaultHistoryTableRow { let accessibilityIdentifier: String } + struct EventItem: Equatable { + let id: String + let title: String + let titleTooltip: String + let subtitle: String + let timestamp: Date + let icon: Icon + let action: VaultHistoryRowAction? + let agent: SessionAgent? + let accessibilityIdentifier: String + + init( + event: VaultHistoryEvent, + action: VaultHistoryRowAction?, + agent: SessionAgent? + ) { + id = event.id + let normalizedTitle = VaultHistoryDisplayText.singleLine(event.title) + if normalizedTitle.isEmpty { + switch event.kind { + case .windowOpened, .windowClosed: + title = String(localized: "vaultHistory.window", defaultValue: "Window") + default: + title = String(localized: "vaultHistory.untitled", defaultValue: "Untitled") + } + } else { + title = normalizedTitle + } + titleTooltip = VaultHistoryDisplayText.singleLine( + event.title, + maximumLength: VaultHistoryDisplayText.tooltipMaximumLength + ) + subtitle = Self.subtitle(for: event) + timestamp = event.timestamp + if event.kind == .sessionActivity, let agent { + icon = .agent(agent) + } else { + icon = .system(name: event.kind.symbolName, style: .secondary) + } + self.action = action + self.agent = agent + accessibilityIdentifier = "VaultHistoryEventRow:\(event.id)" + } + + private static func subtitle(for event: VaultHistoryEvent) -> String { + var parts: [String] = [] + if event.kind == .sessionActivity { + if let displayName = event.subject.agentDisplayName, !displayName.isEmpty { + parts.append(VaultHistoryDisplayText.singleLine(displayName)) + } else if let raw = event.subject.agent, let agent = SessionAgent(rawValue: raw) { + parts.append(agent.displayName) + } else { + parts.append(event.kind.label) + } + } 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 “%@”" + ), + VaultHistoryDisplayText.singleLine(previousTitle) + )) + } + if let count = event.workspaceCount { + parts.append(workspaceCountLabel(count)) + } + if let directory = event.subject.directory, !directory.isEmpty { + let component = (directory as NSString).lastPathComponent + if !component.isEmpty, component != "." { + parts.append(VaultHistoryDisplayText.singleLine(component)) + } + } + return VaultHistoryDisplayText.singleLine(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 + ) + } + } + case group( id: String, title: String, @@ -287,14 +384,22 @@ enum VaultHistoryTableRow { ) case workspace(WorkspaceHeader) case topologyItem(TopologyItem) - case event(event: VaultHistoryEvent, action: VaultHistoryRowAction?, agent: SessionAgent?) + case event(EventItem) + + static func event( + event: VaultHistoryEvent, + action: VaultHistoryRowAction?, + agent: SessionAgent? + ) -> Self { + .event(EventItem(event: event, action: action, agent: agent)) + } var id: VaultHistoryTableRowID { switch self { case .group(let id, _, _, _, _): return .group(id) case .workspace(let header): return .workspace(header.id) case .topologyItem(let item): return .topologyItem(item.id) - case .event(let event, _, _): return .event(event.id) + case .event(let item): return .event(item.id) } } @@ -316,8 +421,8 @@ enum VaultHistoryTableRow { && lhsCount == rhsCount && lhsAgent == rhsAgent && lhsAction == rhsAction - case let (.event(lhsEvent, lhsAction, lhsAgent), .event(rhsEvent, rhsAction, rhsAgent)): - return lhsEvent == rhsEvent && lhsAction == rhsAction && lhsAgent == rhsAgent + case let (.event(lhs), .event(rhs)): + return lhs == rhs case let (.workspace(lhs), .workspace(rhs)): return lhs == rhs case let (.topologyItem(lhs), .topologyItem(rhs)): @@ -329,10 +434,15 @@ enum VaultHistoryTableRow { } @MainActor -final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTableViewDelegate { +final class VaultHistoryTableController: NSObject, NSTableViewDelegate { + private enum Section: Hashable { + case main + } + private static let columnIdentifier = NSUserInterfaceItemIdentifier("history") private weak var containerView: VaultHistoryTableContainerView? - private var rows: [VaultHistoryTableRow] = [] + private var dataSource: NSTableViewDiffableDataSource? + private var rowByID: [VaultHistoryTableRowID: VaultHistoryTableRow] = [:] private var actions = VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) private var globalFontMagnificationPercent = GlobalFontMagnification.defaultPercent private var pendingApply: ApplyInput? @@ -342,7 +452,6 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl let container = VaultHistoryTableContainerView() containerView = container let table = container.tableView - table.dataSource = self table.delegate = self table.headerView = nil table.style = .plain @@ -365,6 +474,17 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl column.resizingMask = .autoresizingMask table.addTableColumn(column) + let dataSource = NSTableViewDiffableDataSource( + tableView: table + ) { [weak self] tableView, _, _, identifier in + guard let self, let row = self.rowByID[identifier] else { + return NSTableCellView() + } + return self.makeCell(tableView: tableView, row: row) + } + dataSource.defaultRowAnimation = [] + self.dataSource = dataSource + let scrollView = container.scrollView scrollView.documentView = table table.frame = scrollView.contentView.bounds @@ -397,48 +517,59 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl } private func flushPendingApply() { - guard let input = pendingApply, let table = containerView?.tableView else { + guard let input = pendingApply, let dataSource else { pendingApply = nil isFlushScheduled = false return } pendingApply = nil isFlushScheduled = false - let previousRows = rows + let identifiers = input.rows.map(\.id) + guard Set(identifiers).count == identifiers.count else { + assertionFailure("History rows must have unique identifiers") + return + } + + let previousRowByID = rowByID + let currentIdentifiers = dataSource.snapshot().itemIdentifiers + let currentIdentifierSet = Set(currentIdentifiers) + let nextRowByID = Dictionary(uniqueKeysWithValues: input.rows.map { ($0.id, $0) }) let presentationChanged = globalFontMagnificationPercent != input.globalFontMagnificationPercent - let structuralChanged = previousRows.map(\.id) != input.rows.map(\.id) - rows = input.rows + let changedIdentifiers = identifiers.filter { identifier in + guard currentIdentifierSet.contains(identifier), + let previousRow = previousRowByID[identifier], + let nextRow = nextRowByID[identifier] else { + return false + } + return presentationChanged || !previousRow.hasEquivalentContent(to: nextRow) + } + + rowByID = nextRowByID globalFontMagnificationPercent = input.globalFontMagnificationPercent + guard currentIdentifiers != identifiers || !changedIdentifiers.isEmpty else { return } - if structuralChanged || presentationChanged { - table.reloadData() - return - } - let changedRows = IndexSet(input.rows.indices.filter { - !previousRows[$0].hasEquivalentContent(to: input.rows[$0]) - }) - guard !changedRows.isEmpty else { return } - table.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0)) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(identifiers, toSection: .main) + snapshot.reloadItems(changedIdentifiers) + dataSource.apply(snapshot, animatingDifferences: true) } - func numberOfRows(in tableView: NSTableView) -> Int { rows.count } - func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { - rows.indices.contains(row) && rows[row].isGroup + tableRow(at: row)?.isGroup == true } func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { - guard rows.indices.contains(row) else { return 36 } - let base: CGFloat = rows[row].isGroup ? 26 : 38 + guard let row = tableRow(at: row) else { return 36 } + let base: CGFloat = row.isGroup ? 26 : 38 return max(base, GlobalFontMagnification.scaledSize( base, percent: globalFontMagnificationPercent )) } - func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - guard rows.indices.contains(row) else { return nil } - switch rows[row] { + private func makeCell(tableView: NSTableView, row: VaultHistoryTableRow) -> NSView { + switch row { case .group(let id, let title, let count, let agent, let action): let cell = (tableView.makeView( withIdentifier: VaultHistoryTableGroupCellView.reuseIdentifier, @@ -476,15 +607,13 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl onPerformAction: { [weak self] action in self?.actions.perform(action) } ) return cell - case .event(let event, let action, let agent): + case .event(let item): let cell = (tableView.makeView( withIdentifier: VaultHistoryTableEventCellView.reuseIdentifier, owner: self ) as? VaultHistoryTableEventCellView) ?? VaultHistoryTableEventCellView() cell.configure( - event: event, - action: action, - agent: agent, + eventItem: item, globalFontMagnificationPercent: globalFontMagnificationPercent, onPerformAction: { [weak self] action in self?.actions.perform(action) } ) @@ -493,10 +622,10 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl } @objc private func handleDoubleClick(_ sender: NSTableView) { - let row = sender.clickedRow - guard rows.indices.contains(row) else { return } - switch rows[row] { - case .event(_, let action?, _): + guard let row = tableRow(at: sender.clickedRow) else { return } + switch row { + case .event(let item): + guard let action = item.action else { return } actions.perform(action) case .topologyItem(let item): guard let action = item.action else { return } @@ -511,6 +640,14 @@ final class VaultHistoryTableController: NSObject, NSTableViewDataSource, NSTabl } } + private func tableRow(at index: Int) -> VaultHistoryTableRow? { + guard index >= 0, + let identifier = dataSource?.itemIdentifier(forRow: index) else { + return nil + } + return rowByID[identifier] + } + private struct ApplyInput { let rows: [VaultHistoryTableRow] let globalFontMagnificationPercent: Int diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index e8d5241aa357..c5e0129bf0ae 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -667,10 +667,11 @@ import Testing return } #expect(groupAgent == .codex) - guard case .event(_, nil, let rowAgent) = rows[1] else { + guard case .event(let item) = rows[1] else { Issue.record("Expected the second virtual row to be an event") return } + let rowAgent = item.agent #expect(rowAgent == .codex) #expect(rowAgent?.assetName == "AgentIcons/Codex") } @@ -710,10 +711,11 @@ import Testing actions: VaultHistoryRowActions(onResume: nil, onReopenClosedItem: nil) ) - guard case .event(_, nil, let rowAgent) = rows[1] else { + guard case .event(let item) = rows[1] else { Issue.record("Expected a virtualized event row") return } + let rowAgent = item.agent #expect(rowAgent == registeredAgent) #expect(rowAgent?.assetName == "AgentIcons/Pi") } @@ -986,6 +988,45 @@ struct VaultHistoryAppKitViewportTests { } } + @MainActor + @Test + func timelineCellDefersPointerOnlyWorkUntilInteraction() throws { + let workspaceId = UUID() + let cell = VaultHistoryTableEventCellView( + frame: NSRect(x: 0, y: 0, width: 320, height: 38) + ) + cell.configure( + event: VaultHistoryEvent( + id: "lazy-pointer-work", + timestamp: Date(), + kind: .workspaceCreated, + title: "Active workspace" + ), + action: .activateWorkspace(workspaceId), + agent: nil, + globalFontMagnificationPercent: 100, + onPerformAction: { _ in } + ) + + #expect(cell.trackingAreas.isEmpty) + #expect(cell.menu == nil) + + let event = try #require(NSEvent.mouseEvent( + with: .rightMouseDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1 + )) + let contextMenu = try #require(cell.menu(for: event)) + #expect(contextMenu.items.count == 1) + #expect(contextMenu.items[0].title == VaultHistoryRowAction.activateWorkspace(workspaceId).label) + } + @MainActor @Test func historyControlsAreNativeAppKitAndRouteChangesWithoutRebuilding() { From b25d19abfed02511d82fcc0d19994892bd35f9b8 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 1 Oct 2026 18:24:30 -0400 Subject: [PATCH 10/10] fix: keep window lifecycle events in History timeline (#15628) * feat: expose History pane from History menu * fix: retain window events in History timeline --- Sources/VaultHistoryTimelineModel.swift | 2 +- ...ltHistoryWorkspaceTimelineProjection.swift | 2 + Sources/cmuxApp+HistoryMenu.swift | 11 +++++ cmuxTests/VaultHistoryGroupingTests.swift | 42 ++++++++++++++++++- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Sources/VaultHistoryTimelineModel.swift b/Sources/VaultHistoryTimelineModel.swift index 705dc890f8b7..65d62805843c 100644 --- a/Sources/VaultHistoryTimelineModel.swift +++ b/Sources/VaultHistoryTimelineModel.swift @@ -42,7 +42,7 @@ enum VaultHistoryMode: String, CaseIterable, Identifiable, Sendable { events.filter { event in switch self { case .timeline: - return event.subject.workspaceId != nil + return event.subject.workspaceId != nil || event.subject.windowId != nil case .folder: let directory = event.subject.directory? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/Sources/VaultHistoryWorkspaceTimelineProjection.swift b/Sources/VaultHistoryWorkspaceTimelineProjection.swift index cf1a9c881b0c..4754a9a3f27b 100644 --- a/Sources/VaultHistoryWorkspaceTimelineProjection.swift +++ b/Sources/VaultHistoryWorkspaceTimelineProjection.swift @@ -354,6 +354,7 @@ struct VaultHistoryWorkspaceTimelineProjection: Sendable { [ workspace.stableId.map { "workspace-stable:\($0.uuidString)" }, workspace.workspaceId.map { "workspace:\($0.uuidString)" }, + workspace.windowId.map { "window:\($0.uuidString)" }, ].compactMap { $0 } } @@ -361,6 +362,7 @@ struct VaultHistoryWorkspaceTimelineProjection: Sendable { [ event.subject.workspaceStableId.map { "workspace-stable:\($0.uuidString)" }, event.subject.workspaceId.map { "workspace:\($0.uuidString)" }, + event.subject.windowId.map { "window:\($0.uuidString)" }, ].compactMap { $0 } } diff --git a/Sources/cmuxApp+HistoryMenu.swift b/Sources/cmuxApp+HistoryMenu.swift index 7e3477b8d7b4..5839a4e6da4b 100644 --- a/Sources/cmuxApp+HistoryMenu.swift +++ b/Sources/cmuxApp+HistoryMenu.swift @@ -46,6 +46,17 @@ extension cmuxApp { snapshot: recentlyClosedSnapshot ) + Button(String(localized: "command.openVaultPane.title", defaultValue: "Open History as Pane")) { + guard AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .sessions, + focusFirstItem: true, + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) == true else { + NSSound.beep() + return + } + } + Divider() splitCommandButton(title: String(localized: "menu.file.restorePreviousAppLaunch", defaultValue: "Restore Previous Launch"), shortcut: menuShortcut(for: .reopenPreviousSession)) { diff --git a/cmuxTests/VaultHistoryGroupingTests.swift b/cmuxTests/VaultHistoryGroupingTests.swift index c5e0129bf0ae..ddde27c823a5 100644 --- a/cmuxTests/VaultHistoryGroupingTests.swift +++ b/cmuxTests/VaultHistoryGroupingTests.swift @@ -226,11 +226,51 @@ import Testing #expect(VaultHistoryMode.timeline.groupKey == .workspace) #expect(VaultHistoryMode.folder.groupKey == .directory) #expect(VaultHistoryMode.agent.groupKey == .agent) - #expect(VaultHistoryMode.timeline.includedEvents(from: events).map(\.id) == ["workspace"]) + #expect(VaultHistoryMode.timeline.includedEvents(from: events).map(\.id) == ["workspace", "window"]) #expect(VaultHistoryMode.folder.includedEvents(from: events).map(\.id) == ["workspace", "session"]) #expect(VaultHistoryMode.agent.includedEvents(from: events).map(\.id) == ["session"]) } + @Test func timelineShowsWindowLifecycleEventsOnTheirWorkspace() throws { + let workspaceId = UUID() + let windowId = UUID() + let topology = VaultHistoryWorkspaceTopology(workspaces: [ + VaultHistoryWorkspaceTopology.Workspace( + id: "workspace:\(workspaceId.uuidString)", + workspaceId: workspaceId, + stableId: nil, + windowId: windowId, + windowLabel: "Main", + title: "History", + directory: "/tmp/repo", + timestamp: Self.now, + state: .active, + isSelected: true, + closedItemId: nil, + terminals: [] + ), + ]) + let windowClosed = event( + id: "window-closed", + secondsAgo: 10, + kind: .windowClosed, + title: "Main", + windowId: windowId + ) + + let section = try #require( + VaultHistoryWorkspaceTimelineProjection().sections( + topology: topology, + events: [windowClosed], + query: VaultHistoryQuery(), + now: Self.now + ).first + ) + + #expect(section.workspaceId == workspaceId) + #expect(section.activityEvents.map(\.id) == ["window-closed"]) + } + // MARK: - Filtering, search, and sorting @Test func timeRangesUseRollingBoundaries() {