diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index d4adea20a0ea..2cbe48393548 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1348,6 +1348,7 @@ struct SessionPanelSnapshot: Codable, Sendable { var isPinned: Bool var isManuallyUnread: Bool var hasUnreadIndicator: Bool? = nil + var restoredUnreadContributesToWorkspace: Bool? = nil var notifications: [SessionNotificationSnapshot]? = nil var gitBranch: SessionGitBranchSnapshot? var listeningPorts: [Int] diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f1411a6d9b5a..2c5f7a2c9262 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -9017,6 +9017,7 @@ extension TabManager { hasher.combine(panelId) hasher.combine(workspace.manualUnreadPanelIds.contains(panelId)) hasher.combine(workspace.restoredUnreadPanelIds.contains(panelId)) + hasher.combine(workspace.restoredUnreadIndicatorContributesToWorkspace(panelId: panelId)) hasher.combine( notificationStore?.hasVisibleNotificationIndicator( forTabId: workspace.id, diff --git a/Sources/Workspace+DetachedSurfaceTransfer.swift b/Sources/Workspace+DetachedSurfaceTransfer.swift index e29c6997dc5f..eaa2c2e3406f 100644 --- a/Sources/Workspace+DetachedSurfaceTransfer.swift +++ b/Sources/Workspace+DetachedSurfaceTransfer.swift @@ -24,7 +24,7 @@ extension Workspace { let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool - let restoredUnread: Bool + let restoredUnreadIndicator: RestoredPanelUnreadIndicator? let restorableAgent: SessionRestorableAgentSnapshot? let restorableAgentResumeState: RestoredAgentResumeState? let resumeBinding: SurfaceResumeBindingSnapshot? @@ -50,7 +50,7 @@ extension Workspace { cachedTitle: cachedTitle, customTitle: customTitle, manuallyUnread: manuallyUnread, - restoredUnread: restoredUnread, + restoredUnreadIndicator: restoredUnreadIndicator, restorableAgent: restorableAgent, restorableAgentResumeState: restorableAgentResumeState, resumeBinding: resumeBinding, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8b4c3aae493e..38cc5507426e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -452,9 +452,19 @@ extension Workspace { let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let panelNotificationSnapshots = notificationSnapshots(surfaceId: panelId) + let panelHasUnreadNotification = hasUnreadNotification(panelId: panelId) let hasUnreadIndicator = restoredUnreadPanelIds.contains(panelId) || - hasUnreadNotification(panelId: panelId) + hasVisibleNotificationIndicator(panelId: panelId) + let restoredUnreadContributesToWorkspace: Bool? = { + if let restoredIndicator = restoredUnreadPanelIndicators[panelId] { + return restoredIndicator.contributesToWorkspaceUnread + } + if hasUnreadIndicator && !panelHasUnreadNotification { + return false + } + return nil + }() let branchSnapshot = panelGitBranches[panelId].map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } @@ -581,6 +591,7 @@ extension Workspace { isPinned: isPinned, isManuallyUnread: isManuallyUnread, hasUnreadIndicator: hasUnreadIndicator, + restoredUnreadContributesToWorkspace: restoredUnreadContributesToWorkspace, notifications: panelNotificationSnapshots.isEmpty ? nil : panelNotificationSnapshots, gitBranch: branchSnapshot, listeningPorts: listeningPorts, @@ -1094,9 +1105,14 @@ extension Workspace { } else { clearManualUnread(panelId: panelId) } - if snapshot.hasUnreadIndicator == true, - snapshot.notifications?.contains(where: { !$0.isRead }) != true { - restorePanelUnreadIndicator(panelId) + let hasUnreadPanelNotification = snapshot.notifications?.contains(where: { !$0.isRead }) == true + if snapshot.hasUnreadIndicator == true, !hasUnreadPanelNotification { + let contributesToWorkspaceUnread = snapshot.restoredUnreadContributesToWorkspace + ?? (snapshot.notifications?.isEmpty ?? true) + restorePanelUnreadIndicator( + panelId, + contributesToWorkspaceUnread: contributesToWorkspaceUnread + ) } else { clearRestoredUnreadIndicator(panelId: panelId) } @@ -9009,6 +9025,19 @@ final class Workspace: Identifiable, ObservableObject { case terminalFirstResponder } + nonisolated enum RestoredPanelUnreadIndicator: Equatable, Sendable { + case visualOnly + case workspaceUnread + + init(contributesToWorkspaceUnread: Bool) { + self = contributesToWorkspaceUnread ? .workspaceUnread : .visualOnly + } + + var contributesToWorkspaceUnread: Bool { + self == .workspaceUnread + } + } + /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @@ -9020,7 +9049,15 @@ final class Workspace: Identifiable, ObservableObject { syncPanelDerivedWorkspaceUnread() } } - @Published private(set) var restoredUnreadPanelIds: Set = [] + @Published private var restoredUnreadPanelIndicators: [UUID: RestoredPanelUnreadIndicator] = [:] { + didSet { + guard restoredUnreadPanelIndicators != oldValue else { return } + syncPanelDerivedWorkspaceUnread() + } + } + var restoredUnreadPanelIds: Set { + Set(restoredUnreadPanelIndicators.keys) + } @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? @@ -10085,10 +10122,14 @@ final class Workspace: Identifiable, ObservableObject { } } - private func hasUnreadNotification(panelId: UUID) -> Bool { + private func hasVisibleNotificationIndicator(panelId: UUID) -> Bool { AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false } + private func hasUnreadNotification(panelId: UUID) -> Bool { + AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false + } + private func attentionPersistentState() -> WorkspaceAttentionPersistentState { let notificationStore = AppDelegate.shared?.notificationStore let unreadPanelIDs = Set( @@ -10118,7 +10159,7 @@ final class Workspace: Identifiable, ObservableObject { guard let tabId = surfaceIdFromPanelId(panelId) else { return } let notificationStore = AppDelegate.shared?.notificationStore let shouldShowUnread = Self.shouldShowUnreadIndicator( - hasUnreadNotification: hasUnreadNotification(panelId: panelId), + hasUnreadNotification: hasVisibleNotificationIndicator(panelId: panelId), hasPanelUnreadIndicator: manualUnreadPanelIds.contains(panelId) || restoredUnreadPanelIds.contains(panelId), isWorkspaceManuallyUnread: notificationStore?.hasManualUnread(forTabId: id) ?? false, isWorkspaceManualUnreadRepresentative: representativePanelIdForWorkspaceManualUnread() == panelId @@ -10136,7 +10177,11 @@ final class Workspace: Identifiable, ObservableObject { } func syncPanelDerivedWorkspaceUnread() { - AppDelegate.shared?.notificationStore?.setPanelDerivedUnread(!manualUnreadPanelIds.isEmpty, forTabId: id) + AppDelegate.shared?.notificationStore?.setPanelDerivedUnread( + !manualUnreadPanelIds.isEmpty || + restoredUnreadPanelIndicators.values.contains { $0.contributesToWorkspaceUnread }, + forTabId: id + ) } private func normalizePinnedTabs(in paneId: PaneID) { @@ -10302,7 +10347,7 @@ final class Workspace: Identifiable, ObservableObject { func markPanelUnread(_ panelId: UUID) { guard panels[panelId] != nil else { return } - let didClearRestored = restoredUnreadPanelIds.remove(panelId) != nil + let didClearRestored = restoredUnreadPanelIndicators.removeValue(forKey: panelId) != nil let didInsertManual = manualUnreadPanelIds.insert(panelId).inserted guard didInsertManual || didClearRestored else { return } manualUnreadMarkedAt[panelId] = Date() @@ -10358,7 +10403,7 @@ final class Workspace: Identifiable, ObservableObject { .union(restoredUnreadPanelIds) guard !affectedPanelIds.isEmpty else { return false } manualUnreadPanelIds.removeAll() - restoredUnreadPanelIds.removeAll() + restoredUnreadPanelIndicators.removeAll() manualUnreadMarkedAt.removeAll() for panelId in affectedPanelIds { syncUnreadBadgeStateForPanel(panelId) @@ -10372,9 +10417,16 @@ final class Workspace: Identifiable, ObservableObject { return didRemoveUnread } - func restorePanelUnreadIndicator(_ panelId: UUID) { + func restorePanelUnreadIndicator( + _ panelId: UUID, + contributesToWorkspaceUnread: Bool = true + ) { guard panels[panelId] != nil else { return } - guard restoredUnreadPanelIds.insert(panelId).inserted else { return } + let nextIndicator = RestoredPanelUnreadIndicator( + contributesToWorkspaceUnread: contributesToWorkspaceUnread + ) + guard restoredUnreadPanelIndicators[panelId] != nextIndicator else { return } + restoredUnreadPanelIndicators[panelId] = nextIndicator syncUnreadBadgeStateForPanel(panelId) } @@ -10388,8 +10440,12 @@ final class Workspace: Identifiable, ObservableObject { restoredUnreadPanelIds.contains(panelId) } + func restoredUnreadIndicatorContributesToWorkspace(panelId: UUID) -> Bool? { + restoredUnreadPanelIndicators[panelId]?.contributesToWorkspaceUnread + } + private func clearRestoredUnreadIndicatorState(panelId: UUID) -> Bool { - restoredUnreadPanelIds.remove(panelId) != nil + restoredUnreadPanelIndicators.removeValue(forKey: panelId) != nil } static func shouldShowUnreadIndicator( @@ -10874,7 +10930,7 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) } pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } - restoredUnreadPanelIds = restoredUnreadPanelIds.filter { validSurfaceIds.contains($0) } + restoredUnreadPanelIndicators = restoredUnreadPanelIndicators.filter { validSurfaceIds.contains($0.key) } panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } @@ -13583,10 +13639,10 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } - if detached.restoredUnread { - restoredUnreadPanelIds.insert(detached.panelId) + if let restoredUnreadIndicator = detached.restoredUnreadIndicator { + restoredUnreadPanelIndicators[detached.panelId] = restoredUnreadIndicator } else { - restoredUnreadPanelIds.remove(detached.panelId) + restoredUnreadPanelIndicators.removeValue(forKey: detached.panelId) } guard let newTabId = bonsplitController.createTab( @@ -13610,7 +13666,7 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) manualUnreadPanelIds.remove(detached.panelId) - restoredUnreadPanelIds.remove(detached.panelId) + restoredUnreadPanelIndicators.removeValue(forKey: detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) #if DEBUG @@ -16121,7 +16177,7 @@ extension Workspace: BonsplitDelegate { cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId), - restoredUnread: restoredUnreadPanelIds.contains(panelId), + restoredUnreadIndicator: restoredUnreadPanelIndicators[panelId], restorableAgent: restorableAgent, restorableAgentResumeState: restorableAgentResumeState, resumeBinding: resumeBinding, diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 864fd7c9c4ea..3bc5f0096f58 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -134,6 +134,73 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 0) } + func testRestoredPanelUnreadIndicatorMarksWorkspaceUnreadForSidebar() throws { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let store = TerminalNotificationStore.shared + + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + source.restorePanelUnreadIndicator(sourcePanelId) + + let snapshot = source.sessionSnapshot(includeScrollback: false) + let sourcePanelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == sourcePanelId }) + XCTAssertEqual(sourcePanelSnapshot.hasUnreadIndicator, true) + XCTAssertNil(sourcePanelSnapshot.notifications) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + XCTAssertTrue(restored.hasRestoredUnreadIndicator(panelId: restoredPanelId)) + XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: restored.id)) + XCTAssertEqual(store.unreadCount(forTabId: restored.id), 1) + } + + func testLegacyRestoredPanelUnreadIndicatorMarksWorkspaceUnreadForSidebar() throws { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let store = TerminalNotificationStore.shared + + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let source = Workspace() + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + source.restorePanelUnreadIndicator(sourcePanelId) + + var snapshot = source.sessionSnapshot(includeScrollback: false) + let sourcePanelIndex = try XCTUnwrap(snapshot.panels.firstIndex { $0.id == sourcePanelId }) + snapshot.panels[sourcePanelIndex].restoredUnreadContributesToWorkspace = nil + XCTAssertEqual(snapshot.panels[sourcePanelIndex].hasUnreadIndicator, true) + XCTAssertNil(snapshot.panels[sourcePanelIndex].restoredUnreadContributesToWorkspace) + XCTAssertNil(snapshot.panels[sourcePanelIndex].notifications) + + store.replaceNotificationsForTesting([]) + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + XCTAssertTrue(restored.hasRestoredUnreadIndicator(panelId: restoredPanelId)) + XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: restored.id)) + XCTAssertEqual(store.unreadCount(forTabId: restored.id), 1) + } + func testRestoredUnreadClearsWhenWorkspaceIsExplicitlySelected() throws { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() @@ -1241,6 +1308,69 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertEqual(store.unreadCount(forTabId: restored.id), 0) } + func testSessionRestorePreservesFocusedReadIndicatorWithReadNotificationsAsVisualOnly() throws { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let store = TerminalNotificationStore.shared + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let workspace = Workspace() + let panelId = try XCTUnwrap(workspace.focusedPanelId) + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: workspace.id, + surfaceId: panelId, + title: "Read", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + isRead: true + ), + ]) + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) + XCTAssertEqual(panelSnapshot.hasUnreadIndicator, true) + XCTAssertEqual(panelSnapshot.restoredUnreadContributesToWorkspace, false) + XCTAssertEqual(panelSnapshot.notifications?.count, 1) + XCTAssertEqual(panelSnapshot.notifications?.first?.isRead, true) + + store.replaceNotificationsForTesting([]) + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredTabId = try XCTUnwrap(restored.surfaceIdFromPanelId(restoredPanelId)) + XCTAssertTrue(restored.hasRestoredUnreadIndicator(panelId: restoredPanelId)) + XCTAssertTrue(restored.bonsplitController.tab(restoredTabId)?.showsNotificationBadge ?? false) + XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: restored.id)) + XCTAssertEqual(store.unreadCount(forTabId: restored.id), 0) + + var legacySnapshot = snapshot + let legacyPanelIndex = try XCTUnwrap(legacySnapshot.panels.firstIndex { $0.id == panelId }) + legacySnapshot.panels[legacyPanelIndex].restoredUnreadContributesToWorkspace = nil + + store.replaceNotificationsForTesting([]) + let legacyRestored = Workspace() + legacyRestored.restoreSessionSnapshot(legacySnapshot) + + let legacyRestoredPanelId = try XCTUnwrap(legacyRestored.focusedPanelId) + let legacyRestoredTabId = try XCTUnwrap(legacyRestored.surfaceIdFromPanelId(legacyRestoredPanelId)) + XCTAssertTrue(legacyRestored.hasRestoredUnreadIndicator(panelId: legacyRestoredPanelId)) + XCTAssertTrue(legacyRestored.bonsplitController.tab(legacyRestoredTabId)?.showsNotificationBadge ?? false) + XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: legacyRestored.id)) + XCTAssertEqual(store.unreadCount(forTabId: legacyRestored.id), 0) + } + func testSessionRestorePreservesWorkspaceManualUnreadIndicator() throws { let appDelegate = AppDelegate.shared ?? AppDelegate() let store = TerminalNotificationStore.shared @@ -1437,6 +1567,13 @@ final class WorkspaceManualUnreadTests: XCTestCase { resetUnreadState() workspace.restorePanelUnreadIndicator(panelId) XCTAssertNotEqual(cleanFingerprint, manager.sessionAutosaveFingerprint()) + + resetUnreadState() + workspace.restorePanelUnreadIndicator(panelId, contributesToWorkspaceUnread: false) + let visualOnlyRestoredFingerprint = manager.sessionAutosaveFingerprint() + XCTAssertNotEqual(cleanFingerprint, visualOnlyRestoredFingerprint) + workspace.restorePanelUnreadIndicator(panelId, contributesToWorkspaceUnread: true) + XCTAssertNotEqual(visualOnlyRestoredFingerprint, manager.sessionAutosaveFingerprint()) } func testShouldShowUnreadIndicatorWhenNotificationIsUnread() { diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 8d1ab009f1d4..0e0f2eb8c1f8 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2708,7 +2708,7 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { cachedTitle: nil, customTitle: nil, manuallyUnread: false, - restoredUnread: false, + restoredUnreadIndicator: nil, restorableAgent: nil, restorableAgentResumeState: nil, resumeBinding: resumeBinding,