Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions Sources/Workspace+DetachedSurfaceTransfer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -50,7 +50,7 @@ extension Workspace {
cachedTitle: cachedTitle,
customTitle: customTitle,
manuallyUnread: manuallyUnread,
restoredUnread: restoredUnread,
restoredUnreadIndicator: restoredUnreadIndicator,
restorableAgent: restorableAgent,
restorableAgentResumeState: restorableAgentResumeState,
resumeBinding: resumeBinding,
Expand Down
94 changes: 75 additions & 19 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -581,6 +591,7 @@ extension Workspace {
isPinned: isPinned,
isManuallyUnread: isManuallyUnread,
hasUnreadIndicator: hasUnreadIndicator,
restoredUnreadContributesToWorkspace: restoredUnreadContributesToWorkspace,
notifications: panelNotificationSnapshots.isEmpty ? nil : panelNotificationSnapshots,
gitBranch: branchSnapshot,
listeningPorts: listeningPorts,
Expand Down Expand Up @@ -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
)
Comment on lines +1112 to +1115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sessions saved before this PR have restoredUnreadContributesToWorkspace = nil (the field didn't exist). Because nil == true evaluates to false, those restored panels get the visual unread indicator but never contribute to the workspace sidebar badge — the exact bug this PR is fixing. On first boot after upgrading, every user with a pre-existing unread panel in their session will silently skip the badge. The fix only kicks in after they clear the unread and go through another save/restore cycle.

Changing to != false treats nil (legacy, no recorded preference) as "should contribute", which is the correct migration default: every panel that was unread when the session was saved should light up the sidebar badge after restore.

Suggested change
restorePanelUnreadIndicator(
panelId,
contributesToWorkspaceUnread: snapshot.restoredUnreadContributesToWorkspace == true
)
restorePanelUnreadIndicator(
panelId,
contributesToWorkspaceUnread: snapshot.restoredUnreadContributesToWorkspace != false
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with explicit restoredUnreadContributesToWorkspace persistence and a more precise legacy fallback: nil plus no notification rows contributes to workspace unread, while nil plus read-only notification rows remains visual-only. Covered by testLegacyRestoredPanelUnreadIndicatorMarksWorkspaceUnreadForSidebar and testSessionRestorePreservesFocusedReadIndicatorWithReadNotificationsAsVisualOnly.

— Claude Code

Comment thread
cursor[bot] marked this conversation as resolved.
} else {
clearRestoredUnreadIndicator(panelId: panelId)
}
Expand Down Expand Up @@ -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] = [:]
Expand All @@ -9020,7 +9049,15 @@ final class Workspace: Identifiable, ObservableObject {
syncPanelDerivedWorkspaceUnread()
}
}
@Published private(set) var restoredUnreadPanelIds: Set<UUID> = []
@Published private var restoredUnreadPanelIndicators: [UUID: RestoredPanelUnreadIndicator] = [:] {
didSet {
guard restoredUnreadPanelIndicators != oldValue else { return }
syncPanelDerivedWorkspaceUnread()
}
}
var restoredUnreadPanelIds: Set<UUID> {
Set(restoredUnreadPanelIndicators.keys)
}
@Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot?
@Published private(set) var tmuxWorkspaceFlashPanelId: UUID?
@Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason?
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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(
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading