native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #28 from supabitapp/move-notifications-right-button

Move notifications to right toolbar popover

authored by

khoi and committed by
GitHub
3be40797 f1075367

+686 -102
-1
supacode/Clients/Terminal/TerminalClient.swift
··· 20 20 case endSearch(Worktree) 21 21 case prune(Set<Worktree.ID>) 22 22 case setNotificationsEnabled(Bool) 23 - case clearNotificationIndicator(Worktree) 24 23 case setSelectedWorktreeID(Worktree.ID?) 25 24 } 26 25
+2 -3
supacode/Features/App/Reducer/AppFeature.swift
··· 159 159 }, 160 160 .run { _ in 161 161 await terminalClient.send(.setSelectedWorktreeID(worktree.id)) 162 - await terminalClient.send(.clearNotificationIndicator(worktree)) 163 162 }, 164 163 .run { _ in 165 164 await worktreeInfoWatcher.send(.setSelectedWorktreeID(worktree.id)) ··· 568 567 case .commandPalette: 569 568 return .none 570 569 571 - case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 570 + case .terminalEvent(.notificationReceived(let worktreeID, _, _)): 572 571 var effects: [Effect<Action>] = [ 573 - .send(.repositories(.worktreeNotificationReceived(worktreeID, title: title, body: body))) 572 + .send(.repositories(.worktreeNotificationReceived(worktreeID))) 574 573 ] 575 574 if state.settings.notificationSoundEnabled { 576 575 effects.append(
-6
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 354 354 subtitle: "Simulates a success toast", 355 355 kind: .debugTestToast(.success("Pull request merged")) 356 356 ), 357 - CommandPaletteItem( 358 - id: "debug.toast.notification", 359 - title: "[Debug] Toast: Notification", 360 - subtitle: "Simulates a notification toast", 361 - kind: .debugTestToast(.notification("Build completed – All checks passed", worktreeID: "debug")) 362 - ), 363 357 ] 364 358 } 365 359 #endif
+66
supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift
··· 1 + import Foundation 2 + 3 + struct ToolbarNotificationRepositoryGroup: Identifiable, Equatable { 4 + let id: Repository.ID 5 + let name: String 6 + let worktrees: [ToolbarNotificationWorktreeGroup] 7 + 8 + var notificationCount: Int { 9 + worktrees.reduce(0) { count, worktree in 10 + count + worktree.notifications.count 11 + } 12 + } 13 + 14 + var unseenWorktreeCount: Int { 15 + worktrees.reduce(0) { count, worktree in 16 + count + (worktree.hasUnseenNotifications ? 1 : 0) 17 + } 18 + } 19 + } 20 + 21 + struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { 22 + let id: Worktree.ID 23 + let name: String 24 + let notifications: [WorktreeTerminalNotification] 25 + let hasUnseenNotifications: Bool 26 + } 27 + 28 + extension RepositoriesFeature.State { 29 + func toolbarNotificationGroups( 30 + terminalManager: WorktreeTerminalManager 31 + ) -> [ToolbarNotificationRepositoryGroup] { 32 + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 33 + var groups: [ToolbarNotificationRepositoryGroup] = [] 34 + 35 + for repositoryID in orderedRepositoryIDs() { 36 + guard let repository = repositoriesByID[repositoryID] else { 37 + continue 38 + } 39 + 40 + let worktreeGroups: [ToolbarNotificationWorktreeGroup] = 41 + orderedWorktrees(in: repository).compactMap { worktree -> ToolbarNotificationWorktreeGroup? in 42 + guard let state = terminalManager.stateIfExists(for: worktree.id), !state.notifications.isEmpty else { 43 + return nil 44 + } 45 + return ToolbarNotificationWorktreeGroup( 46 + id: worktree.id, 47 + name: worktree.name, 48 + notifications: state.notifications, 49 + hasUnseenNotifications: terminalManager.hasUnseenNotifications(for: worktree.id) 50 + ) 51 + } 52 + 53 + if !worktreeGroups.isEmpty { 54 + groups.append( 55 + ToolbarNotificationRepositoryGroup( 56 + id: repository.id, 57 + name: repository.name, 58 + worktrees: worktreeGroups 59 + ) 60 + ) 61 + } 62 + } 63 + 64 + return groups 65 + } 66 + }
+5 -20
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 103 103 case unpinWorktree(Worktree.ID) 104 104 case presentAlert(title: String, message: String) 105 105 case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) 106 - case worktreeNotificationReceived(Worktree.ID, title: String, body: String) 107 - case notificationToastTapped 106 + case worktreeNotificationReceived(Worktree.ID) 108 107 case worktreeBranchNameLoaded(worktreeID: Worktree.ID, name: String) 109 108 case worktreeLineChangesLoaded(worktreeID: Worktree.ID, added: Int, removed: Int) 110 109 case worktreePullRequestLoaded(worktreeID: Worktree.ID, pullRequest: GithubPullRequest?) ··· 139 138 enum StatusToast: Equatable { 140 139 case inProgress(String) 141 140 case success(String) 142 - case notification(String, worktreeID: Worktree.ID) 143 141 } 144 142 145 143 enum Alert: Equatable { ··· 1204 1202 case .showToast(let toast): 1205 1203 state.statusToast = toast 1206 1204 switch toast { 1207 - case .inProgress, .notification: 1205 + case .inProgress: 1208 1206 return .cancel(id: CancelID.toastAutoDismiss) 1209 1207 case .success: 1210 1208 return .run { send in ··· 1240 1238 } 1241 1239 .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true) 1242 1240 1243 - case .worktreeNotificationReceived(let worktreeID, let title, let body): 1241 + case .worktreeNotificationReceived(let worktreeID): 1244 1242 guard let repositoryID = state.repositoryID(containing: worktreeID), 1245 1243 let repository = state.repositories[id: repositoryID], 1246 1244 let worktree = repository.worktrees[id: worktreeID] ··· 1253 1251 1254 1252 var effects: [Effect<Action>] = [] 1255 1253 1256 - if case .inProgress = state.statusToast { 1257 - // don't interrupt in-progress toasts 1258 - } else { 1259 - let content = [title, body].filter { !$0.isEmpty }.joined(separator: " – ") 1260 - if !content.isEmpty { 1261 - effects.append(.send(.showToast(.notification(content, worktreeID: worktreeID)))) 1262 - } 1263 - } 1264 - 1265 1254 if !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 1266 1255 let reordered = reorderedUnpinnedWorktreeIDs( 1267 1256 for: worktreeID, ··· 1281 1270 } 1282 1271 } 1283 1272 1284 - return .merge(effects) 1285 - 1286 - case .notificationToastTapped: 1287 - guard case .notification(_, let worktreeID) = state.statusToast else { 1273 + if effects.isEmpty { 1288 1274 return .none 1289 1275 } 1290 - state.statusToast = nil 1291 - return .send(.selectWorktree(worktreeID)) 1276 + return .merge(effects) 1292 1277 1293 1278 case .worktreeInfoEvent(let event): 1294 1279 switch event {
+4 -5
supacode/Features/Repositories/Views/NotificationPopoverButton.swift
··· 2 2 3 3 struct NotificationPopoverButton<Label: View>: View { 4 4 let notifications: [WorktreeTerminalNotification] 5 - let onClear: (() -> Void)? 6 - let onFocusSurface: (UUID) -> Void 5 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 7 6 @ViewBuilder let label: () -> Label 8 7 @State private var isPresented = false 9 8 @State private var isHoveringButton = false ··· 12 11 13 12 var body: some View { 14 13 Button { 15 - onClear?() 14 + isPresented.toggle() 16 15 } label: { 17 16 label() 18 17 } 19 18 .buttonStyle(.plain) 20 19 .contentShape(.rect) 21 - .help("Unread notifications. Hover to show. Click to clear.") 20 + .help("Unread notifications. Hover to show.") 22 21 .accessibilityLabel("Unread notifications") 23 22 .onHover { hovering in 24 23 isHoveringButton = hovering 25 24 updatePresentation() 26 25 } 27 26 .popover(isPresented: $isPresented) { 28 - NotificationPopoverView(notifications: notifications, onFocusSurface: onFocusSurface) 27 + NotificationPopoverView(notifications: notifications, onFocusNotification: onFocusNotification) 29 28 .onHover { hovering in 30 29 isHoveringPopover = hovering 31 30 updatePresentation()
+5 -4
supacode/Features/Repositories/Views/NotificationPopoverView.swift
··· 2 2 3 3 struct NotificationPopoverView: View { 4 4 let notifications: [WorktreeTerminalNotification] 5 - let onFocusSurface: (UUID) -> Void 5 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 6 6 7 7 var body: some View { 8 8 let count = notifications.count ··· 17 17 Divider() 18 18 ForEach(notifications) { notification in 19 19 Button { 20 - onFocusSurface(notification.surfaceId) 20 + onFocusNotification(notification) 21 21 } label: { 22 22 HStack(alignment: .top) { 23 23 Image(systemName: "bell") 24 - .foregroundStyle(.secondary) 24 + .foregroundStyle(notification.isRead ? Color.secondary : Color.orange) 25 25 .accessibilityHidden(true) 26 26 Text(notification.content) 27 + .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) 27 28 .lineLimit(2) 28 29 } 29 30 .frame(maxWidth: .infinity, alignment: .leading) 30 31 } 31 32 .buttonStyle(.plain) 32 33 .font(.caption) 33 - .help("Focus pane") 34 + .help(notification.content.isEmpty ? "Focus pane" : notification.content) 34 35 } 35 36 } 36 37 .padding()
+102
supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift
··· 1 + import SwiftUI 2 + 3 + struct ToolbarNotificationsPopoverButton: View { 4 + let groups: [ToolbarNotificationRepositoryGroup] 5 + let unseenWorktreeCount: Int 6 + let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void 7 + let onDismissAll: () -> Void 8 + @State private var isPresented = false 9 + @State private var isPinnedOpen = false 10 + @State private var isHoveringButton = false 11 + @State private var isHoveringPopover = false 12 + @State private var closeTask: Task<Void, Never>? 13 + 14 + private var notificationCount: Int { 15 + groups.reduce(0) { count, repository in 16 + count 17 + + repository.worktrees.reduce(0) { worktreeCount, worktree in 18 + worktreeCount + worktree.notifications.filter { !$0.isRead }.count 19 + } 20 + } 21 + } 22 + 23 + var body: some View { 24 + Button { 25 + togglePresentation() 26 + } label: { 27 + HStack(spacing: 6) { 28 + Image(systemName: unseenWorktreeCount > 0 ? "bell.badge.fill" : "bell.fill") 29 + .foregroundStyle(unseenWorktreeCount > 0 ? .orange : .secondary) 30 + .accessibilityHidden(true) 31 + Text(notificationCount, format: .number) 32 + .font(.caption.monospacedDigit()) 33 + } 34 + } 35 + .help("Notifications. Hover or click to show all notifications.") 36 + .accessibilityLabel("Notifications") 37 + .onHover { hovering in 38 + isHoveringButton = hovering 39 + updatePresentation() 40 + } 41 + .popover(isPresented: $isPresented) { 42 + ToolbarNotificationsPopoverView( 43 + groups: groups, 44 + onSelectNotification: { worktreeID, notification in 45 + onSelectNotification(worktreeID, notification) 46 + closePopover() 47 + }, 48 + onDismissAll: { 49 + onDismissAll() 50 + closePopover() 51 + } 52 + ) 53 + .onHover { hovering in 54 + isHoveringPopover = hovering 55 + updatePresentation() 56 + } 57 + .onDisappear { 58 + isHoveringPopover = false 59 + isPinnedOpen = false 60 + } 61 + } 62 + .onChange(of: groups) { _, newValue in 63 + if newValue.isEmpty { 64 + closePopover() 65 + } 66 + } 67 + .onDisappear { 68 + closeTask?.cancel() 69 + } 70 + } 71 + 72 + private func togglePresentation() { 73 + if isPinnedOpen { 74 + closePopover() 75 + return 76 + } 77 + closeTask?.cancel() 78 + isPinnedOpen = true 79 + isPresented = true 80 + } 81 + 82 + private func updatePresentation() { 83 + if isPinnedOpen || isHoveringButton || isHoveringPopover { 84 + closeTask?.cancel() 85 + isPresented = true 86 + return 87 + } 88 + closeTask?.cancel() 89 + closeTask = Task { @MainActor in 90 + try? await Task.sleep(for: .milliseconds(150)) 91 + if !Task.isCancelled { 92 + isPresented = false 93 + } 94 + } 95 + } 96 + 97 + private func closePopover() { 98 + closeTask?.cancel() 99 + isPinnedOpen = false 100 + isPresented = false 101 + } 102 + }
+81
supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift
··· 1 + import SwiftUI 2 + 3 + struct ToolbarNotificationsPopoverView: View { 4 + let groups: [ToolbarNotificationRepositoryGroup] 5 + let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void 6 + let onDismissAll: () -> Void 7 + 8 + var body: some View { 9 + let notificationCount = groups.reduce(0) { count, repository in 10 + count + repository.notificationCount 11 + } 12 + let notificationLabel = notificationCount == 1 ? "notification" : "notifications" 13 + 14 + ScrollView { 15 + VStack(alignment: .leading, spacing: 12) { 16 + HStack { 17 + VStack(alignment: .leading, spacing: 2) { 18 + Text("Notifications") 19 + .font(.headline) 20 + Text("\(notificationCount) \(notificationLabel)") 21 + .font(.subheadline) 22 + .foregroundStyle(.secondary) 23 + } 24 + Spacer() 25 + Button("Dismiss All") { 26 + onDismissAll() 27 + } 28 + .disabled(notificationCount == 0) 29 + .help("Dismiss all notifications") 30 + } 31 + 32 + ForEach(groups) { repository in 33 + VStack(alignment: .leading, spacing: 8) { 34 + Divider() 35 + Text(repository.name) 36 + .font(.subheadline) 37 + ForEach(repository.worktrees) { worktree in 38 + VStack(alignment: .leading, spacing: 6) { 39 + HStack(spacing: 6) { 40 + Text(worktree.name) 41 + .font(.caption) 42 + .foregroundStyle(.secondary) 43 + if worktree.hasUnseenNotifications { 44 + Circle() 45 + .fill(.orange) 46 + .frame(width: 6, height: 6) 47 + .accessibilityHidden(true) 48 + } 49 + } 50 + ForEach(worktree.notifications) { notification in 51 + Button { 52 + onSelectNotification(worktree.id, notification) 53 + } label: { 54 + HStack(alignment: .top, spacing: 8) { 55 + Image(systemName: "bell") 56 + .foregroundStyle(notification.isRead ? Color.secondary : Color.orange) 57 + .accessibilityHidden(true) 58 + Text(notification.content) 59 + .font(.caption) 60 + .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) 61 + .lineLimit(2) 62 + } 63 + .frame(maxWidth: .infinity, alignment: .leading) 64 + } 65 + .buttonStyle(.plain) 66 + .help( 67 + notification.content.isEmpty 68 + ? "Select worktree and focus terminal" 69 + : notification.content 70 + ) 71 + } 72 + } 73 + } 74 + } 75 + } 76 + } 77 + .padding() 78 + } 79 + .frame(minWidth: 320, maxWidth: 520, maxHeight: 440) 80 + } 81 + }
-14
supacode/Features/Repositories/Views/ToolbarStatusView.swift
··· 3 3 struct ToolbarStatusView: View { 4 4 let toast: RepositoriesFeature.StatusToast? 5 5 let pullRequest: GithubPullRequest? 6 - var onNotificationTapped: () -> Void = {} 7 6 8 7 var body: some View { 9 8 Group { ··· 26 25 .font(.footnote) 27 26 .foregroundStyle(.secondary) 28 27 } 29 - .transition(.opacity) 30 - case .notification(let message, _): 31 - Button(action: onNotificationTapped) { 32 - HStack(spacing: 6) { 33 - Image(systemName: "bell.fill") 34 - .foregroundStyle(.yellow) 35 - .accessibilityHidden(true) 36 - Text(message) 37 - .font(.footnote.monospaced()) 38 - .foregroundStyle(.secondary) 39 - } 40 - } 41 - .buttonStyle(.plain) 42 28 .transition(.opacity) 43 29 case nil: 44 30 if let model = PullRequestStatusModel(pullRequest: pullRequest) {
+47 -5
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 22 22 !state.selectedRunScript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 23 23 let runScriptEnabled = hasActiveWorktree && runScriptConfigured 24 24 let runScriptIsRunning = selectedWorktree.flatMap { state.runScriptStatusByWorktreeID[$0.id] } == true 25 + let notificationGroups = repositories.toolbarNotificationGroups(terminalManager: terminalManager) 26 + let unseenNotificationWorktreeCount = notificationGroups.reduce(0) { count, repository in 27 + count + repository.unseenWorktreeCount 28 + } 25 29 let content = Group { 26 30 if repositories.isShowingArchivedWorktrees { 27 31 ArchivedWorktreesDetailView( ··· 64 68 branchName: selectedWorktree.name, 65 69 statusToast: repositories.statusToast, 66 70 pullRequest: matchesBranch ? pullRequest : nil, 71 + notificationGroups: notificationGroups, 72 + unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, 67 73 openActionSelection: openActionSelection, 68 74 showExtras: commandKeyObserver.isPressed, 69 75 runScriptEnabled: runScriptEnabled, ··· 84 90 NSPasteboard.general.clearContents() 85 91 NSPasteboard.general.setString(selectedWorktree.workingDirectory.path, forType: .string) 86 92 }, 87 - onNotificationTapped: { store.send(.repositories(.notificationToastTapped)) }, 93 + onSelectNotification: selectToolbarNotification, 94 + onDismissAllNotifications: { dismissAllToolbarNotifications(in: notificationGroups) }, 88 95 onRunScript: { store.send(.runScript) }, 89 96 onStopRunScript: { store.send(.stopRunScript) } 90 97 ) ··· 139 146 ) 140 147 } 141 148 149 + private func selectToolbarNotification( 150 + _ worktreeID: Worktree.ID, 151 + _ notification: WorktreeTerminalNotification 152 + ) { 153 + store.send(.repositories(.selectWorktree(worktreeID))) 154 + if let terminalState = terminalManager.stateIfExists(for: worktreeID) { 155 + _ = terminalState.focusSurface(id: notification.surfaceId) 156 + } 157 + } 158 + 159 + private func dismissAllToolbarNotifications(in groups: [ToolbarNotificationRepositoryGroup]) { 160 + for repositoryGroup in groups { 161 + for worktreeGroup in repositoryGroup.worktrees { 162 + terminalManager.stateIfExists(for: worktreeGroup.id)?.dismissAllNotifications() 163 + } 164 + } 165 + } 166 + 142 167 private struct FocusedActions { 143 168 let openSelectedWorktree: (() -> Void)? 144 169 let newTerminal: (() -> Void)? ··· 157 182 let branchName: String 158 183 let statusToast: RepositoriesFeature.StatusToast? 159 184 let pullRequest: GithubPullRequest? 185 + let notificationGroups: [ToolbarNotificationRepositoryGroup] 186 + let unseenNotificationWorktreeCount: Int 160 187 let openActionSelection: OpenWorktreeAction 161 188 let showExtras: Bool 162 189 let runScriptEnabled: Bool ··· 177 204 let onOpenWorktree: (OpenWorktreeAction) -> Void 178 205 let onOpenActionSelectionChanged: (OpenWorktreeAction) -> Void 179 206 let onCopyPath: () -> Void 180 - let onNotificationTapped: () -> Void 207 + let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void 208 + let onDismissAllNotifications: () -> Void 181 209 let onRunScript: () -> Void 182 210 let onStopRunScript: () -> Void 183 211 ··· 194 222 ToolbarItemGroup { 195 223 ToolbarStatusView( 196 224 toast: toolbarState.statusToast, 197 - pullRequest: toolbarState.pullRequest, 198 - onNotificationTapped: onNotificationTapped 225 + pullRequest: toolbarState.pullRequest 199 226 ) 200 227 .padding(.horizontal) 228 + } 229 + 230 + if !toolbarState.notificationGroups.isEmpty { 231 + ToolbarSpacer(.fixed) 232 + ToolbarItemGroup { 233 + ToolbarNotificationsPopoverButton( 234 + groups: toolbarState.notificationGroups, 235 + unseenWorktreeCount: toolbarState.unseenNotificationWorktreeCount, 236 + onSelectNotification: onSelectNotification, 237 + onDismissAll: onDismissAllNotifications 238 + ) 239 + } 201 240 } 202 241 203 242 ToolbarSpacer(.flexible) ··· 378 417 branchName: "feature/toolbar-preview", 379 418 statusToast: nil, 380 419 pullRequest: nil, 420 + notificationGroups: [], 421 + unseenNotificationWorktreeCount: 0, 381 422 openActionSelection: .finder, 382 423 showExtras: false, 383 424 runScriptEnabled: true, ··· 400 441 onOpenWorktree: { _ in }, 401 442 onOpenActionSelectionChanged: { _ in }, 402 443 onCopyPath: {}, 403 - onNotificationTapped: {}, 444 + onSelectNotification: { _, _ in }, 445 + onDismissAllNotifications: {}, 404 446 onRunScript: {}, 405 447 onStopRunScript: {} 406 448 )
+2 -4
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 11 11 let isRunScriptRunning: Bool 12 12 let showsNotificationIndicator: Bool 13 13 let notifications: [WorktreeTerminalNotification] 14 - let onClearNotifications: (() -> Void)? 15 - let onFocusSurface: (UUID) -> Void 14 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 16 15 let shortcutHint: String? 17 16 let archiveAction: (() -> Void)? 18 17 @Environment(\.colorScheme) private var colorScheme ··· 36 35 if showsNotificationIndicator { 37 36 NotificationPopoverButton( 38 37 notifications: notifications, 39 - onClear: onClearNotifications, 40 - onFocusSurface: onFocusSurface 38 + onFocusNotification: onFocusNotification 41 39 ) { 42 40 Image(systemName: "bell.fill") 43 41 .font(.caption)
+8 -12
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 98 98 ? { store.send(.requestArchiveWorktree(row.id, repository.id)) } 99 99 : nil 100 100 let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] 101 - let onClearNotifications: (() -> Void)? = 102 - showsNotificationIndicator 103 - ? { terminalManager.stateIfExists(for: row.id)?.clearNotificationIndicator() } 104 - : nil 105 - let onFocusSurface: (UUID) -> Void = { surfaceId in 106 - terminalManager.stateIfExists(for: row.id)?.focusSurface(id: surfaceId) 101 + let onFocusNotification: (WorktreeTerminalNotification) -> Void = { notification in 102 + guard let terminalState = terminalManager.stateIfExists(for: row.id) else { 103 + return 104 + } 105 + _ = terminalState.focusSurface(id: notification.surfaceId) 107 106 } 108 107 let config = WorktreeRowViewConfig( 109 108 displayName: displayName, 110 109 showsNotificationIndicator: showsNotificationIndicator, 111 110 notifications: notifications, 112 - onClearNotifications: onClearNotifications, 113 - onFocusSurface: onFocusSurface, 111 + onFocusNotification: onFocusNotification, 114 112 shortcutHint: shortcutHint, 115 113 archiveAction: archiveAction, 116 114 moveDisabled: moveDisabled ··· 152 150 let displayName: String 153 151 let showsNotificationIndicator: Bool 154 152 let notifications: [WorktreeTerminalNotification] 155 - let onClearNotifications: (() -> Void)? 156 - let onFocusSurface: (UUID) -> Void 153 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 157 154 let shortcutHint: String? 158 155 let archiveAction: (() -> Void)? 159 156 let moveDisabled: Bool ··· 173 170 isRunScriptRunning: isRunScriptRunning, 174 171 showsNotificationIndicator: config.showsNotificationIndicator, 175 172 notifications: config.notifications, 176 - onClearNotifications: config.onClearNotifications, 177 - onFocusSurface: config.onFocusSurface, 173 + onFocusNotification: config.onFocusNotification, 178 174 shortcutHint: config.shortcutHint, 179 175 archiveAction: config.archiveAction 180 176 )
-6
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 75 75 prune(keeping: ids) 76 76 case .setNotificationsEnabled(let enabled): 77 77 setNotificationsEnabled(enabled) 78 - case .clearNotificationIndicator(let worktree): 79 - clearNotificationIndicator(for: worktree) 80 78 case .setSelectedWorktreeID(let id): 81 79 selectedWorktreeID = id 82 80 default: ··· 217 215 218 216 func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool { 219 217 states[worktreeID]?.hasUnseenNotification == true 220 - } 221 - 222 - func clearNotificationIndicator(for worktree: Worktree) { 223 - states[worktree.id]?.clearNotificationIndicator() 224 218 } 225 219 226 220 private func emit(_ event: TerminalClient.Event) {
+3 -1
supacode/Features/Terminal/Models/WorktreeTerminalNotification.swift
··· 5 5 let surfaceId: UUID 6 6 let title: String 7 7 let body: String 8 + var isRead: Bool 8 9 9 - init(id: UUID = UUID(), surfaceId: UUID, title: String, body: String) { 10 + init(id: UUID = UUID(), surfaceId: UUID, title: String, body: String, isRead: Bool = false) { 10 11 self.id = id 11 12 self.surfaceId = surfaceId 12 13 self.title = title 13 14 self.body = body 15 + self.isRead = isRead 14 16 } 15 17 16 18 var content: String {
+56 -19
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 24 24 private var lastEmittedFocusSurfaceId: UUID? 25 25 var notifications: [WorktreeTerminalNotification] = [] 26 26 var notificationsEnabled = true 27 - var hasUnseenNotification = false 27 + var hasUnseenNotification: Bool { 28 + notifications.contains { !$0.isRead } 29 + } 28 30 var isSelected: () -> Bool = { false } 29 31 var onNotificationReceived: ((String, String) -> Void)? 30 32 var onNotificationIndicatorChanged: (() -> Void)? ··· 407 409 func setNotificationsEnabled(_ enabled: Bool) { 408 410 notificationsEnabled = enabled 409 411 if !enabled { 410 - let wasUnseen = hasUnseenNotification 411 - hasUnseenNotification = false 412 - if wasUnseen { 413 - onNotificationIndicatorChanged?() 414 - } 412 + markAllNotificationsRead() 415 413 } 416 414 } 417 415 418 416 func clearNotificationIndicator() { 419 - if hasUnseenNotification { 420 - hasUnseenNotification = false 421 - onNotificationIndicatorChanged?() 417 + markAllNotificationsRead() 418 + } 419 + 420 + func markAllNotificationsRead() { 421 + let previousHasUnseen = hasUnseenNotification 422 + for index in notifications.indices { 423 + notifications[index].isRead = true 422 424 } 425 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 426 + } 427 + 428 + func markNotificationsRead(forSurfaceID surfaceID: UUID) { 429 + let previousHasUnseen = hasUnseenNotification 430 + for index in notifications.indices where notifications[index].surfaceId == surfaceID { 431 + notifications[index].isRead = true 432 + } 433 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 434 + } 435 + 436 + func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { 437 + let previousHasUnseen = hasUnseenNotification 438 + notifications.removeAll { $0.id == notificationID } 439 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 440 + } 441 + 442 + func dismissAllNotifications() { 443 + let previousHasUnseen = hasUnseenNotification 444 + notifications.removeAll() 445 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 423 446 } 424 447 425 448 func needsSetupScript() -> Bool { ··· 516 539 view.onFocusChange = { [weak self, weak view] focused in 517 540 guard let self, let view, focused else { return } 518 541 self.focusedSurfaceIdByTab[tabId] = view.id 542 + self.markNotificationsRead(forSurfaceID: view.id) 519 543 self.updateTabTitle(for: tabId) 520 544 self.emitFocusChangedIfNeeded(view.id) 521 545 self.emitTaskStatusIfChanged() ··· 546 570 private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { 547 571 let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } 548 572 focusedSurfaceIdByTab[tabId] = surface.id 573 + markNotificationsRead(forSurfaceID: surface.id) 549 574 updateTabTitle(for: tabId) 550 575 guard tabId == tabManager.selectedTabId else { return } 551 576 let fromSurface = (previousSurface === surface) ? nil : previousSurface ··· 558 583 let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) 559 584 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) 560 585 guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } 561 - notifications.append( 586 + let previousHasUnseen = hasUnseenNotification 587 + let isRead = isSelected() && isFocusedSurface(surfaceId) 588 + notifications.insert( 562 589 WorktreeTerminalNotification( 563 590 surfaceId: surfaceId, 564 591 title: trimmedTitle, 565 - body: trimmedBody 566 - )) 567 - let wasUnseen = hasUnseenNotification 568 - if !isSelected() { 569 - hasUnseenNotification = true 570 - } 571 - if hasUnseenNotification != wasUnseen { 572 - onNotificationIndicatorChanged?() 573 - } 592 + body: trimmedBody, 593 + isRead: isRead 594 + ), 595 + at: 0 596 + ) 597 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 574 598 onNotificationReceived?(trimmedTitle, trimmedBody) 575 599 } 576 600 ··· 591 615 return nil 592 616 } 593 617 618 + private func isFocusedSurface(_ surfaceId: UUID) -> Bool { 619 + guard let selectedTabId = tabManager.selectedTabId else { 620 + return false 621 + } 622 + return focusedSurfaceIdByTab[selectedTabId] == surfaceId 623 + } 624 + 594 625 private func updateRunningState(for tabId: TerminalTabID) { 595 626 guard let tree = trees[tabId] else { return } 596 627 let isRunningNow = tree.leaves().contains { surface in ··· 613 644 guard surfaceId != lastEmittedFocusSurfaceId else { return } 614 645 lastEmittedFocusSurfaceId = surfaceId 615 646 onFocusChanged?(surfaceId) 647 + } 648 + 649 + private func emitNotificationIndicatorIfNeeded(previousHasUnseen: Bool) { 650 + if previousHasUnseen != hasUnseenNotification { 651 + onNotificationIndicatorChanged?() 652 + } 616 653 } 617 654 618 655 private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool {
+33
supacodeTests/RepositoriesFeatureTests.swift
··· 222 222 } 223 223 } 224 224 225 + @Test func worktreeNotificationReceivedDoesNotShowStatusToast() async { 226 + let repoRoot = "/tmp/repo" 227 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 228 + let featureWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 229 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 230 + var state = makeState(repositories: [repository]) 231 + state.worktreeOrderByRepository[repoRoot] = [featureWorktree.id] 232 + let store = TestStore(initialState: state) { 233 + RepositoriesFeature() 234 + } 235 + 236 + await store.send(.worktreeNotificationReceived(featureWorktree.id)) 237 + #expect(store.state.statusToast == nil) 238 + } 239 + 240 + @Test func worktreeNotificationReceivedReordersUnpinnedWorktrees() async { 241 + let repoRoot = "/tmp/repo" 242 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 243 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 244 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 245 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 246 + var state = makeState(repositories: [repository]) 247 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 248 + let store = TestStore(initialState: state) { 249 + RepositoriesFeature() 250 + } 251 + 252 + await store.send(.worktreeNotificationReceived(featureB.id)) { 253 + $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureA.id] 254 + } 255 + #expect(store.state.statusToast == nil) 256 + } 257 + 225 258 @Test func worktreeBranchNameLoadedPreservesCreatedAt() async { 226 259 let createdAt = Date(timeIntervalSince1970: 1_737_303_600) 227 260 let worktree = makeWorktree(id: "/tmp/wt", name: "eagle", createdAt: createdAt)
+146
supacodeTests/ToolbarNotificationGroupingTests.swift
··· 1 + import Foundation 2 + import IdentifiedCollections 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct ToolbarNotificationGroupingTests { 9 + @Test func groupsNotificationsByRepositoryAndWorktreeInDisplayOrder() { 10 + let repoAPath = "/tmp/repo-a" 11 + let repoBPath = "/tmp/repo-b" 12 + 13 + let repoAMain = makeWorktree(id: repoAPath, name: "main", repoRoot: repoAPath) 14 + let repoAOne = makeWorktree(id: "\(repoAPath)/one", name: "one", repoRoot: repoAPath) 15 + let repoATwo = makeWorktree(id: "\(repoAPath)/two", name: "two", repoRoot: repoAPath) 16 + 17 + let repoBMain = makeWorktree(id: repoBPath, name: "main", repoRoot: repoBPath) 18 + let repoBOne = makeWorktree(id: "\(repoBPath)/one", name: "one", repoRoot: repoBPath) 19 + 20 + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [repoAMain, repoAOne, repoATwo]) 21 + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [repoBMain, repoBOne]) 22 + 23 + var state = RepositoriesFeature.State(repositories: [repoA, repoB]) 24 + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] 25 + state.repositoryOrderIDs = [repoB.id, repoA.id] 26 + state.worktreeOrderByRepository[repoA.id] = [repoATwo.id, repoAOne.id] 27 + 28 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 29 + manager.state(for: repoAOne).notifications = [ 30 + WorktreeTerminalNotification(surfaceId: UUID(), title: "A1", body: "done", isRead: true) 31 + ] 32 + manager.state(for: repoATwo).notifications = [ 33 + WorktreeTerminalNotification(surfaceId: UUID(), title: "A2", body: "done") 34 + ] 35 + manager.state(for: repoBOne).notifications = [ 36 + WorktreeTerminalNotification(surfaceId: UUID(), title: "B1", body: "done", isRead: true) 37 + ] 38 + 39 + let groups = state.toolbarNotificationGroups(terminalManager: manager) 40 + 41 + #expect(groups.map(\.id) == [repoB.id, repoA.id]) 42 + #expect(groups[0].worktrees.map(\.id) == [repoBOne.id]) 43 + #expect(groups[1].worktrees.map(\.id) == [repoATwo.id, repoAOne.id]) 44 + #expect(groups[1].unseenWorktreeCount == 1) 45 + } 46 + 47 + @Test func omitsArchivedAndEmptyNotificationGroups() { 48 + let repoAPath = "/tmp/repo-a" 49 + let repoBPath = "/tmp/repo-b" 50 + 51 + let repoAMain = makeWorktree(id: repoAPath, name: "main", repoRoot: repoAPath) 52 + let repoAArchived = makeWorktree(id: "\(repoAPath)/archived", name: "archived", repoRoot: repoAPath) 53 + let repoBMain = makeWorktree(id: repoBPath, name: "main", repoRoot: repoBPath) 54 + let repoBEmpty = makeWorktree(id: "\(repoBPath)/empty", name: "empty", repoRoot: repoBPath) 55 + 56 + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [repoAMain, repoAArchived]) 57 + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [repoBMain, repoBEmpty]) 58 + 59 + var state = RepositoriesFeature.State(repositories: [repoA, repoB]) 60 + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] 61 + state.archivedWorktreeIDs = [repoAArchived.id] 62 + 63 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 64 + manager.state(for: repoAArchived).notifications = [ 65 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Archived", body: "hidden") 66 + ] 67 + 68 + let groups = state.toolbarNotificationGroups(terminalManager: manager) 69 + 70 + #expect(groups.isEmpty) 71 + } 72 + 73 + @Test func unseenWorktreeCountUsesUnreadNotificationsOnly() { 74 + let repoPath = "/tmp/repo" 75 + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) 76 + let readOnly = makeWorktree(id: "\(repoPath)/read-only", name: "read-only", repoRoot: repoPath) 77 + let mixed = makeWorktree(id: "\(repoPath)/mixed", name: "mixed", repoRoot: repoPath) 78 + 79 + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, readOnly, mixed]) 80 + var state = RepositoriesFeature.State(repositories: [repo]) 81 + state.repositoryRoots = [repo.rootURL] 82 + 83 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 84 + manager.state(for: readOnly).notifications = [ 85 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Read 1", body: "done", isRead: true) 86 + ] 87 + manager.state(for: mixed).notifications = [ 88 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Read 2", body: "done", isRead: true), 89 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Unread", body: "new", isRead: false), 90 + ] 91 + 92 + let groups = state.toolbarNotificationGroups(terminalManager: manager) 93 + 94 + #expect(groups.count == 1) 95 + #expect(groups[0].notificationCount == 3) 96 + #expect(groups[0].unseenWorktreeCount == 1) 97 + } 98 + 99 + @Test func keepsReadOnlyNotificationsInGroups() { 100 + let repoPath = "/tmp/repo" 101 + let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) 102 + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) 103 + 104 + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [main, feature]) 105 + var state = RepositoriesFeature.State(repositories: [repo]) 106 + state.repositoryRoots = [repo.rootURL] 107 + 108 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 109 + manager.state(for: feature).notifications = [ 110 + WorktreeTerminalNotification(surfaceId: UUID(), title: "Read", body: "kept", isRead: true) 111 + ] 112 + 113 + let groups = state.toolbarNotificationGroups(terminalManager: manager) 114 + 115 + #expect(groups.map(\.id) == [repo.id]) 116 + #expect(groups[0].worktrees.map(\.id) == [feature.id]) 117 + #expect(groups[0].unseenWorktreeCount == 0) 118 + } 119 + 120 + private func makeWorktree( 121 + id: String, 122 + name: String, 123 + repoRoot: String 124 + ) -> Worktree { 125 + Worktree( 126 + id: id, 127 + name: name, 128 + detail: "detail", 129 + workingDirectory: URL(fileURLWithPath: id), 130 + repositoryRootURL: URL(fileURLWithPath: repoRoot) 131 + ) 132 + } 133 + 134 + private func makeRepository( 135 + id: String, 136 + name: String, 137 + worktrees: [Worktree] 138 + ) -> Repository { 139 + Repository( 140 + id: id, 141 + rootURL: URL(fileURLWithPath: id), 142 + name: name, 143 + worktrees: IdentifiedArray(uniqueElements: worktrees) 144 + ) 145 + } 146 + }
+126 -2
supacodeTests/WorktreeTerminalManagerTests.swift
··· 49 49 let worktree = makeWorktree() 50 50 let state = manager.state(for: worktree) 51 51 52 - state.hasUnseenNotification = true 52 + state.notifications = [ 53 + WorktreeTerminalNotification( 54 + surfaceId: UUID(), 55 + title: "Unread", 56 + body: "body", 57 + isRead: false 58 + ), 59 + ] 53 60 state.onNotificationIndicatorChanged?() 54 - state.hasUnseenNotification = false 61 + state.notifications = [ 62 + WorktreeTerminalNotification( 63 + surfaceId: UUID(), 64 + title: "Read", 65 + body: "body", 66 + isRead: true 67 + ), 68 + ] 55 69 56 70 let stream = manager.eventStream() 57 71 var iterator = stream.makeAsyncIterator() ··· 64 78 #expect(second == .setupScriptConsumed(worktreeID: worktree.id)) 65 79 } 66 80 81 + @Test func hasUnseenNotificationsReflectsUnreadEntries() { 82 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 83 + let worktree = makeWorktree() 84 + let state = manager.state(for: worktree) 85 + 86 + state.notifications = [ 87 + makeNotification(isRead: true), 88 + makeNotification(isRead: true), 89 + ] 90 + 91 + #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 92 + 93 + state.notifications.append(makeNotification(isRead: false)) 94 + 95 + #expect(manager.hasUnseenNotifications(for: worktree.id) == true) 96 + } 97 + 98 + @Test func markAllNotificationsReadEmitsUpdatedIndicatorCount() async { 99 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 100 + let worktree = makeWorktree() 101 + let state = manager.state(for: worktree) 102 + 103 + state.notifications = [ 104 + makeNotification(isRead: false), 105 + makeNotification(isRead: true), 106 + ] 107 + 108 + let stream = manager.eventStream() 109 + var iterator = stream.makeAsyncIterator() 110 + 111 + let first = await iterator.next() 112 + state.markAllNotificationsRead() 113 + let second = await iterator.next() 114 + 115 + #expect(first == .notificationIndicatorChanged(count: 1)) 116 + #expect(second == .notificationIndicatorChanged(count: 0)) 117 + #expect(state.notifications.map(\.isRead) == [true, true]) 118 + } 119 + 120 + @Test func markNotificationsReadOnlyAffectsMatchingSurface() { 121 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 122 + let worktree = makeWorktree() 123 + let state = manager.state(for: worktree) 124 + let surfaceA = UUID() 125 + let surfaceB = UUID() 126 + 127 + state.notifications = [ 128 + makeNotification(surfaceId: surfaceA, isRead: false), 129 + makeNotification(surfaceId: surfaceB, isRead: false), 130 + makeNotification(surfaceId: surfaceB, isRead: true), 131 + ] 132 + 133 + state.markNotificationsRead(forSurfaceID: surfaceB) 134 + 135 + let aNotifications = state.notifications.filter { $0.surfaceId == surfaceA } 136 + let bNotifications = state.notifications.filter { $0.surfaceId == surfaceB } 137 + 138 + #expect(aNotifications.map(\.isRead) == [false]) 139 + #expect(bNotifications.map(\.isRead) == [true, true]) 140 + #expect(manager.hasUnseenNotifications(for: worktree.id) == true) 141 + 142 + state.markNotificationsRead(forSurfaceID: surfaceA) 143 + 144 + #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 145 + } 146 + 147 + @Test func setNotificationsDisabledMarksAllRead() { 148 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 149 + let worktree = makeWorktree() 150 + let state = manager.state(for: worktree) 151 + 152 + state.notifications = [ 153 + makeNotification(isRead: false), 154 + makeNotification(isRead: false), 155 + ] 156 + 157 + state.setNotificationsEnabled(false) 158 + 159 + #expect(state.notifications.map(\.isRead) == [true, true]) 160 + #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 161 + } 162 + 163 + @Test func dismissAllNotificationsClearsState() { 164 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 165 + let worktree = makeWorktree() 166 + let state = manager.state(for: worktree) 167 + 168 + state.notifications = [ 169 + makeNotification(isRead: false), 170 + makeNotification(isRead: true), 171 + ] 172 + 173 + state.dismissAllNotifications() 174 + 175 + #expect(state.notifications.isEmpty) 176 + #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 177 + } 178 + 67 179 private func makeWorktree() -> Worktree { 68 180 Worktree( 69 181 id: "/tmp/repo/wt-1", ··· 82 194 return event 83 195 } 84 196 return nil 197 + } 198 + 199 + private func makeNotification( 200 + surfaceId: UUID = UUID(), 201 + isRead: Bool 202 + ) -> WorktreeTerminalNotification { 203 + WorktreeTerminalNotification( 204 + surfaceId: surfaceId, 205 + title: "Title", 206 + body: "Body", 207 + isRead: isRead 208 + ) 85 209 } 86 210 }