Skip to content
Closed
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
177 changes: 114 additions & 63 deletions Sources/ContentView.swift

Large diffs are not rendered by default.

186 changes: 185 additions & 1 deletion Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Foundation
import UserNotifications
import Bonsplit
Expand Down Expand Up @@ -666,6 +667,118 @@ struct TerminalNotification: Identifiable, Hashable {
var isRead: Bool
}

struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable {
let tabId: UUID?
let unreadCount: Int
let hasRead: Bool
let unreadSurfaceIds: Set<UUID>
let visibleSurfaceIds: Set<UUID>
let latestNotification: TerminalNotification?
let focusedReadIndicatorSurfaceId: UUID?

static let empty = TerminalNotificationWorkspaceSnapshot(
tabId: nil,
unreadCount: 0,
hasRead: false,
unreadSurfaceIds: [],
visibleSurfaceIds: [],
latestNotification: nil,
focusedReadIndicatorSurfaceId: nil
)

static func empty(
forTabId tabId: UUID,
focusedReadIndicatorSurfaceId: UUID? = nil
) -> TerminalNotificationWorkspaceSnapshot {
var visibleSurfaceIds = Set<UUID>()
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
}

var hasReadNotifications: Bool {
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)
}
Comment on lines +675 to +724

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve workspace-level indicators in the snapshot.

Line 695 now hard-codes surfaceId == nil to false, and Lines 990-991 only record non-nil surface IDs. The existing store API treats (tabId, nil) as a valid unread key, so this presentation model can no longer represent workspace-scoped notifications at all. Any caller migrated from hasVisibleNotificationIndicator(forTabId:surfaceId:) to this snapshot will silently lose that indicator.

🧩 Proposed fix
 struct TerminalNotificationWorkspaceSnapshot: Equatable, Sendable {
     let tabId: UUID?
     let unreadCount: Int
     let hasRead: Bool
+    let hasVisibleWorkspaceIndicator: Bool
     let visibleSurfaceIds: Set<UUID>
     let latestNotification: TerminalNotification?
     let focusedReadIndicatorSurfaceId: UUID?

     static let empty = TerminalNotificationWorkspaceSnapshot(
         tabId: nil,
         unreadCount: 0,
         hasRead: false,
+        hasVisibleWorkspaceIndicator: false,
         visibleSurfaceIds: [],
         latestNotification: nil,
         focusedReadIndicatorSurfaceId: nil
     )
@@
     func hasVisibleNotificationIndicator(surfaceId: UUID?) -> Bool {
-        guard let surfaceId else { return false }
+        guard let surfaceId else { return hasVisibleWorkspaceIndicator }
         return visibleSurfaceIds.contains(surfaceId)
     }
 }
@@
     func workspaceSnapshot(forTabId tabId: UUID) -> TerminalNotificationWorkspaceSnapshot {
         var hasRead = false
+        var hasVisibleWorkspaceIndicator = false
         var visibleSurfaceIds = Set<UUID>()
         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)
+            } else {
+                hasVisibleWorkspaceIndicator = true
             }
         }
@@
         return TerminalNotificationWorkspaceSnapshot(
             tabId: tabId,
             unreadCount: unreadCount(forTabId: tabId),
             hasRead: hasRead,
+            hasVisibleWorkspaceIndicator: hasVisibleWorkspaceIndicator,
             visibleSurfaceIds: visibleSurfaceIds,
             latestNotification: latestNotification(forTabId: tabId),
             focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId
         )
     }

