diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 044b0380..52bdcc7b 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -264,6 +264,9 @@ struct SupacodeApp: App { }, markNotificationRead: { worktreeID, notificationID in terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + }, + markNotificationsReadForSurface: { worktreeID, surfaceID in + terminalManager.markNotificationsRead(worktreeID: worktreeID, surfaceID: surfaceID) } ) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index da35439f..94187a70 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -8,6 +8,7 @@ struct TerminalClient { var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void + var markNotificationsReadForSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Void enum Command: Equatable { case createTab(Worktree, runSetupScriptIfNew: Bool) @@ -74,7 +75,8 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, - markNotificationRead: { _, _ in } + markNotificationRead: { _, _ in }, + markNotificationsReadForSurface: { _, _ in } ) static let testValue = TerminalClient( @@ -83,7 +85,8 @@ extension TerminalClient: DependencyKey { canvasFocusedWorktreeID: { nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, - markNotificationRead: { _, _ in } + markNotificationRead: { _, _ in }, + markNotificationsReadForSurface: { _, _ in } ) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 9f049646..d7f0a8fe 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -885,6 +885,7 @@ struct AppFeature { .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), .run { _ in _ = await terminalClient.focusSurface(worktreeID, surfaceID) + await terminalClient.markNotificationsReadForSurface(worktreeID, surfaceID) } ) @@ -943,6 +944,9 @@ struct AppFeature { case .commandPalette(.delegate(.refreshWorktrees)): return .send(.repositories(.refreshWorktrees)) + case .commandPalette(.delegate(.jumpToLatestUnread)): + return .send(.jumpToLatestUnread) + case .commandPalette(.delegate(.installCLI)): return .send(.settings(.installCLIButtonTapped(showAlert: false))) diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index d67e0dde..c906ff2c 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -31,6 +31,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case archiveWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees + case jumpToLatestUnread case ghosttyCommand(String) case openPullRequest(Worktree.ID) case openRepositoryOnCodeHost(Worktree.ID) @@ -52,7 +53,7 @@ struct CommandPaletteItem: Identifiable, Equatable { var isGlobal: Bool { switch kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI: + .refreshWorktrees, .installCLI, .jumpToLatestUnread: return true case .ghosttyCommand: return false @@ -81,7 +82,7 @@ struct CommandPaletteItem: Identifiable, Equatable { var isRootAction: Bool { switch kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI: + .refreshWorktrees, .installCLI, .jumpToLatestUnread: return true case .ghosttyCommand: return false @@ -120,6 +121,8 @@ struct CommandPaletteItem: Identifiable, Equatable { return AppShortcuts.CommandID.archivedWorktrees case .refreshWorktrees: return AppShortcuts.CommandID.refreshWorktrees + case .jumpToLatestUnread: + return AppShortcuts.CommandID.jumpToLatestUnread case .openPullRequest, .openRepositoryOnCodeHost: return AppShortcuts.CommandID.openPullRequest diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index e9f2f6d7..19a824c7 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -40,6 +40,7 @@ struct CommandPaletteFeature { case archiveWorktree(Worktree.ID, Repository.ID) case viewArchivedWorktrees case refreshWorktrees + case jumpToLatestUnread case ghosttyCommand(String) case openPullRequest(Worktree.ID) case markPullRequestReady(Worktree.ID) @@ -209,6 +210,14 @@ struct CommandPaletteFeature { kind: .refreshWorktrees ) ) + items.append( + CommandPaletteItem( + id: CommandPaletteItemID.globalJumpToLatestUnread, + title: "Jump to Latest Unread", + subtitle: nil, + kind: .jumpToLatestUnread + ) + ) items.append( CommandPaletteItem( id: CommandPaletteItemID.globalViewArchivedWorktrees, @@ -493,6 +502,7 @@ private enum CommandPaletteItemID { static let globalOpenRepository = "global.open-repository" static let globalNewWorktree = "global.new-worktree" static let globalRefreshWorktrees = "global.refresh-worktrees" + static let globalJumpToLatestUnread = "global.jump-to-latest-unread" static let globalViewArchivedWorktrees = "global.view-archived-worktrees" static let globalInstallCLI = "global.install-cli" @@ -503,6 +513,7 @@ private enum CommandPaletteItemID { globalOpenRepository, globalNewWorktree, globalRefreshWorktrees, + globalJumpToLatestUnread, globalViewArchivedWorktrees, globalInstallCLI, ] @@ -599,27 +610,16 @@ private func commandPaletteRecencyScore( } private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate { + if let appAction = appDelegateAction(for: kind) { + return appAction + } switch kind { case .worktreeSelect(let id): return .selectWorktree(id) - case .checkForUpdates: - return .checkForUpdates - case .openSettings: - return .openSettings - case .newWorktree: - return .newWorktree - case .openRepository: - return .openRepository case .removeWorktree(let worktreeID, let repositoryID): return .removeWorktree(worktreeID, repositoryID) case .archiveWorktree(let worktreeID, let repositoryID): return .archiveWorktree(worktreeID, repositoryID) - case .viewArchivedWorktrees: - return .viewArchivedWorktrees - case .refreshWorktrees: - return .refreshWorktrees - case .installCLI: - return .installCLI case .ghosttyCommand(let action): return .ghosttyCommand(action) case .changeFocusedTabIcon(let worktreeID): @@ -640,6 +640,38 @@ private func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPalette case .debugSimulateUpdateFound: return .debugSimulateUpdateFound #endif + case .checkForUpdates, + .openSettings, + .newWorktree, + .openRepository, + .viewArchivedWorktrees, + .refreshWorktrees, + .jumpToLatestUnread, + .installCLI: + fatalError("appDelegateAction should handle app-level command palette actions") + } +} + +private func appDelegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature.Delegate? { + switch kind { + case .checkForUpdates: + return .checkForUpdates + case .openSettings: + return .openSettings + case .newWorktree: + return .newWorktree + case .openRepository: + return .openRepository + case .viewArchivedWorktrees: + return .viewArchivedWorktrees + case .refreshWorktrees: + return .refreshWorktrees + case .jumpToLatestUnread: + return .jumpToLatestUnread + case .installCLI: + return .installCLI + default: + return nil } } @@ -673,6 +705,7 @@ private func pullRequestDelegateAction( .archiveWorktree, .viewArchivedWorktrees, .refreshWorktrees, + .jumpToLatestUnread, .installCLI, .ghosttyCommand, .changeFocusedTabIcon: diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index c4d716c2..61377a32 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -348,7 +348,7 @@ private struct CommandPaletteRowView: View { private var badge: String? { switch row.kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .ghosttyCommand, + .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, @@ -379,6 +379,8 @@ private struct CommandPaletteRowView: View { return "archivebox" case .refreshWorktrees: return "arrow.clockwise" + case .jumpToLatestUnread: + return "bell.badge" case .ghosttyCommand: return "terminal" case .openPullRequest, .openRepositoryOnCodeHost: @@ -419,7 +421,7 @@ private struct CommandPaletteRowView: View { private var emphasis: Bool { switch row.kind { case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .viewArchivedWorktrees, - .refreshWorktrees, .installCLI, .ghosttyCommand, + .refreshWorktrees, .installCLI, .jumpToLatestUnread, .ghosttyCommand, .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, .mergePullRequest, .closePullRequest, .copyFailingJobURL, .copyCiFailureLogs, @@ -515,6 +517,8 @@ private struct CommandPaletteRowView: View { base = "View Archived Worktrees" case .refreshWorktrees: base = "Refresh Worktrees" + case .jumpToLatestUnread: + base = "Jump to Latest Unread" case .ghosttyCommand: base = row.title case .removeWorktree: diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 109334b3..48294c16 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -466,6 +466,10 @@ final class WorktreeTerminalManager { states[worktreeID]?.markNotificationRead(id: notificationID) } + func markNotificationsRead(worktreeID: Worktree.ID, surfaceID: UUID) { + states[worktreeID]?.markNotificationsRead(forSurfaceID: surfaceID) + } + func surfaceBackgroundOpacity() -> Double { runtime?.backgroundOpacity() ?? 1.0 } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index a4244deb..a245da9d 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -79,6 +79,15 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.updates.checkForUpdates) } + @Test(.dependencies) func jumpToLatestUnreadDispatchesAppAction() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.commandPalette(.delegate(.jumpToLatestUnread))) + await store.receive(\.jumpToLatestUnread) + } + @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToTerminalClient() async { let worktree = makeWorktree( id: "/tmp/repo-ghostty/wt-1", diff --git a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift index d445ae64..daab59ce 100644 --- a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift +++ b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift @@ -58,6 +58,7 @@ struct AppFeatureJumpToLatestUnreadTests { var repositoriesState = RepositoriesFeature.State(repositories: [repository]) repositoriesState.snapshotPersistencePhase = .active let focusedSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) + let readSurfaces = LockIsolated<[(Worktree.ID, UUID)]>([]) let store = TestStore( initialState: AppFeature.State(repositories: repositoriesState) ) { @@ -67,6 +68,9 @@ struct AppFeatureJumpToLatestUnreadTests { focusedSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } return true } + $0.terminalClient.markNotificationsReadForSurface = { worktreeID, targetSurfaceID in + readSurfaces.withValue { $0.append((worktreeID, targetSurfaceID)) } + } } store.exhaustivity = .off @@ -77,6 +81,7 @@ struct AppFeatureJumpToLatestUnreadTests { await store.finish() #expect(focusedSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) + #expect(readSurfaces.value.map { "\($0.0)|\($0.1.uuidString)" } == ["\(worktree.id)|\(surfaceID.uuidString)"]) } private func makeWorktree() -> Worktree { diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 5bf5fdbd..954866dd 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -16,6 +16,7 @@ struct CommandPaletteFeatureTests { "global.open-repository", "global.new-worktree", "global.refresh-worktrees", + "global.jump-to-latest-unread", "global.view-archived-worktrees", "global.install-cli", ] @@ -29,6 +30,18 @@ struct CommandPaletteFeatureTests { expectNoDifference(items.map(\.id), expectedIDs) } + @Test func commandPaletteItems_includeJumpToLatestUnreadAction() { + let items = CommandPaletteFeature.commandPaletteItems(from: RepositoriesFeature.State()) + let item = items.first { $0.id == "global.jump-to-latest-unread" } + + #expect(item?.title == "Jump to Latest Unread") + #expect(item?.kind == .jumpToLatestUnread) + #expect(item?.appShortcutCommandID == AppShortcuts.CommandID.jumpToLatestUnread) + + let filtered = CommandPaletteFeature.filterItems(items: items, query: "jump unread") + #expect(filtered.first?.id == "global.jump-to-latest-unread") + } + @Test func commandPaletteItems_skipsPendingAndDeletingWorktrees() { let rootPath = "/tmp/repo" let keep = makeWorktree(id: "\(rootPath)/wt-keep", name: "keep", repoRoot: rootPath)