native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Notifications UX: Jump to Latest Unread + per-tab / per-surface dots + tap deeplinks (#266)

* Add Jump to Latest Unread + per-surface notification dots

Adds ⌘⇧U to jump to the newest unread notification across worktrees,
selecting the worktree + focusing the source surface and marking only
that notification as read; the menu item disables when nothing is
unread. Surfaces now render an orange dot overlay while they have
unread notifications, mirroring the sidebar indicator. System
notifications carry a deeplink payload so tapping one routes through
the existing deeplink handler to land on the exact tab + surface that
posted it.

Closes #244.

* Show unread notification dot on tabs

* Polish notification UX + tighten concurrency and logging

- Tab notification dot now lives in the close-button slot: visible
when the tab is idle and has unread notifications, replaced by the
close X on hover.
- `SystemNotificationClient` delegate methods now run on the main
actor directly, so accessing `onDeeplinkTap` no longer needs a
bridging hop. Added `SupaLogger` breadcrumbs for malformed deeplink
payloads, unresolved surface deeplinks, and stale-worktree jumps.
- `latestUnreadNotificationLocation` now walks each worktree's unread
list newest-first until it finds a focusable surface, so a closed
surface on the newest notification no longer hides older focusable
ones. Logs once when every unread points at a closed surface.
- Collapsed duplicate `tabID(forSurfaceID:)` / `tabId(containing:)`
into a single `tabID(containing:)` on `WorktreeTerminalState`.
- Dropped the `createdAt = Date()` default on
`WorktreeTerminalNotification.init` so production code must pass the
injected clock; tests use `.distantPast`.
- Surface dot now animates via an always-mounted `.opacity`/`.animation`
pair rather than a transition without a driver.
- Added `WorktreeTerminalManagerTests` coverage for cross-worktree
ordering, closed-surface fallback, tab-level unread aggregation, and
`markNotificationRead` targeting a single id.
- Tightened the happy-path jump test to assert exact focus command.

* Tighten notification logging + close tab-dot test gap

- `urlOrWarn` now carries `worktreeID` / `surfaceID` into its warning
so diagnostics correlate to the originating surface.
- Log a debug breadcrumb when `latestUnreadNotificationLocation` skips
a closed surface in favour of an older focusable one, preserving the
"which notification was chosen" trace.
- `jumpToLatestUnread` logs a debug line when invoked with no unread,
so the two no-op branches (menu gated vs stale worktree) are
distinguishable in logs.
- New test covers the cross-worktree tie-break path: worktree A's
newest unread is orphaned, A's older focusable is older than
worktree B's only focusable, B wins.
- `hasUnseenNotificationForTabIDWalksSplitTree` now asserts the first
leaf also lights the tab (previously only the second leaf was
exercised), and drops the `_ = firstLeaf` warning-silencer.

authored by

Stefano Bertagno and committed by
GitHub
072ad1e7 57e620a7

+677 -49
+8
SupacodeSettingsShared/App/AppShortcuts.swift
··· 13 13 case selectWorktree(Int) 14 14 case openWorktree, revealInFinder, openRepository, openPullRequest, copyPath 15 15 case runScript, stopRunScript 16 + case jumpToLatestUnread 16 17 17 18 // Stable string key for JSON dictionary persistence. 18 19 public var codingKey: CodingKey { ··· 54 55 case .copyPath: "copyPath" 55 56 case .runScript: "runScript" 56 57 case .stopRunScript: "stopRunScript" 58 + case .jumpToLatestUnread: "jumpToLatestUnread" 57 59 } 58 60 } 59 61 ··· 79 81 "copyPath": .copyPath, 80 82 "runScript": .runScript, 81 83 "stopRunScript": .stopRunScript, 84 + "jumpToLatestUnread": .jumpToLatestUnread, 82 85 ] 83 86 84 87 private init?(stableKey: String) { ··· 116 119 case .copyPath: "Copy Path" 117 120 case .runScript: "Run Script" 118 121 case .stopRunScript: "Stop Run Script" 122 + case .jumpToLatestUnread: "Jump to Latest Unread" 119 123 } 120 124 } 121 125 } ··· 321 325 public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) 322 326 public static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) 323 327 public static let stopRunScript = AppShortcut(id: .stopRunScript, key: ".", modifiers: .command) 328 + public static let jumpToLatestUnread = AppShortcut( 329 + id: .jumpToLatestUnread, key: "u", modifiers: [.command, .shift] 330 + ) 324 331 325 332 public static let worktreeSelection: [AppShortcut] = [ 326 333 selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, ··· 344 351 category: .actions, 345 352 shortcuts: [ 346 353 openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, 354 + jumpToLatestUnread, 347 355 ] 348 356 ), 349 357 ]
+42 -4
SupacodeSettingsShared/Clients/Notifications/SystemNotificationClient.swift
··· 3 3 import Foundation 4 4 import UserNotifications 5 5 6 + /// Payload key under which the system notification stores the deeplink URL 7 + /// that should be dispatched when the user taps the notification banner. 8 + private nonisolated let deeplinkUserInfoKey = "supacode.deeplink" 9 + 10 + private nonisolated let systemNotificationLogger = SupaLogger("SystemNotifications") 11 + 12 + @MainActor 6 13 private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { 14 + var onDeeplinkTap: ((URL) -> Void)? 15 + 16 + // Both delegate methods run on the main actor so reading / writing 17 + // `onDeeplinkTap` is a plain isolated access with no bridging hop. The 18 + // leading `Task.yield()` satisfies the async-without-await lint and 19 + // matches the pattern used elsewhere for protocol-required async stubs. 7 20 func userNotificationCenter( 8 21 _ center: UNUserNotificationCenter, 9 22 willPresent notification: UNNotification ··· 11 24 await Task.yield() 12 25 return [.badge, .sound, .banner] 13 26 } 27 + 28 + func userNotificationCenter( 29 + _ center: UNUserNotificationCenter, 30 + didReceive response: UNNotificationResponse 31 + ) async { 32 + await Task.yield() 33 + let userInfo = response.notification.request.content.userInfo 34 + guard let raw = userInfo[deeplinkUserInfoKey] as? String else { return } 35 + guard let url = URL(string: raw) else { 36 + systemNotificationLogger.warning("Dropped notification tap: userInfo deeplink is not a valid URL: \(raw)") 37 + return 38 + } 39 + onDeeplinkTap?(url) 40 + } 14 41 } 15 42 16 43 @MainActor ··· 23 50 center.delegate = foregroundSystemNotificationDelegate 24 51 } 25 52 return center 53 + } 54 + 55 + /// Registers a handler invoked on the main actor whenever the user taps a 56 + /// delivered system notification that carries a supacode deeplink. 57 + @MainActor 58 + public func setSystemNotificationTapHandler(_ handler: @escaping @MainActor (URL) -> Void) { 59 + _ = configuredNotificationCenter() 60 + foregroundSystemNotificationDelegate.onDeeplinkTap = handler 26 61 } 27 62 28 63 public nonisolated struct SystemNotificationClient: Sendable { ··· 44 79 45 80 public var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus 46 81 public var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult 47 - public var send: @MainActor @Sendable (_ title: String, _ body: String) async -> Void 82 + public var send: @MainActor @Sendable (_ title: String, _ body: String, _ deeplinkURL: URL?) async -> Void 48 83 public var openSettings: @MainActor @Sendable () async -> Void 49 84 50 85 public init( 51 86 authorizationStatus: @escaping @MainActor @Sendable () async -> AuthorizationStatus, 52 87 requestAuthorization: @escaping @MainActor @Sendable () async -> AuthorizationRequestResult, 53 - send: @escaping @MainActor @Sendable (_ title: String, _ body: String) async -> Void, 88 + send: @escaping @MainActor @Sendable (_ title: String, _ body: String, _ deeplinkURL: URL?) async -> Void, 54 89 openSettings: @escaping @MainActor @Sendable () async -> Void 55 90 ) { 56 91 self.authorizationStatus = authorizationStatus ··· 90 125 ) 91 126 } 92 127 }, 93 - send: { title, body in 128 + send: { title, body, deeplinkURL in 94 129 let center = configuredNotificationCenter() 95 130 let content = UNMutableNotificationContent() 96 131 content.title = title 97 132 content.body = body 98 133 content.sound = .default 134 + if let deeplinkURL { 135 + content.userInfo = [deeplinkUserInfoKey: deeplinkURL.absoluteString] 136 + } 99 137 let request = UNNotificationRequest( 100 138 identifier: UUID().uuidString, 101 139 content: content, ··· 114 152 public static let testValue = SystemNotificationClient( 115 153 authorizationStatus: { .notDetermined }, 116 154 requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, 117 - send: { _, _ in }, 155 + send: { _, _, _ in }, 118 156 openSettings: {} 119 157 ) 120 158 }
+14
supacode/App/supacodeApp.swift
··· 40 40 for url in buffered { 41 41 appStore.send(.deeplinkReceived(url)) 42 42 } 43 + // Route taps on delivered system notifications through the store 44 + // so they follow the same dispatch path as URL-scheme deeplinks. 45 + setSystemNotificationTapHandler { [weak appStore] url in 46 + appStore?.send(.deeplinkReceived(url)) 47 + } 43 48 } 44 49 } 45 50 var terminalManager: WorktreeTerminalManager? ··· 218 223 }, 219 224 surfaceExistsInWorktree: { worktreeID, surfaceID in 220 225 terminalManager.surfaceExistsInWorktree(worktreeID: worktreeID, surfaceID: surfaceID) 226 + }, 227 + tabID: { worktreeID, surfaceID in 228 + terminalManager.tabID(forWorktreeID: worktreeID, surfaceID: surfaceID) 229 + }, 230 + latestUnreadNotification: { 231 + terminalManager.latestUnreadNotificationLocation() 232 + }, 233 + markNotificationRead: { worktreeID, notificationID in 234 + terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) 221 235 } 222 236 ) 223 237 values.worktreeInfoWatcher = WorktreeInfoWatcherClient(
+12 -3
supacode/Clients/Terminal/TerminalClient.swift
··· 7 7 var tabExists: @MainActor @Sendable (Worktree.ID, TerminalTabID) -> Bool 8 8 var surfaceExists: @MainActor @Sendable (Worktree.ID, TerminalTabID, UUID) -> Bool 9 9 var surfaceExistsInWorktree: @MainActor @Sendable (Worktree.ID, UUID) -> Bool 10 + var tabID: @MainActor @Sendable (Worktree.ID, UUID) -> TerminalTabID? 11 + var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? 12 + var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void 10 13 11 14 enum Command: Equatable { 12 15 case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil) ··· 37 40 } 38 41 39 42 enum Event: Equatable { 40 - case notificationReceived(worktreeID: Worktree.ID, title: String, body: String) 43 + case notificationReceived(worktreeID: Worktree.ID, surfaceID: UUID, title: String, body: String) 41 44 case notificationIndicatorChanged(count: Int) 42 45 case tabCreated(worktreeID: Worktree.ID) 43 46 case tabClosed(worktreeID: Worktree.ID) ··· 56 59 events: { fatalError("TerminalClient.events not configured") }, 57 60 tabExists: { _, _ in fatalError("TerminalClient.tabExists not configured") }, 58 61 surfaceExists: { _, _, _ in fatalError("TerminalClient.surfaceExists not configured") }, 59 - surfaceExistsInWorktree: { _, _ in fatalError("TerminalClient.surfaceExistsInWorktree not configured") } 62 + surfaceExistsInWorktree: { _, _ in fatalError("TerminalClient.surfaceExistsInWorktree not configured") }, 63 + tabID: { _, _ in fatalError("TerminalClient.tabID not configured") }, 64 + latestUnreadNotification: { fatalError("TerminalClient.latestUnreadNotification not configured") }, 65 + markNotificationRead: { _, _ in fatalError("TerminalClient.markNotificationRead not configured") } 60 66 ) 61 67 62 68 static let testValue = TerminalClient( ··· 64 70 events: { AsyncStream { $0.finish() } }, 65 71 tabExists: unimplemented("TerminalClient.tabExists", placeholder: true), 66 72 surfaceExists: unimplemented("TerminalClient.surfaceExists", placeholder: true), 67 - surfaceExistsInWorktree: unimplemented("TerminalClient.surfaceExistsInWorktree", placeholder: true) 73 + surfaceExistsInWorktree: unimplemented("TerminalClient.surfaceExistsInWorktree", placeholder: true), 74 + tabID: unimplemented("TerminalClient.tabID", placeholder: nil), 75 + latestUnreadNotification: unimplemented("TerminalClient.latestUnreadNotification", placeholder: nil), 76 + markNotificationRead: unimplemented("TerminalClient.markNotificationRead") 68 77 ) 69 78 } 70 79
+7
supacode/Commands/WorktreeCommands.swift
··· 41 41 let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) 42 42 let run = AppShortcuts.runScript.effective(from: overrides) 43 43 let stop = AppShortcuts.stopRunScript.effective(from: overrides) 44 + let jumpToLatestUnread = AppShortcuts.jumpToLatestUnread.effective(from: overrides) 44 45 CommandMenu("Worktrees") { 45 46 // Creation and opening. 46 47 Button("New Worktree…", systemImage: "plus") { ··· 113 114 .appKeyboardShortcut(stop) 114 115 .help("Stop Script (\(stop?.display ?? "none"))") 115 116 .disabled(stopRunScriptAction == nil) 117 + Button("Jump to Latest Unread", systemImage: "bell.badge") { 118 + store.send(.jumpToLatestUnread) 119 + } 120 + .appKeyboardShortcut(jumpToLatestUnread) 121 + .help("Jump to Latest Unread Notification (\(jumpToLatestUnread?.display ?? "none"))") 122 + .disabled(store.notificationIndicatorCount == 0) 116 123 Divider() 117 124 // Navigation. 118 125 Button("Select Next", systemImage: "chevron.down") {
+68 -2
supacode/Features/App/Reducer/AppFeature.swift
··· 8 8 9 9 private nonisolated let appLogger = SupaLogger("App") 10 10 private nonisolated let deeplinkLogger = SupaLogger("Deeplink") 11 + private nonisolated let jumpLogger = SupaLogger("JumpToLatestUnread") 12 + private nonisolated let notificationsLogger = SupaLogger("Notifications") 11 13 12 14 private enum CancelID { 13 15 static let periodicRefresh = "app.periodicRefresh" ··· 74 76 case openWorktreeFailed(OpenActionError) 75 77 case requestQuit 76 78 case newTerminal 79 + case jumpToLatestUnread 77 80 case runScript 78 81 case runNamedScript(ScriptDefinition) 79 82 case stopScript(ScriptDefinition) ··· 437 440 await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) 438 441 } 439 442 443 + case .jumpToLatestUnread: 444 + guard let location = terminalClient.latestUnreadNotification() else { 445 + jumpLogger.debug("jumpToLatestUnread invoked with no unread notifications.") 446 + return .none 447 + } 448 + guard let worktree = state.repositories.worktree(for: location.worktreeID) else { 449 + jumpLogger.warning( 450 + "jumpToLatestUnread: worktree \(location.worktreeID) vanished between notification lookup and dispatch." 451 + ) 452 + return .none 453 + } 454 + analyticsClient.capture("notifications_jump_to_latest_unread", nil) 455 + // `.merge` is safe here: `focusSurface` carries the `Worktree` 456 + // explicitly, so it does not depend on `selectWorktree` landing 457 + // first. `.concatenate` would serialize unnecessarily. 458 + return .merge( 459 + .send(.repositories(.selectWorktree(location.worktreeID, focusTerminal: true))), 460 + .run { _ in 461 + await terminalClient.send( 462 + .focusSurface(worktree, tabID: location.tabID, surfaceID: location.surfaceID) 463 + ) 464 + await terminalClient.markNotificationRead(location.worktreeID, location.notificationID) 465 + } 466 + ) 467 + 440 468 case .runScript: 441 469 // Find the selected or primary script and run it. 442 470 guard let definition = state.primaryScript else { ··· 785 813 case .commandPalette: 786 814 return .none 787 815 788 - case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 816 + case .terminalEvent(.notificationReceived(let worktreeID, let surfaceID, let title, let body)): 789 817 var effects: [Effect<Action>] = [ 790 818 .send(.repositories(.worktreeNotificationReceived(worktreeID))) 791 819 ] 792 820 if state.settings.systemNotificationsEnabled { 821 + let deeplinkURL = surfaceDeeplinkURL(worktreeID: worktreeID, surfaceID: surfaceID) 793 822 effects.append( 794 823 .run { _ in 795 - await systemNotificationClient.send(title, body) 824 + await systemNotificationClient.send(title, body, deeplinkURL) 796 825 } 797 826 ) 798 827 } ··· 1571 1600 case .github: .github 1572 1601 } 1573 1602 return .send(.settings(.setSelection(settingsSection))) 1603 + } 1604 + 1605 + /// Builds a `supacode://worktree/<id>/surface/<tabID>/<surfaceID>` URL for a 1606 + /// notification whose surface is known; falls back to the worktree-level 1607 + /// URL when the tab containing the surface can no longer be resolved. 1608 + private func surfaceDeeplinkURL(worktreeID: Worktree.ID, surfaceID: UUID) -> URL? { 1609 + let percentEncodingSet = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "/")) 1610 + let encodedWorktreeID = 1611 + worktreeID.addingPercentEncoding(withAllowedCharacters: percentEncodingSet) ?? worktreeID 1612 + guard let tabID = terminalClient.tabID(worktreeID, surfaceID) else { 1613 + notificationsLogger.debug( 1614 + "Surface \(surfaceID) is no longer attached to a tab in \(worktreeID); " 1615 + + "degrading tap deeplink to the worktree root." 1616 + ) 1617 + return urlOrWarn( 1618 + "supacode://worktree/\(encodedWorktreeID)", 1619 + worktreeID: worktreeID, 1620 + surfaceID: surfaceID 1621 + ) 1622 + } 1623 + let tabRaw = tabID.rawValue.uuidString 1624 + let surfaceRaw = surfaceID.uuidString 1625 + return urlOrWarn( 1626 + "supacode://worktree/\(encodedWorktreeID)/tab/\(tabRaw)/surface/\(surfaceRaw)", 1627 + worktreeID: worktreeID, 1628 + surfaceID: surfaceID 1629 + ) 1630 + } 1631 + 1632 + private func urlOrWarn(_ string: String, worktreeID: Worktree.ID, surfaceID: UUID) -> URL? { 1633 + guard let url = URL(string: string) else { 1634 + notificationsLogger.warning( 1635 + "Failed to build deeplink URL for worktree \(worktreeID) surface \(surfaceID) from: \(string)" 1636 + ) 1637 + return nil 1638 + } 1639 + return url 1574 1640 } 1575 1641 }
+52 -2
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 290 290 state.isSelected = { [weak self] in 291 291 self?.selectedWorktreeID == worktree.id 292 292 } 293 - state.onNotificationReceived = { [weak self] title, body in 294 - self?.emit(.notificationReceived(worktreeID: worktree.id, title: title, body: body)) 293 + state.onNotificationReceived = { [weak self] surfaceID, title, body in 294 + self?.emit( 295 + .notificationReceived( 296 + worktreeID: worktree.id, 297 + surfaceID: surfaceID, 298 + title: title, 299 + body: body 300 + ) 301 + ) 295 302 } 296 303 state.onNotificationIndicatorChanged = { [weak self] in 297 304 self?.emitNotificationIndicatorCountIfNeeded() ··· 403 410 404 411 func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { 405 412 states[worktreeID]?.hasUnseenNotification == true 413 + } 414 + 415 + /// Locates the most recent unread notification across all managed 416 + /// worktrees whose surface still exists. Notifications whose surface has 417 + /// been closed are skipped in favour of the next-newest focusable unread. 418 + func latestUnreadNotificationLocation() -> NotificationLocation? { 419 + var best: NotificationLocation? 420 + var bestCreatedAt: Date? 421 + var skippedClosedSurface = false 422 + for (worktreeID, state) in states { 423 + for notification in state.unreadNotifications() { 424 + if let bestCreatedAt, bestCreatedAt >= notification.createdAt { break } 425 + guard let tabID = state.tabID(containing: notification.surfaceId) else { 426 + skippedClosedSurface = true 427 + terminalLogger.debug( 428 + "latestUnreadNotificationLocation: skipping closed surface \(notification.surfaceId) " 429 + + "in \(worktreeID); trying older unread." 430 + ) 431 + continue 432 + } 433 + best = NotificationLocation( 434 + worktreeID: worktreeID, 435 + tabID: tabID, 436 + surfaceID: notification.surfaceId, 437 + notificationID: notification.id, 438 + ) 439 + bestCreatedAt = notification.createdAt 440 + break 441 + } 442 + } 443 + if best == nil, skippedClosedSurface { 444 + terminalLogger.debug("latestUnreadNotificationLocation: all unread notifications point at closed surfaces.") 445 + } 446 + return best 447 + } 448 + 449 + /// Resolves the tab containing the given surface, if any. 450 + func tabID(forWorktreeID worktreeID: Worktree.ID, surfaceID: UUID) -> TerminalTabID? { 451 + states[worktreeID]?.tabID(containing: surfaceID) 452 + } 453 + 454 + func markNotificationRead(worktreeID: Worktree.ID, notificationID: UUID) { 455 + states[worktreeID]?.markNotificationRead(id: notificationID) 406 456 } 407 457 408 458 func saveAllLayoutSnapshots() {
+8
supacode/Features/Terminal/Models/NotificationLocation.swift
··· 1 + import Foundation 2 + 3 + struct NotificationLocation: Equatable, Sendable { 4 + let worktreeID: Worktree.ID 5 + let tabID: TerminalTabID 6 + let surfaceID: UUID 7 + let notificationID: UUID 8 + }
+10 -1
supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift
··· 5 5 let surfaceId: UUID 6 6 let title: String 7 7 let body: String 8 + let createdAt: Date 8 9 var isRead: Bool 9 10 10 - init(id: UUID = UUID(), surfaceId: UUID, title: String, body: String, isRead: Bool = false) { 11 + init( 12 + id: UUID = UUID(), 13 + surfaceId: UUID, 14 + title: String, 15 + body: String, 16 + createdAt: Date, 17 + isRead: Bool = false 18 + ) { 11 19 self.id = id 12 20 self.surfaceId = surfaceId 13 21 self.title = title 14 22 self.body = body 23 + self.createdAt = createdAt 15 24 self.isRead = isRead 16 25 } 17 26
+38 -7
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 48 48 var hasUnseenNotification: Bool { 49 49 notifications.contains { !$0.isRead } 50 50 } 51 + 52 + func hasUnseenNotification(forSurfaceID surfaceID: UUID) -> Bool { 53 + notifications.contains { !$0.isRead && $0.surfaceId == surfaceID } 54 + } 55 + 56 + func hasUnseenNotification(forTabID tabID: TerminalTabID) -> Bool { 57 + guard let tree = trees[tabID] else { return false } 58 + let surfaceIDs = Set(tree.leaves().map(\.id)) 59 + return notifications.contains { !$0.isRead && surfaceIDs.contains($0.surfaceId) } 60 + } 61 + 62 + /// Returns the most recent unread notification in this worktree, or nil. 63 + func latestUnreadNotification() -> WorktreeTerminalNotification? { 64 + unreadNotifications().first 65 + } 66 + 67 + /// Returns all unread notifications in this worktree sorted newest first. 68 + func unreadNotifications() -> [WorktreeTerminalNotification] { 69 + notifications.filter { !$0.isRead }.sorted { $0.createdAt > $1.createdAt } 70 + } 71 + 51 72 #if DEBUG 52 73 var debugRecentHookCount: Int { 53 74 recentHookBySurfaceID.count 54 75 } 55 76 #endif 56 77 var isSelected: () -> Bool = { false } 57 - var onNotificationReceived: ((String, String) -> Void)? 78 + var onNotificationReceived: ((UUID, String, String) -> Void)? 58 79 var onNotificationIndicatorChanged: (() -> Void)? 59 80 var onTabCreated: (() -> Void)? 60 81 var onTabClosed: (() -> Void)? ··· 320 341 func listSurfaces(tabID: TerminalTabID) -> [[String: String]] { 321 342 let focusedID = focusedSurfaceIdByTab[tabID] 322 343 return surfaces.compactMap { surfaceID, _ in 323 - guard tabId(containing: surfaceID) == tabID else { return nil } 344 + guard self.tabID(containing: surfaceID) == tabID else { return nil } 324 345 var entry = ["id": surfaceID.uuidString] 325 346 if surfaceID == focusedID { entry["focused"] = "1" } 326 347 return entry ··· 429 450 430 451 @discardableResult 431 452 func focusSurface(id: UUID) -> Bool { 432 - guard let tabId = tabId(containing: id), 453 + guard let tabId = tabID(containing: id), 433 454 let surface = surfaces[id] 434 455 else { 435 456 terminalStateLogger.warning("focusSurface: surface \(id) not found in worktree \(worktree.id).") ··· 571 592 newSurfaceID: UUID? = nil, 572 593 initialInput: String? = nil 573 594 ) -> Bool { 574 - guard let tabId = tabId(containing: surfaceId), var tree = trees[tabId] else { 595 + guard let tabId = tabID(containing: surfaceId), var tree = trees[tabId] else { 575 596 return false 576 597 } 577 598 guard let targetNode = tree.find(id: surfaceId) else { return false } ··· 741 762 emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 742 763 } 743 764 765 + /// Marks a single notification as read, leaving others untouched. 766 + func markNotificationRead(id: WorktreeTerminalNotification.ID) { 767 + let previousHasUnseen = hasUnseenNotification 768 + guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } 769 + guard !notifications[index].isRead else { return } 770 + notifications[index].isRead = true 771 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 772 + } 773 + 744 774 func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { 745 775 let previousHasUnseen = hasUnseenNotification 746 776 notifications.removeAll { $0.id == notificationID } ··· 1273 1303 surfaceId: surfaceId, 1274 1304 title: trimmedTitle, 1275 1305 body: trimmedBody, 1306 + createdAt: now, 1276 1307 isRead: isRead 1277 1308 ), 1278 1309 at: 0 ··· 1283 1314 if !fromHook, shouldSuppressDesktopNotification(title: trimmedTitle, body: trimmedBody, surfaceId: surfaceId) { 1284 1315 return 1285 1316 } 1286 - onNotificationReceived?(trimmedTitle, trimmedBody) 1317 + onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody) 1287 1318 } 1288 1319 1289 1320 // MARK: - Notification deduplication (matches supaterm's approach). ··· 1335 1366 tabIsRunningById.removeValue(forKey: tabId) 1336 1367 } 1337 1368 1338 - private func tabId(containing surfaceId: UUID) -> TerminalTabID? { 1369 + func tabID(containing surfaceId: UUID) -> TerminalTabID? { 1339 1370 for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { 1340 1371 return tabId 1341 1372 } ··· 1452 1483 1453 1484 private func handleCloseRequest(for view: GhosttySurfaceView, processAlive _: Bool) { 1454 1485 guard surfaces[view.id] != nil else { return } 1455 - guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { 1486 + guard let tabId = tabID(containing: view.id), let tree = trees[tabId] else { 1456 1487 view.closeSurface() 1457 1488 cleanupSurfaceState(for: view.id) 1458 1489 return
+3 -1
supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift
··· 10 10 let closeOthers: (TerminalTabID) -> Void 11 11 let closeToRight: (TerminalTabID) -> Void 12 12 let closeAll: () -> Void 13 + let hasNotification: (TerminalTabID) -> Bool 13 14 @Environment(\.controlActiveState) 14 15 private var activeState 15 16 ··· 20 21 closeTab: closeTab, 21 22 closeOthers: closeOthers, 22 23 closeToRight: closeToRight, 23 - closeAll: closeAll 24 + closeAll: closeAll, 25 + hasNotification: hasNotification 24 26 ) 25 27 Spacer(minLength: 0) 26 28 TerminalTabBarTrailingAccessories(
+34 -8
supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift
··· 7 7 let isDragging: Bool 8 8 let tabIndex: Int 9 9 let fixedWidth: CGFloat? 10 + let hasNotification: Bool 10 11 let onSelect: () -> Void 11 12 let onClose: () -> Void 12 13 @Binding var closeButtonGestureActive: Bool ··· 40 41 .help("Open tab \(tab.title)") 41 42 .accessibilityLabel(tab.title) 42 43 43 - TerminalTabCloseButton( 44 - isHoveringTab: isHovering, 45 - isDragging: isDragging, 46 - isShowingShortcutHint: showsShortcutHint, 47 - closeAction: onClose, 48 - closeButtonGestureActive: $closeButtonGestureActive, 49 - isHoveringClose: $isHoveringClose 50 - ) 44 + // The dot and close X share the same trailing slot: the dot is 45 + // visible when the tab is idle and has unread notifications, the 46 + // close button replaces it on hover. `TerminalTabCloseButton` already 47 + // owns the hover visibility — we mirror it inverted here. 48 + ZStack { 49 + TabNotificationDot() 50 + .opacity(isShowingNotificationDot ? 1 : 0) 51 + .allowsHitTesting(false) 52 + TerminalTabCloseButton( 53 + isHoveringTab: isHovering, 54 + isDragging: isDragging, 55 + isShowingShortcutHint: showsShortcutHint, 56 + closeAction: onClose, 57 + closeButtonGestureActive: $closeButtonGestureActive, 58 + isHoveringClose: $isHoveringClose 59 + ) 60 + } 61 + .animation(.easeInOut(duration: TerminalTabBarMetrics.hoverAnimationDuration), value: isHovering) 62 + .animation(.easeInOut(duration: 0.2), value: hasNotification) 51 63 .padding(.trailing, TerminalTabBarMetrics.tabHorizontalPadding) 52 64 } 53 65 .background { ··· 81 93 82 94 private var showsShortcutHint: Bool { 83 95 commandKeyObserver.isPressed && shortcutHint != nil 96 + } 97 + 98 + private var isShowingNotificationDot: Bool { 99 + hasNotification && !isHovering && !isHoveringClose && !isDragging && !showsShortcutHint 100 + } 101 + } 102 + 103 + private struct TabNotificationDot: View { 104 + var body: some View { 105 + Circle() 106 + .fill(.orange) 107 + .frame(width: 6, height: 6) 108 + .frame(width: TerminalTabBarMetrics.closeButtonSize, height: TerminalTabBarMetrics.closeButtonSize) 109 + .accessibilityLabel("Unread notifications") 84 110 } 85 111 } 86 112
+2
supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift
··· 12 12 let closeOthers: (TerminalTabID) -> Void 13 13 let closeToRight: (TerminalTabID) -> Void 14 14 let closeAll: () -> Void 15 + let hasNotification: (TerminalTabID) -> Bool 15 16 let scrollReader: ScrollViewProxy 16 17 17 18 @State private var dropTargetIndex: Int? ··· 28 29 isDragging: draggingTabId == id, 29 30 tabIndex: index, 30 31 fixedWidth: fixedTabWidth, 32 + hasNotification: hasNotification(id), 31 33 onSelect: { 32 34 manager.selectTab(id) 33 35 },
+2
supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift
··· 6 6 let closeOthers: (TerminalTabID) -> Void 7 7 let closeToRight: (TerminalTabID) -> Void 8 8 let closeAll: () -> Void 9 + let hasNotification: (TerminalTabID) -> Bool 9 10 10 11 @State private var draggingTabId: TerminalTabID? 11 12 @State private var draggingStartLocation: CGFloat? ··· 32 33 closeOthers: closeOthers, 33 34 closeToRight: closeToRight, 34 35 closeAll: closeAll, 36 + hasNotification: hasNotification, 35 37 scrollReader: scrollReader 36 38 ) 37 39 .padding(.horizontal, TerminalTabBarMetrics.barPadding)
+34
supacode/Features/Terminal/Views/TerminalSplitTreeView.swift
··· 12 12 // and `unfocused-split-opacity` config values. Fill is nil when the config 13 13 // is unreadable; callers must skip the overlay in that case. 14 14 let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 15 + // Returns whether a given surface has unread notifications. The closure 16 + // is read inside the leaf view so SwiftUI picks up per-surface changes 17 + // via the Observation tracking on the underlying state. 18 + let hasNotification: (UUID) -> Bool 15 19 let action: (Operation) -> Void 16 20 17 21 private static let dragType = UTType(exportedAs: "sh.supacode.ghosttySurfaceId") ··· 35 39 isRoot: node == tree.root, 36 40 activeSurfaceID: activeSurfaceID, 37 41 unfocusedSplitOverlay: unfocusedSplitOverlay, 42 + hasNotification: hasNotification, 38 43 action: action 39 44 ) 40 45 .id(node.structuralIdentity) ··· 52 57 var isRoot: Bool = false 53 58 let activeSurfaceID: UUID? 54 59 let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 60 + let hasNotification: (UUID) -> Bool 55 61 let action: (Operation) -> Void 56 62 57 63 var body: some View { ··· 62 68 isSplit: !isRoot, 63 69 activeSurfaceID: activeSurfaceID, 64 70 unfocusedSplitOverlay: unfocusedSplitOverlay, 71 + hasNotification: hasNotification(leafView.id), 65 72 action: action 66 73 ) 67 74 case .split(let split): ··· 86 93 node: split.left, 87 94 activeSurfaceID: activeSurfaceID, 88 95 unfocusedSplitOverlay: unfocusedSplitOverlay, 96 + hasNotification: hasNotification, 89 97 action: action 90 98 ) 91 99 }, ··· 94 102 node: split.right, 95 103 activeSurfaceID: activeSurfaceID, 96 104 unfocusedSplitOverlay: unfocusedSplitOverlay, 105 + hasNotification: hasNotification, 97 106 action: action 98 107 ) 99 108 }, ··· 110 119 let isSplit: Bool 111 120 let activeSurfaceID: UUID? 112 121 let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 122 + let hasNotification: Bool 113 123 let action: (Operation) -> Void 114 124 115 125 @State private var dropState: DropState = .idle ··· 139 149 if surfaceView.bridge.state.searchNeedle != nil { 140 150 GhosttySurfaceSearchOverlay(surfaceView: surfaceView) 141 151 } 152 + } 153 + .overlay(alignment: .topTrailing) { 154 + SurfaceNotificationDot() 155 + .padding(6) 156 + .opacity(hasNotification ? 1 : 0) 157 + .allowsHitTesting(false) 158 + .animation(.easeInOut(duration: 0.2), value: hasNotification) 142 159 } 143 160 .overlay(alignment: .top) { 144 161 if isSplit { ··· 323 340 } 324 341 } 325 342 343 + // MARK: - Surface notification indicator. 344 + 345 + private struct SurfaceNotificationDot: View { 346 + var body: some View { 347 + Circle() 348 + .fill(.orange) 349 + .frame(width: 8, height: 8) 350 + .overlay( 351 + Circle() 352 + .stroke(.background, lineWidth: 1) 353 + ) 354 + .accessibilityLabel("Unread notifications") 355 + } 356 + } 357 + 326 358 // MARK: - Accessibility Container 327 359 328 360 /// Wraps the SwiftUI split tree in an AppKit view so we can expose an ordered ··· 331 363 let tree: SplitTree<GhosttySurfaceView> 332 364 let activeSurfaceID: UUID? 333 365 let unfocusedSplitOverlay: (fill: Color?, opacity: Double) 366 + let hasNotification: (UUID) -> Bool 334 367 let action: (TerminalSplitTreeView.Operation) -> Void 335 368 336 369 func makeNSView(context: Context) -> TerminalSplitAXContainerView { ··· 344 377 tree: tree, 345 378 activeSurfaceID: activeSurfaceID, 346 379 unfocusedSplitOverlay: unfocusedSplitOverlay, 380 + hasNotification: hasNotification, 347 381 action: action 348 382 ) 349 383 ),
+11 -4
supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift
··· 40 40 }, 41 41 closeAll: { 42 42 state.closeAllTabs() 43 + }, 44 + hasNotification: { tabId in 45 + state.hasUnseenNotification(forTabID: tabId) 43 46 } 44 47 ) 45 48 .transition(.move(edge: .top).combined(with: .opacity)) ··· 49 52 TerminalSplitTreeAXContainer( 50 53 tree: state.splitTree(for: tabId), 51 54 activeSurfaceID: state.activeSurfaceID(for: tabId), 52 - unfocusedSplitOverlay: unfocusedSplitOverlay 53 - ) { operation in 54 - state.performSplitOperation(operation, in: tabId) 55 - } 55 + unfocusedSplitOverlay: unfocusedSplitOverlay, 56 + hasNotification: { surfaceID in 57 + state.hasUnseenNotification(forSurfaceID: surfaceID) 58 + }, 59 + action: { operation in 60 + state.performSplitOperation(operation, in: tabId) 61 + } 62 + ) 56 63 } 57 64 } else { 58 65 EmptyTerminalPaneView(message: "No terminals open")
+3 -3
supacodeTests/AgentBusyStateTests.swift
··· 166 166 } operation: { 167 167 let fixture = makeStateWithSurface() 168 168 var systemNotificationCount = 0 169 - fixture.state.onNotificationReceived = { _, _ in 169 + fixture.state.onNotificationReceived = { _, _, _ in 170 170 systemNotificationCount += 1 171 171 } 172 172 ··· 197 197 } operation: { 198 198 let fixture = makeStateWithSurface() 199 199 var systemNotificationCount = 0 200 - fixture.state.onNotificationReceived = { _, _ in 200 + fixture.state.onNotificationReceived = { _, _, _ in 201 201 systemNotificationCount += 1 202 202 } 203 203 ··· 224 224 } operation: { 225 225 let fixture = makeStateWithSurface() 226 226 var systemNotificationCount = 0 227 - fixture.state.onNotificationReceived = { _, _ in 227 + fixture.state.onNotificationReceived = { _, _, _ in 228 228 systemNotificationCount += 1 229 229 } 230 230
+146
supacodeTests/AppFeatureJumpToLatestUnreadTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import SupacodeSettingsFeature 7 + @testable import SupacodeSettingsShared 8 + @testable import supacode 9 + 10 + @MainActor 11 + @Suite(.serialized) 12 + struct AppFeatureJumpToLatestUnreadTests { 13 + @Test(.dependencies) func noOpWhenNoUnreadNotifications() async { 14 + let worktree = makeWorktree() 15 + let focused = LockIsolated<[TerminalClient.Command]>([]) 16 + let store = makeStore(worktree: worktree) { 17 + $0.terminalClient.latestUnreadNotification = { nil } 18 + $0.terminalClient.send = { command in 19 + focused.withValue { $0.append(command) } 20 + } 21 + } 22 + 23 + await store.send(.jumpToLatestUnread) 24 + await store.finish() 25 + 26 + #expect(focused.value.isEmpty) 27 + } 28 + 29 + @Test(.dependencies) func selectsWorktreeAndFocusesSurfaceOnJump() async { 30 + let worktree = makeWorktree() 31 + let tabUUID = UUID() 32 + let surfaceUUID = UUID() 33 + let notificationUUID = UUID() 34 + let focused = LockIsolated<[TerminalClient.Command]>([]) 35 + let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) 36 + let store = makeStore(worktree: worktree) { 37 + $0.terminalClient.latestUnreadNotification = { 38 + NotificationLocation( 39 + worktreeID: worktree.id, 40 + tabID: TerminalTabID(rawValue: tabUUID), 41 + surfaceID: surfaceUUID, 42 + notificationID: notificationUUID, 43 + ) 44 + } 45 + $0.terminalClient.send = { command in 46 + focused.withValue { $0.append(command) } 47 + } 48 + $0.terminalClient.markNotificationRead = { worktreeID, notificationID in 49 + marked.withValue { $0.append((worktreeID, notificationID)) } 50 + } 51 + } 52 + 53 + await store.send(.jumpToLatestUnread) 54 + await store.receive(\.repositories.selectWorktree) 55 + await store.finish() 56 + 57 + let expectedFocus = TerminalClient.Command.focusSurface( 58 + worktree, 59 + tabID: TerminalTabID(rawValue: tabUUID), 60 + surfaceID: surfaceUUID, 61 + input: nil 62 + ) 63 + // Only the focus command should flow through `send`; the side-effect 64 + // setSelectedWorktreeID is produced by the `selectWorktree` delegate 65 + // and is tested separately. Using an exact-length assertion prevents 66 + // a future refactor from quietly duplicating the focus command. 67 + let focusCommands = focused.value.filter { 68 + if case .focusSurface = $0 { return true } else { return false } 69 + } 70 + #expect(focusCommands == [expectedFocus]) 71 + 72 + #expect(marked.value.count == 1) 73 + #expect(marked.value.first?.0 == worktree.id) 74 + #expect(marked.value.first?.1 == notificationUUID) 75 + } 76 + 77 + @Test(.dependencies) func dropsJumpWhenTargetWorktreeMissing() async { 78 + let worktree = makeWorktree() 79 + let missingID = "/tmp/repo/does-not-exist" 80 + let focused = LockIsolated<[TerminalClient.Command]>([]) 81 + let store = makeStore(worktree: worktree) { 82 + $0.terminalClient.latestUnreadNotification = { 83 + NotificationLocation( 84 + worktreeID: missingID, 85 + tabID: TerminalTabID(rawValue: UUID()), 86 + surfaceID: UUID(), 87 + notificationID: UUID(), 88 + ) 89 + } 90 + $0.terminalClient.send = { command in 91 + focused.withValue { $0.append(command) } 92 + } 93 + } 94 + 95 + await store.send(.jumpToLatestUnread) 96 + await store.finish() 97 + 98 + #expect(focused.value.isEmpty) 99 + } 100 + 101 + // MARK: - Helpers. 102 + 103 + private func makeWorktree( 104 + id: String = "/tmp/repo/wt-1", 105 + name: String = "wt-1" 106 + ) -> Worktree { 107 + Worktree( 108 + id: id, 109 + name: name, 110 + detail: "detail", 111 + workingDirectory: URL(fileURLWithPath: id), 112 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 113 + ) 114 + } 115 + 116 + private func makeStore( 117 + worktree: Worktree, 118 + withAdditionalDependencies: (inout DependencyValues) -> Void 119 + ) -> TestStoreOf<AppFeature> { 120 + var repositoriesState = RepositoriesFeature.State() 121 + let repository = Repository( 122 + id: "/tmp/repo", 123 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 124 + name: "repo", 125 + worktrees: [worktree], 126 + ) 127 + repositoriesState.repositories = [repository] 128 + repositoriesState.selection = .worktree(worktree.id) 129 + repositoriesState.isInitialLoadComplete = true 130 + 131 + let store = TestStore( 132 + initialState: AppFeature.State( 133 + repositories: repositoriesState, 134 + settings: SettingsFeature.State() 135 + ) 136 + ) { 137 + AppFeature() 138 + } withDependencies: { values in 139 + values.terminalClient.tabExists = { _, _ in true } 140 + values.terminalClient.surfaceExists = { _, _, _ in true } 141 + withAdditionalDependencies(&values) 142 + } 143 + store.exhaustivity = .off 144 + return store 145 + } 146 + }
+8 -2
supacodeTests/AppFeatureSystemNotificationTests.swift
··· 128 128 ) { 129 129 AppFeature() 130 130 } withDependencies: { 131 - $0.systemNotificationClient.send = { title, body in 131 + $0.systemNotificationClient.send = { title, body, _ in 132 132 sends.withValue { $0.append((title, body)) } 133 133 } 134 + $0.terminalClient.tabID = { _, _ in nil } 134 135 } 135 136 store.exhaustivity = .off 136 137 ··· 138 139 .terminalEvent( 139 140 .notificationReceived( 140 141 worktreeID: "/tmp/repo/wt-1", 142 + surfaceID: UUID(), 141 143 title: "Done", 142 144 body: "Build succeeded" 143 145 ) ··· 165 167 $0.notificationSoundClient.play = { 166 168 plays.withValue { $0 += 1 } 167 169 } 170 + $0.systemNotificationClient.send = { _, _, _ in } 171 + $0.terminalClient.tabID = { _, _ in nil } 168 172 } 169 173 store.exhaustivity = .off 170 174 ··· 172 176 .terminalEvent( 173 177 .notificationReceived( 174 178 worktreeID: "/tmp/repo/wt-1", 179 + surfaceID: UUID(), 175 180 title: "Done", 176 181 body: "Build succeeded" 177 182 ) ··· 198 203 $0.notificationSoundClient.play = { 199 204 plays.withValue { $0 += 1 } 200 205 } 201 - $0.systemNotificationClient.send = { _, _ in 206 + $0.systemNotificationClient.send = { _, _, _ in 202 207 sends.withValue { $0 += 1 } 203 208 } 204 209 } ··· 208 213 .terminalEvent( 209 214 .notificationReceived( 210 215 worktreeID: "/tmp/repo/wt-1", 216 + surfaceID: UUID(), 211 217 title: "Done", 212 218 body: "Build succeeded" 213 219 )
+3 -3
supacodeTests/RepositoriesFeatureTests.swift
··· 2107 2107 worktree.id: [ 2108 2108 secondID: .orange, 2109 2109 firstID: .purple, 2110 - ], 2110 + ] 2111 2111 ] 2112 2112 2113 2113 #expect(state.runningScriptColors(for: worktree.id) == [.purple, .orange]) ··· 2124 2124 worktree.id: [ 2125 2125 completing.id: completing.resolvedTintColor, 2126 2126 surviving.id: surviving.resolvedTintColor, 2127 - ], 2127 + ] 2128 2128 ] 2129 2129 2130 2130 let store = TestStore(initialState: state) { ··· 2141 2141 ) 2142 2142 ) { 2143 2143 $0.runningScriptsByWorktreeID = [ 2144 - worktree.id: [surviving.id: surviving.resolvedTintColor], 2144 + worktree.id: [surviving.id: surviving.resolvedTintColor] 2145 2145 ] 2146 2146 } 2147 2147 #expect(store.state.alert == nil)
+20 -8
supacodeTests/ToolbarNotificationGroupingTests.swift
··· 40 40 41 41 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 42 42 manager.state(for: repoAOne).notifications = [ 43 - WorktreeTerminalNotification(surfaceId: UUID(), title: "A1", body: "done", isRead: true) 43 + WorktreeTerminalNotification( 44 + surfaceId: UUID(), title: "A1", body: "done", createdAt: .distantPast, isRead: true 45 + ) 44 46 ] 45 47 manager.state(for: repoATwo).notifications = [ 46 - WorktreeTerminalNotification(surfaceId: UUID(), title: "A2", body: "done") 48 + WorktreeTerminalNotification(surfaceId: UUID(), title: "A2", body: "done", createdAt: .distantPast) 47 49 ] 48 50 manager.state(for: repoBOne).notifications = [ 49 - WorktreeTerminalNotification(surfaceId: UUID(), title: "B1", body: "done", isRead: true) 51 + WorktreeTerminalNotification( 52 + surfaceId: UUID(), title: "B1", body: "done", createdAt: .distantPast, isRead: true 53 + ) 50 54 ] 51 55 52 56 let groups = state.toolbarNotificationGroups(terminalManager: manager) ··· 82 86 83 87 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 84 88 manager.state(for: repoAArchived).notifications = [ 85 - WorktreeTerminalNotification(surfaceId: UUID(), title: "Archived", body: "hidden") 89 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Archived", body: "hidden", createdAt: .distantPast) 86 90 ] 87 91 88 92 let groups = state.toolbarNotificationGroups(terminalManager: manager) ··· 102 106 103 107 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 104 108 manager.state(for: readOnly).notifications = [ 105 - WorktreeTerminalNotification(surfaceId: UUID(), title: "Read 1", body: "done", isRead: true) 109 + WorktreeTerminalNotification( 110 + surfaceId: UUID(), title: "Read 1", body: "done", createdAt: .distantPast, isRead: true 111 + ) 106 112 ] 107 113 manager.state(for: mixed).notifications = [ 108 - WorktreeTerminalNotification(surfaceId: UUID(), title: "Read 2", body: "done", isRead: true), 109 - WorktreeTerminalNotification(surfaceId: UUID(), title: "Unread", body: "new", isRead: false), 114 + WorktreeTerminalNotification( 115 + surfaceId: UUID(), title: "Read 2", body: "done", createdAt: .distantPast, isRead: true 116 + ), 117 + WorktreeTerminalNotification( 118 + surfaceId: UUID(), title: "Unread", body: "new", createdAt: .distantPast, isRead: false 119 + ), 110 120 ] 111 121 112 122 let groups = state.toolbarNotificationGroups(terminalManager: manager) ··· 127 137 128 138 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 129 139 manager.state(for: feature).notifications = [ 130 - WorktreeTerminalNotification(surfaceId: UUID(), title: "Read", body: "kept", isRead: true) 140 + WorktreeTerminalNotification( 141 + surfaceId: UUID(), title: "Read", body: "kept", createdAt: .distantPast, isRead: true 142 + ) 131 143 ] 132 144 133 145 let groups = state.toolbarNotificationGroups(terminalManager: manager)
+152 -1
supacodeTests/WorktreeTerminalManagerTests.swift
··· 166 166 surfaceId: UUID(), 167 167 title: "Unread", 168 168 body: "body", 169 + createdAt: .distantPast, 169 170 isRead: false 170 171 ) 171 172 ] ··· 175 176 surfaceId: UUID(), 176 177 title: "Read", 177 178 body: "body", 179 + createdAt: .distantPast, 178 180 isRead: true 179 181 ) 180 182 ] ··· 797 799 #expect(manager.listSurfaces(worktreeID: worktree.id, tabID: "not-a-uuid") == nil) 798 800 } 799 801 802 + @Test func latestUnreadNotificationPicksNewestAcrossWorktrees() { 803 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 804 + let worktreeA = makeWorktree(id: "/tmp/repo/wt-a") 805 + let worktreeB = makeWorktree(id: "/tmp/repo/wt-b") 806 + let stateA = manager.state(for: worktreeA) 807 + let stateB = manager.state(for: worktreeB) 808 + guard 809 + let tabA = stateA.createTab(), 810 + let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf(), 811 + let tabB = stateB.createTab(), 812 + let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf() 813 + else { 814 + Issue.record("Expected tabs and surfaces") 815 + return 816 + } 817 + 818 + let older = Date(timeIntervalSince1970: 1_000) 819 + let newer = Date(timeIntervalSince1970: 2_000) 820 + stateA.notifications = [makeNotification(surfaceId: surfaceA.id, isRead: false, createdAt: older)] 821 + stateB.notifications = [makeNotification(surfaceId: surfaceB.id, isRead: false, createdAt: newer)] 822 + 823 + let location = manager.latestUnreadNotificationLocation() 824 + #expect(location?.worktreeID == worktreeB.id) 825 + #expect(location?.tabID == tabB) 826 + #expect(location?.surfaceID == surfaceB.id) 827 + } 828 + 829 + @Test func latestUnreadNotificationSkipsNotificationsWithClosedSurfaces() { 830 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 831 + let worktree = makeWorktree() 832 + let state = manager.state(for: worktree) 833 + guard 834 + let tab = state.createTab(), 835 + let surface = state.splitTree(for: tab).root?.leftmostLeaf() 836 + else { 837 + Issue.record("Expected tab and surface") 838 + return 839 + } 840 + 841 + let alive = makeNotification(surfaceId: surface.id, isRead: false, createdAt: Date(timeIntervalSince1970: 1_000)) 842 + let orphan = makeNotification(surfaceId: UUID(), isRead: false, createdAt: Date(timeIntervalSince1970: 2_000)) 843 + // The orphan is newer but its surface no longer exists in any tab, so 844 + // it must be skipped and the alive notification wins. 845 + state.notifications = [orphan, alive] 846 + 847 + let location = manager.latestUnreadNotificationLocation() 848 + #expect(location?.surfaceID == surface.id) 849 + #expect(location?.tabID == tab) 850 + } 851 + 852 + @Test func latestUnreadNotificationComparesFocusableAcrossWorktreesAfterFallback() { 853 + // Worktree A: newest unread is orphaned, but an older unread targets 854 + // a live surface at t=1. 855 + // Worktree B: only has a focusable unread at t=2, which is newer than 856 + // A's focusable fallback but older than A's orphaned newest. 857 + // Expected winner: B. 858 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 859 + let worktreeA = makeWorktree(id: "/tmp/repo/wt-a") 860 + let worktreeB = makeWorktree(id: "/tmp/repo/wt-b") 861 + let stateA = manager.state(for: worktreeA) 862 + let stateB = manager.state(for: worktreeB) 863 + guard 864 + let tabA = stateA.createTab(), 865 + let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf(), 866 + let tabB = stateB.createTab(), 867 + let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf() 868 + else { 869 + Issue.record("Expected tabs and surfaces") 870 + return 871 + } 872 + 873 + let orphanSurface = UUID() 874 + stateA.notifications = [ 875 + makeNotification(surfaceId: orphanSurface, isRead: false, createdAt: Date(timeIntervalSince1970: 3)), 876 + makeNotification(surfaceId: surfaceA.id, isRead: false, createdAt: Date(timeIntervalSince1970: 1)), 877 + ] 878 + stateB.notifications = [ 879 + makeNotification(surfaceId: surfaceB.id, isRead: false, createdAt: Date(timeIntervalSince1970: 2)) 880 + ] 881 + 882 + let location = manager.latestUnreadNotificationLocation() 883 + #expect(location?.worktreeID == worktreeB.id) 884 + #expect(location?.surfaceID == surfaceB.id) 885 + #expect(location?.tabID == tabB) 886 + } 887 + 888 + @Test func latestUnreadNotificationReturnsNilWhenAllUnreadTargetClosedSurfaces() { 889 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 890 + let worktree = makeWorktree() 891 + let state = manager.state(for: worktree) 892 + state.notifications = [ 893 + makeNotification(surfaceId: UUID(), isRead: false, createdAt: .distantPast) 894 + ] 895 + #expect(manager.latestUnreadNotificationLocation() == nil) 896 + } 897 + 898 + @Test func hasUnseenNotificationForTabIDWalksSplitTree() { 899 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 900 + let worktree = makeWorktree() 901 + let state = manager.state(for: worktree) 902 + guard 903 + let tab = state.createTab(), 904 + let surface = state.splitTree(for: tab).root?.leftmostLeaf() 905 + else { 906 + Issue.record("Expected tab and surface") 907 + return 908 + } 909 + // Split so the tab owns two surfaces. 910 + _ = state.performSplitAction(.newSplit(direction: .right), for: surface.id) 911 + let leaves = state.splitTree(for: tab).leaves() 912 + #expect(leaves.count == 2) 913 + 914 + // No notifications yet. 915 + #expect(state.hasUnseenNotification(forTabID: tab) == false) 916 + 917 + // Notification on the first leaf lights up the tab. 918 + state.notifications = [makeNotification(surfaceId: leaves[0].id, isRead: false, createdAt: .distantPast)] 919 + #expect(state.hasUnseenNotification(forTabID: tab) == true) 920 + state.markAllNotificationsRead() 921 + 922 + // Notification on the second leaf also lights up the tab. 923 + state.notifications = [makeNotification(surfaceId: leaves[1].id, isRead: false, createdAt: .distantPast)] 924 + #expect(state.hasUnseenNotification(forTabID: tab) == true) 925 + 926 + // Once read, the tab is clean again. 927 + state.markAllNotificationsRead() 928 + #expect(state.hasUnseenNotification(forTabID: tab) == false) 929 + 930 + // A notification tied to a surface outside this tab does NOT light it up. 931 + state.notifications = [makeNotification(surfaceId: UUID(), isRead: false, createdAt: .distantPast)] 932 + #expect(state.hasUnseenNotification(forTabID: tab) == false) 933 + } 934 + 935 + @Test func markNotificationReadOnlyTouchesMatchingId() { 936 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 937 + let worktree = makeWorktree() 938 + let state = manager.state(for: worktree) 939 + let first = makeNotification(surfaceId: UUID(), isRead: false, createdAt: .distantPast) 940 + let second = makeNotification(surfaceId: UUID(), isRead: false, createdAt: .distantPast) 941 + state.notifications = [first, second] 942 + 943 + manager.markNotificationRead(worktreeID: worktree.id, notificationID: first.id) 944 + 945 + #expect(state.notifications.first(where: { $0.id == first.id })?.isRead == true) 946 + #expect(state.notifications.first(where: { $0.id == second.id })?.isRead == false) 947 + } 948 + 800 949 private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { 801 950 let name = URL(fileURLWithPath: id).lastPathComponent 802 951 return Worktree( ··· 820 969 821 970 private func makeNotification( 822 971 surfaceId: UUID = UUID(), 823 - isRead: Bool 972 + isRead: Bool, 973 + createdAt: Date = .distantPast 824 974 ) -> WorktreeTerminalNotification { 825 975 WorktreeTerminalNotification( 826 976 surfaceId: surfaceId, 827 977 title: "Title", 828 978 body: "Body", 979 + createdAt: createdAt, 829 980 isRead: isRead 830 981 ) 831 982 }