Also applies to: 987-997

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalNotificationStore.swift` around lines 674 - 698, The snapshot
currently cannot represent workspace-scoped indicators because visibleSurfaceIds
is a Set<UUID> (can't hold nil) and hasVisibleNotificationIndicator(surfaceId:)
returns false for nil; add a dedicated Bool property (e.g.,
workspaceVisibleIndicator) to TerminalNotificationWorkspaceSnapshot, include it
in the static empty initializer, set or clear it wherever visible surface IDs
are recorded (the code paths around the previous lines ~987-997 that only record
non-nil IDs), and change hasVisibleNotificationIndicator(surfaceId:) to return
workspaceVisibleIndicator when surfaceId is nil, otherwise check
visibleSurfaceIds.contains(surfaceId).

}

typealias WorkspaceNotificationPresentation = TerminalNotificationWorkspaceSnapshot

@MainActor
final class WorkspaceNotificationPresentationStore: ObservableObject {
@Published private(set) var presentation: WorkspaceNotificationPresentation

private var cancellable: AnyCancellable?

convenience init(tabId: UUID) {
self.init(tabId: tabId, notificationStore: .shared)
}

init(
tabId: UUID,
notificationStore: TerminalNotificationStore
) {
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] = [:]

convenience init() {
self.init(notificationStore: .shared)
}

init(notificationStore: TerminalNotificationStore) {
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<UUID>) {
stores = stores.filter { tabIds.contains($0.key) }
}
}

@MainActor
final class TerminalNotificationStore: ObservableObject {
private struct TabSurfaceKey: Hashable {
Expand All @@ -676,7 +789,9 @@ final class TerminalNotificationStore: ObservableObject {
private struct NotificationIndexes {
var unreadCount = 0
var unreadCountByTabId: [UUID: Int] = [:]
var unreadSurfaceIdsByTabId: [UUID: Set<UUID>] = [:]
var unreadByTabSurface = Set<TabSurfaceKey>()
var hasReadByTabId = Set<UUID>()
var latestUnreadByTabId: [UUID: TerminalNotification] = [:]
var latestByTabId: [UUID: TerminalNotification] = [:]
}
Expand Down Expand Up @@ -896,6 +1011,69 @@ final class TerminalNotificationStore: ObservableObject {
focusedReadIndicatorByTabId[tabId]
}

func presentation(forTabId tabId: UUID) -> WorkspaceNotificationPresentation {
workspaceSnapshot(forTabId: tabId)
}

Comment thread
cursor[bot] marked this conversation as resolved.
func workspaceSnapshot(forTabId tabId: UUID) -> TerminalNotificationWorkspaceSnapshot {
workspaceSnapshot(
forTabId: tabId,
focusedReadIndicatorByTabId: focusedReadIndicatorByTabId
)
}

private func workspaceSnapshot(
forTabId tabId: UUID,
focusedReadIndicatorByTabId: [UUID: UUID]
) -> TerminalNotificationWorkspaceSnapshot {
let focusedReadIndicatorSurfaceId = focusedReadIndicatorByTabId[tabId]
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)
}

return TerminalNotificationWorkspaceSnapshot(
tabId: tabId,
unreadCount: unreadCount,
hasRead: hasRead,
unreadSurfaceIds: unreadSurfaceIds,
visibleSurfaceIds: visibleSurfaceIds,
latestNotification: latestNotification,
focusedReadIndicatorSurfaceId: focusedReadIndicatorSurfaceId
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func presentationPublisher(forTabId tabId: UUID) -> AnyPublisher<WorkspaceNotificationPresentation, Never> {
workspaceSnapshotPublisher(forTabId: tabId)
}

func workspaceSnapshotPublisher(forTabId tabId: UUID) -> AnyPublisher<TerminalNotificationWorkspaceSnapshot, Never> {
Publishers.CombineLatest($notifications, $focusedReadIndicatorByTabId)
.map { [weak self] _, focusedReadIndicatorByTabId in
guard let self else {
return .empty(forTabId: tabId)
}
return self.workspaceSnapshot(
forTabId: tabId,
focusedReadIndicatorByTabId: focusedReadIndicatorByTabId
)
}
.removeDuplicates()
.eraseToAnyPublisher()
}
Comment thread
cursor[bot] marked this conversation as resolved.

func addNotification(
tabId: UUID,
surfaceId: UUID?,
Expand Down Expand Up @@ -1337,12 +1515,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
}
Expand Down
40 changes: 23 additions & 17 deletions Sources/WorkspaceContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }

Expand All @@ -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"
}
Expand Down Expand Up @@ -474,7 +483,7 @@ struct WorkspaceContentView: View {

private static func tmuxWorkspacePaneRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
visibleNotificationSurfaceIds: Set<UUID>,
layoutSnapshot: LayoutSnapshot?,
includeContainerOffset: Bool,
trimMode: TmuxWorkspacePaneOverlayTrimMode
Expand All @@ -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 }
Expand Down Expand Up @@ -555,12 +561,12 @@ struct WorkspaceContentView: View {

static func tmuxWorkspacePaneUnreadRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
visibleNotificationSurfaceIds: Set<UUID>,
layoutSnapshot: LayoutSnapshot?
) -> [CGRect] {
tmuxWorkspacePaneRects(
workspace: workspace,
notificationStore: notificationStore,
visibleNotificationSurfaceIds: visibleNotificationSurfaceIds,
layoutSnapshot: layoutSnapshot,
includeContainerOffset: false,
trimMode: .workspaceLocal
Expand All @@ -569,12 +575,12 @@ struct WorkspaceContentView: View {

static func tmuxWorkspacePaneWindowUnreadRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
visibleNotificationSurfaceIds: Set<UUID>,
layoutSnapshot: LayoutSnapshot?
) -> [CGRect] {
tmuxWorkspacePaneRects(
workspace: workspace,
notificationStore: notificationStore,
visibleNotificationSurfaceIds: visibleNotificationSurfaceIds,
layoutSnapshot: layoutSnapshot,
includeContainerOffset: true,
trimMode: .windowContent
Expand Down
6 changes: 5 additions & 1 deletion cmuxTests/FileExplorerStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading