From a546827baf453121b6e4ec22252abdc1e03d982d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 02:49:07 -0700 Subject: [PATCH 1/9] Add notification presentation regression tests --- cmuxTests/NotificationAndMenuBarTests.swift | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 0e4149418276..601b7a3a4f6c 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1207,3 +1207,93 @@ final class MenuBarIconRendererTests: XCTestCase { XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) } } + +@MainActor +final class WorkspaceNotificationPresentationStoreTests: XCTestCase { + override func tearDown() { + TerminalNotificationStore.shared.replaceNotificationsForTesting([]) + super.tearDown() + } + + func testWorkspacePresentationStoreIgnoresUnrelatedWorkspaceNotifications() { + let store = TerminalNotificationStore.shared + let workspaceId = UUID() + let otherWorkspaceId = UUID() + let workspacePanelId = UUID() + let otherPanelId = UUID() + let presentationStore = WorkspaceNotificationPresentationStore( + tabId: workspaceId, + notificationStore: store + ) + + var updates: [WorkspaceNotificationPresentation] = [] + let cancellable = presentationStore.$presentation + .dropFirst() + .sink { presentation in + updates.append(presentation) + } + defer { cancellable.cancel() } + + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: otherWorkspaceId, + surfaceId: otherPanelId, + title: "Other", + subtitle: "", + body: "Other body", + createdAt: Date(), + isRead: false + ) + ]) + + XCTAssertTrue( + updates.isEmpty, + "Expected unrelated workspace notifications to avoid publishing workspace presentation updates" + ) + + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: workspacePanelId, + title: "Mine", + subtitle: "", + body: "Workspace body", + createdAt: Date(), + isRead: false + ) + ]) + + let update = try XCTUnwrap(updates.last) + XCTAssertEqual(update.tabId, workspaceId) + XCTAssertEqual(update.unreadCount, 1) + XCTAssertTrue(update.hasVisibleNotificationIndicator(surfaceId: workspacePanelId)) + } + + func testWorkspacePresentationIncludesLatestNotificationAndFocusedIndicatorForWorkspaceOnly() { + let store = TerminalNotificationStore.shared + let workspaceId = UUID() + let panelId = UUID() + let latestNotification = TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: panelId, + title: "Build finished", + subtitle: "", + body: "Done", + createdAt: Date(), + isRead: true + ) + store.replaceNotificationsForTesting([latestNotification]) + store.setFocusedReadIndicator(forTabId: workspaceId, surfaceId: panelId) + + let presentation = store.presentation(forTabId: workspaceId) + + XCTAssertEqual(presentation.latestNotification, latestNotification) + XCTAssertEqual(presentation.focusedReadIndicatorSurfaceId, panelId) + XCTAssertTrue(presentation.hasVisibleNotificationIndicator(surfaceId: panelId)) + XCTAssertFalse(presentation.hasUnreadNotifications) + XCTAssertTrue(presentation.hasReadNotifications) + } +} From 69eca70c521690bc9fb371d8374210a56853564c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 02:53:19 -0700 Subject: [PATCH 2/9] Scope workspace notification updates --- Sources/ContentView.swift | 140 ++++++++++++++---------- Sources/TerminalNotificationStore.swift | 123 +++++++++++++++++++++ Sources/WorkspaceContentView.swift | 40 ++++--- 3 files changed, 228 insertions(+), 75 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e52592971..093ff6f5b657 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1827,11 +1827,11 @@ struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel let windowId: UUID @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @EnvironmentObject var cmuxConfigStore: CmuxConfigStore @EnvironmentObject var fileExplorerState: FileExplorerState + private let notificationStore = TerminalNotificationStore.shared @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @@ -1844,6 +1844,7 @@ struct ContentView: View { @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() @StateObject private var fileExplorerStore = FileExplorerStore() + @StateObject private var notificationPresentationStoreCache = WorkspaceNotificationPresentationStoreCache() @State private var fileExplorerWidth: CGFloat = 220 @State private var fileExplorerDragStartWidth: CGFloat? @State private var previousSelectedWorkspaceId: UUID? @@ -1852,6 +1853,7 @@ struct ContentView: View { @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false @State private var titlebarThemeGeneration: UInt64 = 0 + @State private var selectedWorkspaceNotificationSnapshot = TerminalNotificationWorkspaceSnapshot.empty @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @@ -2102,6 +2104,7 @@ struct ContentView: View { liveSnapshot: workspace.bonsplitController.layoutSnapshot() ) let contentView = window.contentView + let visibleNotificationSurfaceIds = selectedWorkspaceNotificationSnapshot.visibleSurfaceIds let unreadRects: [CGRect] if let layoutSnapshot, let contentView { @@ -2114,10 +2117,7 @@ struct ContentView: View { } let shouldShowUnread = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( - forTabId: workspace.id, - surfaceId: panelId - ), + hasUnreadNotification: visibleNotificationSurfaceIds.contains(panelId), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) ) guard shouldShowUnread else { return nil } @@ -2135,7 +2135,7 @@ struct ContentView: View { } else { unreadRects = WorkspaceContentView.tmuxWorkspacePaneWindowUnreadRects( workspace: workspace, - notificationStore: notificationStore, + visibleNotificationSurfaceIds: visibleNotificationSurfaceIds, layoutSnapshot: layoutSnapshot ) } @@ -2668,6 +2668,7 @@ struct ContentView: View { VerticalTabsSidebar( updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, + notificationPresentationStoreCache: notificationPresentationStoreCache, onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, @@ -2964,6 +2965,14 @@ struct ContentView: View { ) } + private func refreshSelectedWorkspaceNotificationSnapshot() { + guard let selectedTabId = tabManager.selectedTabId else { + selectedWorkspaceNotificationSnapshot = .empty + return + } + selectedWorkspaceNotificationSnapshot = notificationStore.workspaceSnapshot(forTabId: selectedTabId) + } + private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -3100,6 +3109,7 @@ struct ContentView: View { view = AnyView(view.onAppear { tabManager.applyWindowBackgroundForSelectedTab() + refreshSelectedWorkspaceNotificationSnapshot() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() @@ -3189,6 +3199,21 @@ struct ContentView: View { updateTitlebarText() }) + view = AnyView(view.onReceive( + tabManager.$selectedTabId + .map { [notificationStore] tabId -> AnyPublisher in + guard let tabId else { + return Just(.empty).eraseToAnyPublisher() + } + return notificationStore.workspaceSnapshotPublisher(forTabId: tabId) + } + .switchToLatest() + .receive(on: RunLoop.main) + ) { snapshot in + guard selectedWorkspaceNotificationSnapshot != snapshot else { return } + selectedWorkspaceNotificationSnapshot = snapshot + }) + view = AnyView(view.onChange(of: selectedTabIds) { _ in syncSidebarSelectedWorkspaceIds() }) @@ -10089,9 +10114,9 @@ private struct SidebarTabItemPresentationSnapshot: Equatable { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel @ObservedObject var fileExplorerState: FileExplorerState + let notificationPresentationStoreCache: WorkspaceNotificationPresentationStoreCache let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var notificationStore: TerminalNotificationStore @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -10102,7 +10127,6 @@ struct VerticalTabsSidebar: View { @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? - @State private var frozenTabItemPresentation: SidebarTabItemPresentationSnapshot? @State private var terminalScrollBarVisibilityGeneration: UInt64 = 0 @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @@ -10116,10 +10140,6 @@ struct VerticalTabsSidebar: View { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal } - private var showsSidebarNotificationMessage: Bool { - tabItemSettingsStore.snapshot.showsNotificationMessage - } - private var workspaceNumberShortcut: StoredShortcut { let _ = keyboardShortcutSettingsObserver.revision return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) @@ -10177,29 +10197,11 @@ struct VerticalTabsSidebar: View { contextMenuWorkspaceIds.allSatisfy { workspaceId in workspaceTerminalScrollBarHiddenById[workspaceId] == true } - let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) - let liveLatestNotificationText: String? = { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }() - let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed - let livePresentation = SidebarTabItemPresentationSnapshot( - tabId: tab.id, - unreadCount: liveUnreadCount, - latestNotificationText: liveLatestNotificationText, - showsModifierShortcutHints: liveShowsModifierShortcutHints - ) - let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id - ? frozenTabItemPresentation - : nil + let notificationPresentationStore = notificationPresentationStoreCache.store(for: tab.id) TabItemView( tabManager: tabManager, - notificationStore: notificationStore, + notificationStore: TerminalNotificationStore.shared, + notificationPresentationStore: notificationPresentationStore, tab: tab, index: index, isActive: tabManager.selectedTabId == tab.id, @@ -10210,13 +10212,11 @@ struct VerticalTabsSidebar: View { workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: canCloseWorkspace, accessibilityWorkspaceCount: workspaceCount, - unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, - latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, rowSpacing: tabRowSpacing, setSelectionToTabs: { selection = .tabs }, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, @@ -10225,9 +10225,7 @@ struct VerticalTabsSidebar: View { allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, - settings: tabItemSettings, - livePresentation: livePresentation, - frozenPresentation: $frozenTabItemPresentation + settings: tabItemSettings ) .equatable() } @@ -10267,7 +10265,7 @@ struct VerticalTabsSidebar: View { } .overlay(alignment: .topLeading) { if isMinimalMode { - HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + HiddenTitlebarSidebarControlsView(notificationStore: TerminalNotificationStore.shared) .padding(.leading, hiddenTitlebarControlsLeadingInset) .padding(.top, 2) } @@ -10291,6 +10289,7 @@ struct VerticalTabsSidebar: View { .frame(width: 0, height: 0) ) .onAppear { + notificationPresentationStoreCache.removeStaleStores(keepingTabIds: Set(tabManager.tabs.map(\.id))) modifierKeyMonitor.start() draggedTabId = nil dropIndicator = nil @@ -10329,9 +10328,7 @@ struct VerticalTabsSidebar: View { dropIndicator = nil } .onChange(of: tabs.map(\.id)) { tabIds in - guard let frozenTabItemPresentation, - !tabIds.contains(frozenTabItemPresentation.tabId) else { return } - self.frozenTabItemPresentation = nil + notificationPresentationStoreCache.removeStaleStores(keepingTabIds: Set(tabIds)) } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } @@ -12629,8 +12626,6 @@ private struct TabItemView: View, Equatable { lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && lhs.canCloseWorkspace == rhs.canCloseWorkspace && lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && @@ -12646,6 +12641,7 @@ private struct TabItemView: View, Equatable { // action handlers use the plain references without triggering re-evaluation. let tabManager: TabManager let notificationStore: TerminalNotificationStore + @ObservedObject var notificationPresentationStore: WorkspaceNotificationPresentationStore @Environment(\.colorScheme) private var colorScheme let tab: Tab let index: Int @@ -12654,8 +12650,6 @@ private struct TabItemView: View, Equatable { let workspaceShortcutModifierSymbol: String let canCloseWorkspace: Bool let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? let rowSpacing: CGFloat let setSelectionToTabs: () -> Void @Binding var selectedTabIds: Set @@ -12670,12 +12664,11 @@ private struct TabItemView: View, Equatable { let allRemoteContextMenuTargetsDisconnected: Bool let allContextMenuWorkspacesHideTerminalScrollBar: Bool let settings: SidebarTabItemSettingsSnapshot - let livePresentation: SidebarTabItemPresentationSnapshot - @Binding var frozenPresentation: SidebarTabItemPresentationSnapshot? @State private var workspaceObservationGeneration: UInt64 = 0 @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var isHovering = false @State private var rowHeight: CGFloat = 1 + @State private var frozenPresentation: SidebarTabItemPresentationSnapshot? var isMultiSelected: Bool { selectedTabIds.contains(tab.id) @@ -12737,6 +12730,37 @@ private struct TabItemView: View, Equatable { .semibold } + private var liveLatestNotificationText: String? { + guard settings.showsNotificationMessage, + let notification = notificationPresentationStore.presentation.latestNotification else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private var livePresentation: SidebarTabItemPresentationSnapshot { + SidebarTabItemPresentationSnapshot( + tabId: tab.id, + unreadCount: notificationPresentationStore.presentation.unreadCount, + latestNotificationText: liveLatestNotificationText, + showsModifierShortcutHints: showsModifierShortcutHints + ) + } + + private var effectiveUnreadCount: Int { + frozenPresentation?.unreadCount ?? notificationPresentationStore.presentation.unreadCount + } + + private var effectiveLatestNotificationText: String? { + frozenPresentation?.latestNotificationText ?? liveLatestNotificationText + } + + private var effectiveShowsModifierShortcutHints: Bool { + frozenPresentation?.showsModifierShortcutHints ?? showsModifierShortcutHints + } + private var showsLeadingRail: Bool { explicitRailColor != nil } @@ -12796,7 +12820,7 @@ private struct TabItemView: View, Equatable { } private var showCloseButton: Bool { - isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) + isHovering && canCloseWorkspace && !(effectiveShowsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -12805,7 +12829,7 @@ private struct TabItemView: View, Equatable { } private var showsWorkspaceShortcutHint: Bool { - (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + (effectiveShowsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } private var trailingAccessoryWidth: CGFloat { @@ -12882,7 +12906,7 @@ private struct TabItemView: View, Equatable { .lineLimit(1) } } - .padding(.top, latestNotificationText == nil ? 1 : 2) + .padding(.top, effectiveLatestNotificationText == nil ? 1 : 2) .safeHelp(remoteStateHelpText) } } @@ -12910,7 +12934,7 @@ private struct TabItemView: View, Equatable { let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText + let latestNotificationSubtitle = effectiveLatestNotificationText let effectiveSubtitle = latestNotificationSubtitle let detailVisibility = visibleAuxiliaryDetails let orderedPanelIds: [UUID]? = (detailVisibility.showsBranchDirectory || detailVisibility.showsPullRequests) @@ -12953,11 +12977,11 @@ private struct TabItemView: View, Equatable { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - if unreadCount > 0 { + if effectiveUnreadCount > 0 { ZStack { Circle() .fill(activeUnreadBadgeFillColor) - Text("\(unreadCount)") + Text("\(effectiveUnreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) } @@ -13006,7 +13030,7 @@ private struct TabItemView: View, Equatable { .transition(.opacity) } } - .animation(.easeOut(duration: 0.12), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .animation(.easeOut(duration: 0.12), value: effectiveShowsModifierShortcutHints || alwaysShowShortcutHints) .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) } @@ -13901,8 +13925,8 @@ private struct TabItemView: View, Equatable { syncSelectionAfterMutation() } - // latestNotificationText is now passed as a parameter from the parent view - // to avoid subscribing to notificationStore changes in every TabItemView. + // Notification presentation is observed per workspace row so unrelated + // workspace notifications do not invalidate sibling sidebar rows. private func branchDirectoryRow( gitSummary: String?, diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 6e3420ce5402..bd3635d97d69 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Foundation import UserNotifications import Bonsplit @@ -666,6 +667,84 @@ struct TerminalNotification: Identifiable, Hashable { var isRead: Bool } +struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable { + let tabId: UUID? + let unreadCount: Int + let hasRead: Bool + let visibleSurfaceIds: Set + let latestNotification: TerminalNotification? + let focusedReadIndicatorSurfaceId: UUID? + + static let empty = TerminalNotificationWorkspaceSnapshot( + tabId: nil, + unreadCount: 0, + hasRead: false, + visibleSurfaceIds: [], + latestNotification: nil, + focusedReadIndicatorSurfaceId: nil + ) + + var hasUnreadNotifications: Bool { + unreadCount > 0 + } + + var hasReadNotifications: Bool { + hasRead + } + + func hasVisibleNotificationIndicator(surfaceId: UUID?) -> Bool { + guard let surfaceId else { return false } + return visibleSurfaceIds.contains(surfaceId) + } +} + +typealias WorkspaceNotificationPresentation = TerminalNotificationWorkspaceSnapshot + +@MainActor +final class WorkspaceNotificationPresentationStore: ObservableObject { + @Published private(set) var presentation: WorkspaceNotificationPresentation + + private var cancellable: AnyCancellable? + + init( + tabId: UUID, + notificationStore: TerminalNotificationStore = .shared + ) { + self.presentation = notificationStore.presentation(forTabId: tabId) + cancellable = notificationStore.presentationPublisher(forTabId: tabId) + .sink { [weak self] presentation in + guard let self, self.presentation != presentation else { return } + self.presentation = presentation + } + } +} + +@MainActor +final class WorkspaceNotificationPresentationStoreCache: ObservableObject { + private let notificationStore: TerminalNotificationStore + private var stores: [UUID: WorkspaceNotificationPresentationStore] = [:] + + init(notificationStore: TerminalNotificationStore = .shared) { + self.notificationStore = notificationStore + } + + func store(for tabId: UUID) -> WorkspaceNotificationPresentationStore { + if let existing = stores[tabId] { + return existing + } + let store = WorkspaceNotificationPresentationStore( + tabId: tabId, + notificationStore: notificationStore + ) + stores[tabId] = store + return store + } + + func removeStaleStores(keepingTabIds tabIds: Set) { + stores = stores.filter { tabIds.contains($0.key) } + } +} + @MainActor final class TerminalNotificationStore: ObservableObject { private struct TabSurfaceKey: Hashable { @@ -896,6 +975,50 @@ final class TerminalNotificationStore: ObservableObject { focusedReadIndicatorByTabId[tabId] } + func presentation(forTabId tabId: UUID) -> WorkspaceNotificationPresentation { + workspaceSnapshot(forTabId: tabId) + } + + func workspaceSnapshot(forTabId tabId: UUID) -> TerminalNotificationWorkspaceSnapshot { + var hasRead = false + var visibleSurfaceIds = Set() + let focusedReadIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId] + + for notification in notifications where notification.tabId == tabId { + if notification.isRead { + hasRead = true + } else if let surfaceId = notification.surfaceId { + visibleSurfaceIds.insert(surfaceId) + } + } + + if let surfaceId = focusedReadIndicatorSurfaceId { + visibleSurfaceIds.insert(surfaceId) + } + + return TerminalNotificationWorkspaceSnapshot( + tabId: tabId, + unreadCount: unreadCount(forTabId: tabId), + hasRead: hasRead, + visibleSurfaceIds: visibleSurfaceIds, + latestNotification: latestNotification(forTabId: tabId), + focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId + ) + } + + func presentationPublisher(forTabId tabId: UUID) -> AnyPublisher { + workspaceSnapshotPublisher(forTabId: tabId) + } + + func workspaceSnapshotPublisher(forTabId tabId: UUID) -> AnyPublisher { + Publishers.CombineLatest($notifications, $focusedReadIndicatorByTabId) + .map { [weak self] _, _ in + self?.workspaceSnapshot(forTabId: tabId) ?? .empty + } + .removeDuplicates() + .eraseToAnyPublisher() + } + func addNotification( tabId: UUID, surfaceId: UUID?, diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 13b877817352..0be4d4e354f9 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -237,7 +237,8 @@ struct WorkspaceContentView: View { @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var notificationStore: TerminalNotificationStore + private let notificationStore = TerminalNotificationStore.shared + @State private var notificationSnapshot = TerminalNotificationWorkspaceSnapshot.empty private var isMinimalMode: Bool { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal @@ -290,10 +291,7 @@ struct WorkspaceContentView: View { isFocused: isFocused ) let showsNotificationRing = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( - forTabId: workspace.id, - surfaceId: panel.id - ), + hasUnreadNotification: notificationSnapshot.visibleSurfaceIds.contains(panel.id), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) ) PanelContentView( @@ -342,10 +340,18 @@ struct WorkspaceContentView: View { .id(splitZoomRenderIdentity) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { + refreshNotificationSnapshot() syncBonsplitNotificationBadges() refreshGhosttyAppearanceConfig(reason: "onAppear") } - .onChange(of: notificationStore.notifications) { _, _ in + .onReceive( + notificationStore.workspaceSnapshotPublisher(forTabId: workspace.id) + .receive(on: RunLoop.main) + ) { snapshot in + guard notificationSnapshot != snapshot else { return } + notificationSnapshot = snapshot + } + .onChange(of: notificationSnapshot) { _, _ in syncBonsplitNotificationBadges() } .onChange(of: workspace.manualUnreadPanelIds) { _, _ in @@ -396,8 +402,7 @@ struct WorkspaceContentView: View { let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) } let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false let shouldShow = panelId.map { - notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) || - manualUnread.contains($0) + notificationSnapshot.visibleSurfaceIds.contains($0) || manualUnread.contains($0) } ?? false let kindUpdate: String?? = expectedKind.map { .some($0) } @@ -415,6 +420,10 @@ struct WorkspaceContentView: View { } } + private func refreshNotificationSnapshot() { + notificationSnapshot = notificationStore.workspaceSnapshot(forTabId: workspace.id) + } + private var splitZoomRenderIdentity: String { workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed" } @@ -474,7 +483,7 @@ struct WorkspaceContentView: View { private static func tmuxWorkspacePaneRects( workspace: Workspace, - notificationStore: TerminalNotificationStore, + visibleNotificationSurfaceIds: Set, layoutSnapshot: LayoutSnapshot?, includeContainerOffset: Bool, trimMode: TmuxWorkspacePaneOverlayTrimMode @@ -489,10 +498,7 @@ struct WorkspaceContentView: View { } let shouldShowUnread = Workspace.shouldShowUnreadIndicator( - hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator( - forTabId: workspace.id, - surfaceId: panelId - ), + hasUnreadNotification: visibleNotificationSurfaceIds.contains(panelId), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId) ) guard shouldShowUnread else { return nil } @@ -555,12 +561,12 @@ struct WorkspaceContentView: View { static func tmuxWorkspacePaneUnreadRects( workspace: Workspace, - notificationStore: TerminalNotificationStore, + visibleNotificationSurfaceIds: Set, layoutSnapshot: LayoutSnapshot? ) -> [CGRect] { tmuxWorkspacePaneRects( workspace: workspace, - notificationStore: notificationStore, + visibleNotificationSurfaceIds: visibleNotificationSurfaceIds, layoutSnapshot: layoutSnapshot, includeContainerOffset: false, trimMode: .workspaceLocal @@ -569,12 +575,12 @@ struct WorkspaceContentView: View { static func tmuxWorkspacePaneWindowUnreadRects( workspace: Workspace, - notificationStore: TerminalNotificationStore, + visibleNotificationSurfaceIds: Set, layoutSnapshot: LayoutSnapshot? ) -> [CGRect] { tmuxWorkspacePaneRects( workspace: workspace, - notificationStore: notificationStore, + visibleNotificationSurfaceIds: visibleNotificationSurfaceIds, layoutSnapshot: layoutSnapshot, includeContainerOffset: true, trimMode: .windowContent From 03c82857339a955b43585ddd76286808cf722c28 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 03:36:19 -0700 Subject: [PATCH 3/9] Fix workspace notification presentation CI failures --- Sources/TerminalNotificationStore.swift | 32 ++++++++++++++++++--- cmuxTests/NotificationAndMenuBarTests.swift | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index bd3635d97d69..b0441a439a40 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -980,15 +980,35 @@ final class TerminalNotificationStore: ObservableObject { } func workspaceSnapshot(forTabId tabId: UUID) -> TerminalNotificationWorkspaceSnapshot { + Self.workspaceSnapshot( + forTabId: tabId, + notifications: notifications, + focusedReadIndicatorByTabId: focusedReadIndicatorByTabId + ) + } + + private static func workspaceSnapshot( + forTabId tabId: UUID, + notifications: [TerminalNotification], + focusedReadIndicatorByTabId: [UUID: UUID] + ) -> TerminalNotificationWorkspaceSnapshot { + var unreadCount = 0 var hasRead = false var visibleSurfaceIds = Set() let focusedReadIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId] + var latestNotification: TerminalNotification? for notification in notifications where notification.tabId == tabId { + if latestNotification == nil { + latestNotification = notification + } if notification.isRead { hasRead = true } else if let surfaceId = notification.surfaceId { + unreadCount += 1 visibleSurfaceIds.insert(surfaceId) + } else { + unreadCount += 1 } } @@ -998,10 +1018,10 @@ final class TerminalNotificationStore: ObservableObject { return TerminalNotificationWorkspaceSnapshot( tabId: tabId, - unreadCount: unreadCount(forTabId: tabId), + unreadCount: unreadCount, hasRead: hasRead, visibleSurfaceIds: visibleSurfaceIds, - latestNotification: latestNotification(forTabId: tabId), + latestNotification: latestNotification, focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId ) } @@ -1012,8 +1032,12 @@ final class TerminalNotificationStore: ObservableObject { func workspaceSnapshotPublisher(forTabId tabId: UUID) -> AnyPublisher { Publishers.CombineLatest($notifications, $focusedReadIndicatorByTabId) - .map { [weak self] _, _ in - self?.workspaceSnapshot(forTabId: tabId) ?? .empty + .map { notifications, focusedReadIndicatorByTabId in + Self.workspaceSnapshot( + forTabId: tabId, + notifications: notifications, + focusedReadIndicatorByTabId: focusedReadIndicatorByTabId + ) } .removeDuplicates() .eraseToAnyPublisher() diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 601b7a3a4f6c..f4f61613b44f 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1215,7 +1215,7 @@ final class WorkspaceNotificationPresentationStoreTests: XCTestCase { super.tearDown() } - func testWorkspacePresentationStoreIgnoresUnrelatedWorkspaceNotifications() { + func testWorkspacePresentationStoreIgnoresUnrelatedWorkspaceNotifications() throws { let store = TerminalNotificationStore.shared let workspaceId = UUID() let otherWorkspaceId = UUID() From 6204b10bb7447d70ee022013b0174e11b44d3ba5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 11:21:17 -0700 Subject: [PATCH 4/9] Fix workspace unread rect test for snapshot API --- cmuxTests/WorkspaceContentViewVisibilityTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index 2d6e8fa5fa08..420eda0b9f36 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -152,7 +152,7 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { XCTAssertEqual( WorkspaceContentView.tmuxWorkspacePaneUnreadRects( workspace: workspace, - notificationStore: store, + visibleNotificationSurfaceIds: store.workspaceSnapshot(forTabId: workspace.id).visibleSurfaceIds, layoutSnapshot: snapshot ), [CGRect(x: 677.5, y: 30, width: 500, height: 290)] From 41f52d2712e398b35bbf76ea812ae78fd5951dcd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 12:19:33 -0700 Subject: [PATCH 5/9] Stabilize file explorer hydration on macOS 26 --- Sources/FileExplorerStore.swift | 6 +++--- cmuxTests/FileExplorerStoreTests.swift | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a6459f6c219a..8f3837cad05b 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -599,7 +599,7 @@ final class FileExplorerStore: ObservableObject { guard !rootPath.isEmpty, provider != nil else { return } isRootLoading = true let path = rootPath - let task = Task { [weak self] in + let task = Task { @MainActor [weak self] in guard let self else { return } await self.loadChildren(for: nil, at: path) } @@ -614,7 +614,7 @@ final class FileExplorerStore: ObservableObject { node.error = nil objectWillChange.send() let nodePath = node.path - let task = Task { [weak self] in + let task = Task { @MainActor [weak self] in guard let self else { return } await self.loadChildren(for: node, at: nodePath) } @@ -702,7 +702,7 @@ final class FileExplorerStore: ObservableObject { child.isLoading = true objectWillChange.send() let childPath = child.path - let childTask = Task { [weak self] in + let childTask = Task { @MainActor [weak self] in guard let self else { return } await self.loadChildren(for: child, at: childPath) } diff --git a/cmuxTests/FileExplorerStoreTests.swift b/cmuxTests/FileExplorerStoreTests.swift index fe40f6327f5f..60351dd09fe2 100644 --- a/cmuxTests/FileExplorerStoreTests.swift +++ b/cmuxTests/FileExplorerStoreTests.swift @@ -167,8 +167,12 @@ final class FileExplorerStoreTests: XCTestCase { XCTAssertTrue(store.rootNodes.isEmpty) // Manually track expanded state (user expanded before provider was ready) - store.expand(node: FileExplorerNode(name: "src", path: "/home/user/project/src", isDirectory: true)) + let pendingNode = FileExplorerNode(name: "src", path: "/home/user/project/src", isDirectory: true) + store.expand(node: pendingNode) XCTAssertTrue(store.expandedPaths.contains("/home/user/project/src")) + try await waitFor("unavailable src expansion attempt finished") { + provider.listCallPaths.contains("/home/user/project/src") && pendingNode.isLoading == false + } // Provider becomes available provider.isAvailable = true From f5b5e30f699e4047c2a4d7e551d8c0d7e4b4c672 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 15:55:00 -0700 Subject: [PATCH 6/9] Keep command palette notification state reactive --- Sources/ContentView.swift | 14 +++++++++++--- Sources/TerminalNotificationStore.swift | 10 ++++++++++ cmuxTests/NotificationAndMenuBarTests.swift | 2 ++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 093ff6f5b657..2a8a1cfaff07 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6483,11 +6483,15 @@ struct ContentView: View { ) snapshot.setBool( CommandPaletteContextKeys.workspaceHasUnread, - notificationStore.notifications.contains { $0.tabId == workspace.id && !$0.isRead } + selectedWorkspaceNotificationSnapshot.tabId == workspace.id + ? selectedWorkspaceNotificationSnapshot.hasUnreadNotifications + : notificationStore.workspaceSnapshot(forTabId: workspace.id).hasUnreadNotifications ) snapshot.setBool( CommandPaletteContextKeys.workspaceHasRead, - notificationStore.notifications.contains { $0.tabId == workspace.id && $0.isRead } + selectedWorkspaceNotificationSnapshot.tabId == workspace.id + ? selectedWorkspaceNotificationSnapshot.hasReadNotifications + : notificationStore.workspaceSnapshot(forTabId: workspace.id).hasReadNotifications ) } @@ -6504,8 +6508,12 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) + let workspaceNotificationSnapshot = + selectedWorkspaceNotificationSnapshot.tabId == workspace.id + ? selectedWorkspaceNotificationSnapshot + : notificationStore.workspaceSnapshot(forTabId: workspace.id) let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) - || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + || workspaceNotificationSnapshot.hasUnreadNotification(surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) if panelIsTerminal { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index b0441a439a40..3d72ee9d3444 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -671,6 +671,7 @@ struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable { let tabId: UUID? let unreadCount: Int let hasRead: Bool + let unreadSurfaceIds: Set let visibleSurfaceIds: Set let latestNotification: TerminalNotification? let focusedReadIndicatorSurfaceId: UUID? @@ -679,6 +680,7 @@ struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable { tabId: nil, unreadCount: 0, hasRead: false, + unreadSurfaceIds: [], visibleSurfaceIds: [], latestNotification: nil, focusedReadIndicatorSurfaceId: nil @@ -692,6 +694,11 @@ struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable { hasRead } + func hasUnreadNotification(surfaceId: UUID?) -> Bool { + guard let surfaceId else { return false } + return unreadSurfaceIds.contains(surfaceId) + } + func hasVisibleNotificationIndicator(surfaceId: UUID?) -> Bool { guard let surfaceId else { return false } return visibleSurfaceIds.contains(surfaceId) @@ -994,6 +1001,7 @@ final class TerminalNotificationStore: ObservableObject { ) -> TerminalNotificationWorkspaceSnapshot { var unreadCount = 0 var hasRead = false + var unreadSurfaceIds = Set() var visibleSurfaceIds = Set() let focusedReadIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId] var latestNotification: TerminalNotification? @@ -1006,6 +1014,7 @@ final class TerminalNotificationStore: ObservableObject { hasRead = true } else if let surfaceId = notification.surfaceId { unreadCount += 1 + unreadSurfaceIds.insert(surfaceId) visibleSurfaceIds.insert(surfaceId) } else { unreadCount += 1 @@ -1020,6 +1029,7 @@ final class TerminalNotificationStore: ObservableObject { tabId: tabId, unreadCount: unreadCount, hasRead: hasRead, + unreadSurfaceIds: unreadSurfaceIds, visibleSurfaceIds: visibleSurfaceIds, latestNotification: latestNotification, focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index f4f61613b44f..9ed5a29b791f 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1268,6 +1268,7 @@ final class WorkspaceNotificationPresentationStoreTests: XCTestCase { let update = try XCTUnwrap(updates.last) XCTAssertEqual(update.tabId, workspaceId) XCTAssertEqual(update.unreadCount, 1) + XCTAssertTrue(update.hasUnreadNotification(surfaceId: workspacePanelId)) XCTAssertTrue(update.hasVisibleNotificationIndicator(surfaceId: workspacePanelId)) } @@ -1292,6 +1293,7 @@ final class WorkspaceNotificationPresentationStoreTests: XCTestCase { XCTAssertEqual(presentation.latestNotification, latestNotification) XCTAssertEqual(presentation.focusedReadIndicatorSurfaceId, panelId) + XCTAssertFalse(presentation.hasUnreadNotification(surfaceId: panelId)) XCTAssertTrue(presentation.hasVisibleNotificationIndicator(surfaceId: panelId)) XCTAssertFalse(presentation.hasUnreadNotifications) XCTAssertTrue(presentation.hasReadNotifications) From c56ef6263082f06b8757ec3a45bebacf9bc6ae5b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Apr 2026 16:41:13 -0700 Subject: [PATCH 7/9] Fix stale notification snapshot consumers --- Sources/ContentView.swift | 49 +++++++++++------ cmuxTests/NotificationAndMenuBarTests.swift | 59 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2a8a1cfaff07..3c9e6534b6a6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2104,7 +2104,7 @@ struct ContentView: View { liveSnapshot: workspace.bonsplitController.layoutSnapshot() ) let contentView = window.contentView - let visibleNotificationSurfaceIds = selectedWorkspaceNotificationSnapshot.visibleSurfaceIds + let visibleNotificationSurfaceIds = workspaceNotificationSnapshot(for: workspace.id).visibleSurfaceIds let unreadRects: [CGRect] if let layoutSnapshot, let contentView { @@ -2973,6 +2973,31 @@ struct ContentView: View { selectedWorkspaceNotificationSnapshot = notificationStore.workspaceSnapshot(forTabId: selectedTabId) } + static func resolvedWorkspaceNotificationSnapshot( + selectedWorkspaceNotificationSnapshot: TerminalNotificationWorkspaceSnapshot, + workspaceId: UUID, + notificationStore: TerminalNotificationStore + ) -> TerminalNotificationWorkspaceSnapshot { + guard selectedWorkspaceNotificationSnapshot.tabId == workspaceId else { + return notificationStore.workspaceSnapshot(forTabId: workspaceId) + } + return selectedWorkspaceNotificationSnapshot + } + + private func workspaceNotificationSnapshot(for workspaceId: UUID) -> TerminalNotificationWorkspaceSnapshot { + Self.resolvedWorkspaceNotificationSnapshot( + selectedWorkspaceNotificationSnapshot: selectedWorkspaceNotificationSnapshot, + workspaceId: workspaceId, + notificationStore: notificationStore + ) + } + + private func pruneNotificationPresentationStores() { + notificationPresentationStoreCache.removeStaleStores( + keepingTabIds: Set(tabManager.tabs.map(\.id)) + ) + } + private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -3110,6 +3135,7 @@ struct ContentView: View { view = AnyView(view.onAppear { tabManager.applyWindowBackgroundForSelectedTab() refreshSelectedWorkspaceNotificationSnapshot() + pruneNotificationPresentationStores() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() @@ -3218,6 +3244,10 @@ struct ContentView: View { syncSidebarSelectedWorkspaceIds() }) + view = AnyView(view.onChange(of: tabManager.tabs.map(\.id)) { _ in + pruneNotificationPresentationStores() + }) + // File explorer: reactively sync CWD when selected workspace or its directory changes. // Uses switchToLatest to automatically unsubscribe from the old workspace's publisher. view = AnyView(view.onReceive( @@ -6483,15 +6513,11 @@ struct ContentView: View { ) snapshot.setBool( CommandPaletteContextKeys.workspaceHasUnread, - selectedWorkspaceNotificationSnapshot.tabId == workspace.id - ? selectedWorkspaceNotificationSnapshot.hasUnreadNotifications - : notificationStore.workspaceSnapshot(forTabId: workspace.id).hasUnreadNotifications + workspaceNotificationSnapshot(for: workspace.id).hasUnreadNotifications ) snapshot.setBool( CommandPaletteContextKeys.workspaceHasRead, - selectedWorkspaceNotificationSnapshot.tabId == workspace.id - ? selectedWorkspaceNotificationSnapshot.hasReadNotifications - : notificationStore.workspaceSnapshot(forTabId: workspace.id).hasReadNotifications + workspaceNotificationSnapshot(for: workspace.id).hasReadNotifications ) } @@ -6508,10 +6534,7 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) - let workspaceNotificationSnapshot = - selectedWorkspaceNotificationSnapshot.tabId == workspace.id - ? selectedWorkspaceNotificationSnapshot - : notificationStore.workspaceSnapshot(forTabId: workspace.id) + let workspaceNotificationSnapshot = workspaceNotificationSnapshot(for: workspace.id) let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) || workspaceNotificationSnapshot.hasUnreadNotification(surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) @@ -10297,7 +10320,6 @@ struct VerticalTabsSidebar: View { .frame(width: 0, height: 0) ) .onAppear { - notificationPresentationStoreCache.removeStaleStores(keepingTabIds: Set(tabManager.tabs.map(\.id))) modifierKeyMonitor.start() draggedTabId = nil dropIndicator = nil @@ -10335,9 +10357,6 @@ struct VerticalTabsSidebar: View { dragAutoScrollController.stop() dropIndicator = nil } - .onChange(of: tabs.map(\.id)) { tabIds in - notificationPresentationStoreCache.removeStaleStores(keepingTabIds: Set(tabIds)) - } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 9ed5a29b791f..75dce722a0fd 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -1298,4 +1298,63 @@ final class WorkspaceNotificationPresentationStoreTests: XCTestCase { XCTAssertFalse(presentation.hasUnreadNotifications) XCTAssertTrue(presentation.hasReadNotifications) } + + func testResolvedWorkspaceNotificationSnapshotFallsBackWhenSelectedSnapshotIsStale() { + let store = TerminalNotificationStore.shared + let previousWorkspaceId = UUID() + let previousPanelId = UUID() + let selectedWorkspaceId = UUID() + let selectedPanelId = UUID() + + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: previousWorkspaceId, + surfaceId: previousPanelId, + title: "Previous", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ), + TerminalNotification( + id: UUID(), + tabId: selectedWorkspaceId, + surfaceId: selectedPanelId, + title: "Selected", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + ]) + defer { store.replaceNotificationsForTesting([]) } + + let staleSnapshot = store.workspaceSnapshot(forTabId: previousWorkspaceId) + + let resolvedSnapshot = ContentView.resolvedWorkspaceNotificationSnapshot( + selectedWorkspaceNotificationSnapshot: staleSnapshot, + workspaceId: selectedWorkspaceId, + notificationStore: store + ) + + XCTAssertEqual(resolvedSnapshot.tabId, selectedWorkspaceId) + XCTAssertTrue(resolvedSnapshot.hasUnreadNotification(surfaceId: selectedPanelId)) + XCTAssertFalse(resolvedSnapshot.hasVisibleNotificationIndicator(surfaceId: previousPanelId)) + } + + @MainActor + func testWorkspaceNotificationPresentationStoreCacheRemovesStaleStores() { + let cache = WorkspaceNotificationPresentationStoreCache(notificationStore: .shared) + let keptWorkspaceId = UUID() + let removedWorkspaceId = UUID() + + let keptStore = cache.store(for: keptWorkspaceId) + let removedStore = cache.store(for: removedWorkspaceId) + + cache.removeStaleStores(keepingTabIds: Set([keptWorkspaceId])) + + XCTAssertTrue(keptStore === cache.store(for: keptWorkspaceId)) + XCTAssertFalse(removedStore === cache.store(for: removedWorkspaceId)) + } } From 2eff05488966908d2f3971e10150b8754c314ac8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 18 Apr 2026 17:47:01 -0700 Subject: [PATCH 8/9] Fix notification snapshot indexing and file explorer loads --- Sources/FileExplorerStore.swift | 6 +- Sources/TerminalNotificationStore.swift | 73 ++++++++++++++++--------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index 7e5f0f93e93c..411856a88663 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -606,7 +606,7 @@ final class FileExplorerStore: ObservableObject { guard !rootPath.isEmpty, provider != nil else { return } isRootLoading = true let path = rootPath - let task = Task { @MainActor [weak self] in + let task = Task { [weak self] in guard let self else { return } await self.loadChildren(for: nil, at: path) } @@ -621,7 +621,7 @@ final class FileExplorerStore: ObservableObject { node.error = nil objectWillChange.send() let nodePath = node.path - let task = Task { @MainActor [weak self] in + let task = Task { [weak self] in guard let self else { return } await self.loadChildren(for: node, at: nodePath) } @@ -709,7 +709,7 @@ final class FileExplorerStore: ObservableObject { child.isLoading = true objectWillChange.send() let childPath = child.path - let childTask = Task { @MainActor [weak self] in + let childTask = Task { [weak self] in guard let self else { return } await self.loadChildren(for: child, at: childPath) } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 3d72ee9d3444..d7409f83168f 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -686,6 +686,25 @@ struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable { focusedReadIndicatorSurfaceId: nil ) + static func empty( + forTabId tabId: UUID, + focusedReadIndicatorSurfaceId: UUID? = nil + ) -> TerminalNotificationWorkspaceSnapshot { + var visibleSurfaceIds = Set() + if let focusedReadIndicatorSurfaceId { + visibleSurfaceIds.insert(focusedReadIndicatorSurfaceId) + } + return TerminalNotificationWorkspaceSnapshot( + tabId: tabId, + unreadCount: 0, + hasRead: false, + unreadSurfaceIds: [], + visibleSurfaceIds: visibleSurfaceIds, + latestNotification: nil, + focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId + ) + } + var hasUnreadNotifications: Bool { unreadCount > 0 } @@ -762,7 +781,9 @@ final class TerminalNotificationStore: ObservableObject { private struct NotificationIndexes { var unreadCount = 0 var unreadCountByTabId: [UUID: Int] = [:] + var unreadSurfaceIdsByTabId: [UUID: Set] = [:] var unreadByTabSurface = Set() + var hasReadByTabId = Set() var latestUnreadByTabId: [UUID: TerminalNotification] = [:] var latestByTabId: [UUID: TerminalNotification] = [:] } @@ -987,40 +1008,30 @@ final class TerminalNotificationStore: ObservableObject { } func workspaceSnapshot(forTabId tabId: UUID) -> TerminalNotificationWorkspaceSnapshot { - Self.workspaceSnapshot( + workspaceSnapshot( forTabId: tabId, - notifications: notifications, focusedReadIndicatorByTabId: focusedReadIndicatorByTabId ) } - private static func workspaceSnapshot( + private func workspaceSnapshot( forTabId tabId: UUID, - notifications: [TerminalNotification], focusedReadIndicatorByTabId: [UUID: UUID] ) -> TerminalNotificationWorkspaceSnapshot { - var unreadCount = 0 - var hasRead = false - var unreadSurfaceIds = Set() - var visibleSurfaceIds = Set() let focusedReadIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId] - var latestNotification: TerminalNotification? - - for notification in notifications where notification.tabId == tabId { - if latestNotification == nil { - latestNotification = notification - } - if notification.isRead { - hasRead = true - } else if let surfaceId = notification.surfaceId { - unreadCount += 1 - unreadSurfaceIds.insert(surfaceId) - visibleSurfaceIds.insert(surfaceId) - } else { - unreadCount += 1 - } + let unreadCount = indexes.unreadCountByTabId[tabId] ?? 0 + let hasRead = indexes.hasReadByTabId.contains(tabId) + let unreadSurfaceIds = indexes.unreadSurfaceIdsByTabId[tabId] ?? [] + let latestNotification = indexes.latestByTabId[tabId] + + guard unreadCount > 0 || hasRead || latestNotification != nil else { + return .empty( + forTabId: tabId, + focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId + ) } + var visibleSurfaceIds = unreadSurfaceIds if let surfaceId = focusedReadIndicatorSurfaceId { visibleSurfaceIds.insert(surfaceId) } @@ -1042,10 +1053,12 @@ final class TerminalNotificationStore: ObservableObject { func workspaceSnapshotPublisher(forTabId tabId: UUID) -> AnyPublisher { Publishers.CombineLatest($notifications, $focusedReadIndicatorByTabId) - .map { notifications, focusedReadIndicatorByTabId in - Self.workspaceSnapshot( + .map { [weak self] _, focusedReadIndicatorByTabId in + guard let self else { + return .empty(forTabId: tabId) + } + return self.workspaceSnapshot( forTabId: tabId, - notifications: notifications, focusedReadIndicatorByTabId: focusedReadIndicatorByTabId ) } @@ -1494,12 +1507,18 @@ final class TerminalNotificationStore: ObservableObject { if indexes.latestByTabId[notification.tabId] == nil { indexes.latestByTabId[notification.tabId] = notification } - guard !notification.isRead else { continue } + if notification.isRead { + indexes.hasReadByTabId.insert(notification.tabId) + continue + } indexes.unreadCount += 1 indexes.unreadCountByTabId[notification.tabId, default: 0] += 1 indexes.unreadByTabSurface.insert( TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) ) + if let surfaceId = notification.surfaceId { + indexes.unreadSurfaceIdsByTabId[notification.tabId, default: []].insert(surfaceId) + } if indexes.latestUnreadByTabId[notification.tabId] == nil { indexes.latestUnreadByTabId[notification.tabId] = notification } From 728447cfb10737ce72e211fbaf4d3210930e5533 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 18 Apr 2026 17:49:36 -0700 Subject: [PATCH 9/9] Avoid shared store default argument warnings --- Sources/TerminalNotificationStore.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index d7409f83168f..5afd6bea28d4 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -732,9 +732,13 @@ final class WorkspaceNotificationPresentationStore: ObservableObject { private var cancellable: AnyCancellable? + convenience init(tabId: UUID) { + self.init(tabId: tabId, notificationStore: .shared) + } + init( tabId: UUID, - notificationStore: TerminalNotificationStore = .shared + notificationStore: TerminalNotificationStore ) { self.presentation = notificationStore.presentation(forTabId: tabId) cancellable = notificationStore.presentationPublisher(forTabId: tabId) @@ -750,7 +754,11 @@ final class WorkspaceNotificationPresentationStoreCache: ObservableObject { private let notificationStore: TerminalNotificationStore private var stores: [UUID: WorkspaceNotificationPresentationStore] = [:] - init(notificationStore: TerminalNotificationStore = .shared) { + convenience init() { + self.init(notificationStore: .shared) + } + + init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore }