From 93966c01f425dcac6de533550dfb5b1ecff841dd Mon Sep 17 00:00:00 2001 From: serennan Date: Tue, 14 Jul 2026 09:02:59 +0800 Subject: [PATCH 1/2] Add a menu bar status item with three-state visibility (#645, #623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a macOS menu bar status item for Supacode, gated behind a new three-state AppVisibility setting (dock / dockAndMenuBar / menuBar, default dock — the menu bar is opt-in). The menuBar mode runs the app as an accessory via NSApp.setActivationPolicy(.accessory), hiding the Dock icon (#623); the policy is applied at launch and whenever visibility changes, and the main window is surfaced when the Dock reappears so the app is never stranded. The status item shows an SC monogram with a red dot while anything is unread. Its dropdown lists the worktrees that need attention (unread notifications or an active agent) rather than the notification bodies — system notifications already surface those; clicking a worktree activates the app and selects it. Menu actions: Show Main Window, Mark All as Read, Settings, Quit. Settings gain a header-less visibility card group (mirroring the Appearance cards, with SF Symbol placeholders) before the Editor section under General. Closes #645 Closes #623 --- .../Reducer/SettingsFeature.swift | 16 +- .../Views/AppVisibilityOptionCardView.swift | 40 ++++ .../Views/AppearanceSettingsView.swift | 14 ++ .../Models/AppVisibility.swift | 38 ++++ .../Models/GlobalSettings.swift | 23 +- supacode/App/WindowSurfacing.swift | 11 + supacode/App/supacodeApp.swift | 30 +++ .../Clients/Terminal/TerminalClient.swift | 4 + .../App/Models/MenuBarNotificationList.swift | 97 +++++++++ .../App/Models/WorktreeMenuSnapshot.swift | 4 +- .../Features/App/Reducer/AppFeature.swift | 74 +++++++ .../App/Views/MenuBarNotificationsMenu.swift | 85 ++++++++ .../WorktreeTerminalManager.swift | 8 + .../AppFeatureMenuBarNotificationsTests.swift | 202 ++++++++++++++++++ .../MenuBarNotificationListTests.swift | 94 ++++++++ supacodeTests/SettingsFeatureTests.swift | 37 ++++ .../SettingsFilePersistenceTests.swift | 106 +++++++++ 17 files changed, 879 insertions(+), 4 deletions(-) create mode 100644 SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift create mode 100644 SupacodeSettingsShared/Models/AppVisibility.swift create mode 100644 supacode/Features/App/Models/MenuBarNotificationList.swift create mode 100644 supacode/Features/App/Views/MenuBarNotificationsMenu.swift create mode 100644 supacodeTests/AppFeatureMenuBarNotificationsTests.swift create mode 100644 supacodeTests/MenuBarNotificationListTests.swift diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 6322516bc..270e37834 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -74,6 +74,7 @@ public struct SettingsFeature { public var confirmQuitMode: ConfirmQuitMode public var terminateSessionsOnQuit: Bool public var remoteSessionPersistenceEnabled: Bool + public var appVisibility: AppVisibility public var cliInstallState = CLIInstallState.checking /// Aggregate per-agent install state for the unified integration row. public var agentIntegrationStates: [SkillAgent: AgentIntegrationRowState] = [:] @@ -123,6 +124,7 @@ public struct SettingsFeature { confirmQuitMode = settings.confirmQuitMode terminateSessionsOnQuit = settings.terminateSessionsOnQuit remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled + appVisibility = settings.appVisibility defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" } @@ -163,7 +165,8 @@ public struct SettingsFeature { autoUpdateAgentIntegrationsEnabled: autoUpdateAgentIntegrationsEnabled, confirmQuitMode: confirmQuitMode, terminateSessionsOnQuit: terminateSessionsOnQuit, - remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled + remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, + appVisibility: appVisibility ) } } @@ -174,6 +177,7 @@ public struct SettingsFeature { case repositoriesChanged([SettingsRepositorySummary]) case setSelection(SettingsSection?) case setSystemNotificationsEnabled(Bool) + case setAppVisibility(AppVisibility) case setAutomatedActionPolicy(AutomatedActionPolicy) case showNotificationPermissionAlert(errorMessage: String?) case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) @@ -302,6 +306,7 @@ public struct SettingsFeature { state.confirmQuitMode = normalizedSettings.confirmQuitMode state.terminateSessionsOnQuit = normalizedSettings.terminateSessionsOnQuit state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled + state.appVisibility = normalizedSettings.appVisibility state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.syncGlobalDefaults(from: normalizedSettings) synchronizeRepositorySelection(for: &state) @@ -328,6 +333,15 @@ public struct SettingsFeature { state.syncGlobalDefaults(from: state.globalSettings) return persist(state) + case .setAppVisibility(let visibility): + // Deduped here (not just at the caller) because `MenuBarExtra`'s + // `isInserted` binding re-writes the current value on every scene + // evaluation; persisting each echo would loop scene -> persist -> scene. + guard state.appVisibility != visibility else { return .none } + state.appVisibility = visibility + state.syncGlobalDefaults(from: state.globalSettings) + return persist(state) + case .setAutomatedActionPolicy(let policy): state.automatedActionPolicy = policy state.syncGlobalDefaults(from: state.globalSettings) diff --git a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift new file mode 100644 index 000000000..ae46e9212 --- /dev/null +++ b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift @@ -0,0 +1,40 @@ +import SupacodeSettingsShared +import SwiftUI + +/// One card in the Dock/menu-bar visibility picker, mirroring +/// `AppearanceOptionCardView`. Uses an SF Symbol placeholder until dedicated +/// artwork lands. +struct AppVisibilityOptionCardView: View { + let visibility: AppVisibility + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .aspectRatio(1.6, contentMode: .fit) + .overlay { + Image(systemName: visibility.symbolName) + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + } + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isSelected ? Color.accentColor : .clear, + lineWidth: 2 + ) + } + .accessibilityLabel(visibility.title) + Text(visibility.title) + .font(.callout) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(isSelected ? .primary : .secondary) + } + } + .buttonStyle(.plain) + } +} diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index 22fb1d1a5..05461b0f1 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -59,6 +59,20 @@ public struct AppearanceSettingsView: View { ) } } + Section { + LabeledContent("Visibility") { + HStack(spacing: 12) { + ForEach(AppVisibility.allCases) { visibility in + AppVisibilityOptionCardView( + visibility: visibility, + isSelected: visibility == store.appVisibility + ) { + store.send(.setAppVisibility(visibility)) + } + } + } + } + } Section("Editor") { Picker( selection: $store.defaultEditorID diff --git a/SupacodeSettingsShared/Models/AppVisibility.swift b/SupacodeSettingsShared/Models/AppVisibility.swift new file mode 100644 index 000000000..d6858e215 --- /dev/null +++ b/SupacodeSettingsShared/Models/AppVisibility.swift @@ -0,0 +1,38 @@ +/// Where Supacode shows up on the system: the Dock, the menu bar, or both. +/// At least one surface is always enabled — there is no "hidden everywhere" +/// case — so the app is never unreachable. +public enum AppVisibility: String, CaseIterable, Identifiable, Codable, Sendable { + case dock + case dockAndMenuBar + case menuBar + + public var id: String { rawValue } + + public var title: String { + switch self { + case .dock: "Dock" + case .dockAndMenuBar: "Dock & Menu Bar" + case .menuBar: "Menu Bar" + } + } + + /// SF Symbol used by the settings option cards. Placeholder art until a + /// dedicated asset lands; conveys "dock" / "both" / "menu bar" at a glance. + public var symbolName: String { + switch self { + case .dock: "dock.rectangle" + case .dockAndMenuBar: "menubar.dock.rectangle" + case .menuBar: "menubar.rectangle" + } + } + + /// True when this mode keeps the menu bar status item inserted. + public var showsMenuBarIcon: Bool { + self == .dockAndMenuBar || self == .menuBar + } + + /// True when this mode hides the Dock icon (runs as an accessory app). + public var hidesDockIcon: Bool { + self == .menuBar + } +} diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 43271b538..c93788414 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -70,6 +70,10 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// When true, remote surfaces wrap their session in zmx on the host when /// the host has it installed, so the session survives disconnects. public var remoteSessionPersistenceEnabled: Bool + /// Where Supacode appears: Dock, menu bar, or both. The menu bar status item + /// is inserted when this includes the menu bar; the Dock icon is hidden in + /// `.menuBar` mode. + public var appVisibility: AppVisibility public static let `default` = GlobalSettings( appearanceMode: .dark, @@ -104,7 +108,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoUpdateAgentIntegrationsEnabled: true, confirmQuitMode: .auto, terminateSessionsOnQuit: false, - remoteSessionPersistenceEnabled: true + remoteSessionPersistenceEnabled: true, + appVisibility: .dock ) public init( @@ -140,7 +145,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { autoUpdateAgentIntegrationsEnabled: Bool = true, confirmQuitMode: ConfirmQuitMode = .auto, terminateSessionsOnQuit: Bool = false, - remoteSessionPersistenceEnabled: Bool = true + remoteSessionPersistenceEnabled: Bool = true, + appVisibility: AppVisibility = .dock ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -175,6 +181,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.confirmQuitMode = confirmQuitMode self.terminateSessionsOnQuit = terminateSessionsOnQuit self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled + self.appVisibility = appVisibility } /// Keys for reading renamed settings fields that no longer @@ -337,5 +344,17 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { remoteSessionPersistenceEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteSessionPersistenceEnabled) ?? Self.default.remoteSessionPersistenceEnabled + // Prefer the new three-state field; fall back to the legacy boolean + // (`showMenuBarIcon`), which always shipped alongside a Dock icon — + // `true` → both surfaces, `false` → Dock only. Missing both → default. + if let visibility = try container.decodeIfPresent(AppVisibility.self, forKey: .appVisibility) { + appVisibility = visibility + } else if let legacyShowMenuBarIcon = try legacy.decodeIfPresent( + Bool.self, forKey: LegacyCodingKey(stringValue: "showMenuBarIcon")!) + { + appVisibility = legacyShowMenuBarIcon ? .dockAndMenuBar : .dock + } else { + appVisibility = Self.default.appVisibility + } } } diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index a076d7977..aa2e6f928 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -1,6 +1,17 @@ import AppKit +import SupacodeSettingsShared extension NSApplication { + /// Applies the Dock/menu-bar visibility mode: `.menuBar` runs as an + /// accessory (no Dock icon), every other mode as a regular app. Idempotent — + /// safe to call on every settings change. + @MainActor + func applyActivationPolicy(for visibility: AppVisibility) { + let policy: NSApplication.ActivationPolicy = visibility.hidesDockIcon ? .accessory : .regular + guard activationPolicy() != policy else { return } + setActivationPolicy(policy) + } + /// Brings the main window forward, deminiaturizing if needed. /// /// Falls back to any non-`NSPanel`, non-settings window so a stale diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 39d3f8703..e3a0b123e 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -76,6 +76,11 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { // the main window. Opt the singleton out per-process so a panel // left open from a previous session can't survive the relaunch. NSColorPanel.shared.isRestorable = false + // Apply the saved Dock/menu-bar visibility before the first window shows, + // so a menu-bar-only user never flashes a Dock icon at launch. + if let visibility = appStore?.state.settings.appVisibility { + NSApplication.shared.applyActivationPolicy(for: visibility) + } appStore?.send(.appLaunched) } @@ -266,6 +271,9 @@ struct SupacodeApp: App { markNotificationRead: { worktreeID, notificationID in terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) }, + markAllNotificationsRead: { + terminalManager.markAllNotificationsRead() + }, hasInflightBlockingScripts: { terminalManager.hasInflightBlockingScripts }, @@ -572,5 +580,27 @@ struct SupacodeApp: App { .windowToolbarStyle(.unified) .defaultSize(width: 720, height: 640) .restorationBehavior(.disabled) + MenuBarExtra(isInserted: menuBarInserted) { + MenuBarNotificationsMenu(store: store) + } label: { + MenuBarNotificationsLabel(store: store) + } + .menuBarExtraStyle(.menu) + } + + /// Visibility-backed insertion so switching modes (or Cmd-dragging the icon + /// out of the menu bar) adds/removes the status item live. Dragging the icon + /// out falls back to `.dock`, which keeps at least one surface enabled. + private var menuBarInserted: Binding { + Binding( + get: { store.settings.appVisibility.showsMenuBarIcon }, + set: { newValue in + // MenuBarExtra re-writes the current value on every scene evaluation; + // only a real flip may reach the store or the persist round-trip + // re-evaluates the scene and loops the app at launch. + guard newValue != store.settings.appVisibility.showsMenuBarIcon else { return } + store.send(.settings(.setAppVisibility(newValue ? .dockAndMenuBar : .dock))) + } + ) } } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index f6b379e03..d2e8e700f 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -16,6 +16,8 @@ struct TerminalClient { var selectedSurfaceID: @MainActor @Sendable (Worktree.ID) -> UUID? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void + /// Marks every notification in every worktree read (menu bar "Mark All as Read"). + var markAllNotificationsRead: @MainActor @Sendable () -> Void /// Blocking scripts (setup / archive / delete / run) bypass zmx and die /// with the app, so the auto-mode quit confirmation needs to know. var hasInflightBlockingScripts: @MainActor @Sendable () -> Bool @@ -123,6 +125,7 @@ extension TerminalClient: DependencyKey { selectedSurfaceID: { _ in fatalError("TerminalClient.selectedSurfaceID not configured") }, latestUnreadNotification: { fatalError("TerminalClient.latestUnreadNotification not configured") }, markNotificationRead: { _, _ in fatalError("TerminalClient.markNotificationRead not configured") }, + markAllNotificationsRead: { fatalError("TerminalClient.markAllNotificationsRead not configured") }, hasInflightBlockingScripts: { fatalError("TerminalClient.hasInflightBlockingScripts not configured") }, terminateAllSessions: { fatalError("TerminalClient.terminateAllSessions not configured") }, reapOrphanSessions: { _ in fatalError("TerminalClient.reapOrphanSessions not configured") }, @@ -140,6 +143,7 @@ extension TerminalClient: DependencyKey { selectedSurfaceID: unimplemented("TerminalClient.selectedSurfaceID", placeholder: nil), latestUnreadNotification: unimplemented("TerminalClient.latestUnreadNotification", placeholder: nil), markNotificationRead: unimplemented("TerminalClient.markNotificationRead"), + markAllNotificationsRead: unimplemented("TerminalClient.markAllNotificationsRead"), hasInflightBlockingScripts: unimplemented("TerminalClient.hasInflightBlockingScripts", placeholder: false), terminateAllSessions: unimplemented("TerminalClient.terminateAllSessions"), reapOrphanSessions: unimplemented("TerminalClient.reapOrphanSessions"), diff --git a/supacode/Features/App/Models/MenuBarNotificationList.swift b/supacode/Features/App/Models/MenuBarNotificationList.swift new file mode 100644 index 000000000..e7527682e --- /dev/null +++ b/supacode/Features/App/Models/MenuBarNotificationList.swift @@ -0,0 +1,97 @@ +import Foundation +import IdentifiedCollections +import OrderedCollections +import SupacodeSettingsShared + +/// One worktree that currently wants attention in the menu bar dropdown: +/// it has unread notifications, a live agent, or both. +struct MenuBarWorktreeRow: Identifiable, Equatable { + let id: Worktree.ID + let repoName: String + let worktreeName: String + let unreadCount: Int + let hasActiveAgent: Bool +} + +/// Menu-sized projection of the worktrees needing attention: those with unread +/// notifications or an active agent, unread ones first. The dropdown lists +/// *which* sessions want attention (system notifications already carry the +/// message text), so this intentionally does not repeat notification bodies. +struct MenuBarNotificationList: Equatable { + var rows: [MenuBarWorktreeRow] = [] + /// True when any listed worktree has unread notifications, so "Mark All as + /// Read" can gate on it. + var hasUnread = false + + static func compute(rows input: [MenuBarWorktreeRow]) -> Self { + let attention = input.filter { $0.unreadCount > 0 || $0.hasActiveAgent } + let sorted = attention.sorted { lhs, rhs in + let lhsUnread = lhs.unreadCount > 0 + let rhsUnread = rhs.unreadCount > 0 + // Unread worktrees float above active-only ones. + if lhsUnread != rhsUnread { return lhsUnread } + if lhs.unreadCount != rhs.unreadCount { return lhs.unreadCount > rhs.unreadCount } + return lhs.worktreeName.localizedCaseInsensitiveCompare(rhs.worktreeName) == .orderedAscending + } + return Self(rows: sorted, hasUnread: attention.contains { $0.unreadCount > 0 }) + } +} + +extension RepositoriesFeature.State { + /// Candidate rows for the menu bar dropdown — one per worktree that has a + /// sidebar row, carrying its unread count and agent-activity flag. Mirrors the + /// repository grouping of `computeToolbarNotificationGroups` so repo/worktree + /// names match the sidebar. `MenuBarNotificationList.compute` does the + /// attention filtering and ordering. + func menuBarWorktreeRows() -> [MenuBarWorktreeRow] { + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + var orderedIDs = orderedRepositoryIDs() + let coveredIDs = Set(orderedIDs) + for repository in repositories where repository.host != nil && !coveredIDs.contains(repository.id) { + orderedIDs.append(repository.id) + } + + var rows: [MenuBarWorktreeRow] = [] + for repositoryID in orderedIDs { + guard let repository = repositoriesByID[repositoryID] else { continue } + let isFolder = !repository.isGitRepository + let folderRow = isFolder ? sidebarItems[id: Repository.folderWorktreeID(for: repository.rootURL)] : nil + let section = sidebar.sections[repositoryID] + let repoName = + isFolder + ? (folderRow?.resolvedSidebarTitle ?? repository.name) + : Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name) + + for worktree in orderedWorktrees(in: repository) { + guard let row = sidebarItems[id: worktree.id] else { continue } + let unread = row.notifications.count { !$0.isRead } + guard unread > 0 || row.hasAgentActivity else { continue } + rows.append( + MenuBarWorktreeRow( + id: worktree.id, + repoName: repoName, + worktreeName: row.resolvedSidebarTitle ?? worktree.name, + unreadCount: unread, + hasActiveAgent: row.hasAgentActivity + ) + ) + } + } + return rows + } +} + +extension WorktreeTerminalNotification { + /// Display headline: agent notifications carry the agent slug as `title`, so + /// headline with the live session (tab) title when one exists, else the agent + /// display name. Non-agent notifications keep their own title (it's real + /// content, not a slug). + func headline(sessionTitle: String?) -> String { + let agent = SkillAgent(rawValue: title.lowercased()) + let fallbackTitle = agent?.displayName ?? (title.isEmpty ? "Terminal" : title) + // An unrenamed tab is titled with the bare process name ("claude"); map + // that back to the display name rather than headlining the raw slug. + let resolvedSessionTitle = sessionTitle.map { SkillAgent(rawValue: $0.lowercased())?.displayName ?? $0 } + return (agent != nil ? resolvedSessionTitle : nil) ?? fallbackTitle + } +} diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 415de78c8..1a20772cf 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -123,7 +123,9 @@ extension AppFeature.Action { .worktreeSettingsLoaded, .openSelectedWorktree, .revealInFinder, .openWorktree, .openWorktreeFailed, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, - .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, .runScript, .runNamedScript, + .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, + .menuBarNotificationSelected, .menuBarWorktreeSelected, .showNotificationsPane, + .markAllNotificationsRead, .runScript, .runNamedScript, .stopScript, .stopRunScripts, .closeTab, .closeSurface, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a9d4339e4..1d3fa9750 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -84,6 +84,7 @@ struct AppFeature { var hasAnyTerminalSurface: Bool = false var lastKnownSystemNotificationsEnabled: Bool var lastKnownAgentPresenceBadgesEnabled: Bool + var lastKnownAppVisibility: AppVisibility var pendingDeeplinks: [Deeplink] = [] var isDeeplinkReferenceRequested = false /// Cached projection of every primitive the menu-bar `WorktreeCommands` @@ -114,6 +115,7 @@ struct AppFeature { self.settings = settings lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled + lastKnownAppVisibility = settings.appVisibility // Seed from settings so `state.allScripts` doesn't start empty before the // first `settingsChanged` delegate fires. Globals aren't worktree-scoped, // so deselection (line below in `selectedWorktreeChanged(nil)`) @@ -208,6 +210,11 @@ struct AppFeature { case selectTerminalTabAtIndex(Int) case splitTerminal(TerminalSplitMenuDirection) case jumpToLatestUnread + case menuBarNotificationSelected( + worktreeID: Worktree.ID, tabID: TerminalTabID?, surfaceID: UUID, notificationID: UUID) + case menuBarWorktreeSelected(worktreeID: Worktree.ID) + case showNotificationsPane + case markAllNotificationsRead case runScript case runNamedScript(ScriptDefinition) case stopScript(ScriptDefinition) @@ -524,6 +531,12 @@ struct AppFeature { let agentBadgesFlipped = settings.agentPresenceBadgesEnabled != state.lastKnownAgentPresenceBadgesEnabled state.lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled + let visibilityChanged = settings.appVisibility != state.lastKnownAppVisibility + // Surface the main window when the Dock icon comes back, so leaving + // menu-bar-only mode never strands the user without a window. + let dockIconReappeared = + state.lastKnownAppVisibility.hidesDockIcon && !settings.appVisibility.hidesDockIcon + state.lastKnownAppVisibility = settings.appVisibility // Compare IDs as a set — name/command edits and pure reorders should not re-prune recency. let globalScriptIDsChanged = Set(state.globalScripts.map(\.id)) != Set(settings.globalScripts.map(\.id)) state.globalScripts = settings.globalScripts @@ -580,6 +593,16 @@ struct AppFeature { } }, ] + if visibilityChanged { + effects.append( + .run { @MainActor _ in + NSApplication.shared.applyActivationPolicy(for: settings.appVisibility) + if dockIconReappeared { + NSApplication.shared.surfaceMainWindow() + } + } + ) + } if globalScriptIDsChanged { effects.append(pruneScriptRecencyEffect(state: state)) } @@ -746,6 +769,57 @@ struct AppFeature { } ) + case .menuBarNotificationSelected(let worktreeID, let tabID, let surfaceID, let notificationID): + guard let worktree = state.repositories.worktree(for: worktreeID) else { + jumpLogger.warning( + "menuBarNotificationSelected: worktree \(worktreeID) vanished between menu render and click." + ) + return .none + } + analyticsClient.capture("menu_bar_notification_selected", nil) + // The notification may predate a tab move; re-resolve the surface's + // current tab when the recorded one is gone. A nil resolution means + // the surface closed — still surface the app and mark the item read. + let resolvedTabID = tabID ?? terminalClient.tabID(worktreeID, surfaceID) + return .merge( + .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), + .run { @MainActor _ in + NSApplication.shared.surfaceMainWindow() + if let resolvedTabID { + terminalClient.send(.focusSurface(worktree, tabID: resolvedTabID, surfaceID: surfaceID)) + } + terminalClient.markNotificationRead(worktreeID, notificationID) + } + ) + + case .menuBarWorktreeSelected(let worktreeID): + guard state.repositories.worktree(for: worktreeID) != nil else { + jumpLogger.warning( + "menuBarWorktreeSelected: worktree \(worktreeID) vanished between menu render and click." + ) + return .none + } + analyticsClient.capture("menu_bar_worktree_selected", nil) + return .merge( + .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), + .run { @MainActor _ in NSApplication.shared.surfaceMainWindow() } + ) + + case .showNotificationsPane: + let surfaceWindow: Effect = .run { @MainActor _ in + NSApplication.shared.surfaceMainWindow() + } + // Toggle only when the notifications pane isn't already up, so a + // second menu invocation surfaces the window instead of closing it. + if state.repositories.inspectorPresented, state.repositories.inspectorPane == .notifications { + return surfaceWindow + } + return .merge(surfaceWindow, .send(.repositories(.toggleInspectorPane(.notifications)))) + + case .markAllNotificationsRead: + analyticsClient.capture("notifications_mark_all_read", nil) + return .run { _ in await terminalClient.markAllNotificationsRead() } + case .runScript: // Find the selected or primary script and run it. guard let definition = state.primaryScript else { diff --git a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift new file mode 100644 index 000000000..9f0704396 --- /dev/null +++ b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift @@ -0,0 +1,85 @@ +import AppKit +import ComposableArchitecture +import SupacodeSettingsFeature +import SwiftUI + +/// Contents of the menu bar extra: the worktrees that currently want attention +/// (unread notifications or an active agent) on top, then quick actions. +/// Rendered with the native `.menu` style, so rows are plain menu items +/// (title + subtitle Texts). It lists *which* sessions need attention rather +/// than repeating notification bodies — system notifications already carry +/// the message text. +struct MenuBarNotificationsMenu: View { + let store: StoreOf + + var body: some View { + let list = MenuBarNotificationList.compute(rows: store.repositories.menuBarWorktreeRows()) + Group { + if list.rows.isEmpty { + Text("No Sessions Need Attention") + } else { + ForEach(list.rows) { row in + Button { + store.send(.menuBarWorktreeSelected(worktreeID: row.id)) + } label: { + Text(row.worktreeName) + Text(verbatim: subtitle(for: row)) + } + .help("Open this worktree.") + } + } + Divider() + Button("Mark All as Read") { + store.send(.markAllNotificationsRead) + } + .disabled(!list.hasUnread) + .help("Mark every notification as read.") + Button("Show Main Window") { + NSApplication.shared.surfaceMainWindow() + } + .help("Bring the main Supacode window to the front.") + Button("Settings...") { + store.send(.settings(.setSelection(.general))) + } + .help("Open Supacode settings.") + Divider() + Button("Quit Supacode") { + store.send(.requestQuit) + } + .help("Quit Supacode.") + } + } + + private func subtitle(for row: MenuBarWorktreeRow) -> String { + var parts: [String] = [row.repoName] + if row.unreadCount > 0 { + parts.append(row.unreadCount == 1 ? "1 unread" : "\(row.unreadCount) unread") + } + if row.hasActiveAgent { + parts.append("agent active") + } + return parts.joined(separator: " · ") + } +} + +/// Status item label: an "SC" monogram that adapts to the menu bar's light/dark +/// appearance, with a red dot while anything is unread. Kept as its own view so +/// notification churn invalidates just this label. +struct MenuBarNotificationsLabel: View { + let store: StoreOf + + var body: some View { + let hasUnread = store.repositories.toolbarNotificationGroupsCache.contains { $0.unseenWorktreeCount > 0 } + ZStack(alignment: .topTrailing) { + Text("SC") + .font(.system(size: 12, weight: .bold, design: .rounded)) + if hasUnread { + Circle() + .fill(.red) + .frame(width: 5, height: 5) + .offset(x: 4, y: -2) + } + } + .accessibilityLabel(hasUnread ? "Supacode, unread notifications" : "Supacode") + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 7923dab87..a9c7f1466 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1064,6 +1064,14 @@ final class WorktreeTerminalManager { emitProjection(for: worktreeID) } + /// Marks every notification in every managed worktree read. Indicator and + /// projection updates propagate via each state's notification callbacks. + func markAllNotificationsRead() { + for state in states.values { + state.markAllNotificationsRead() + } + } + /// Embed `agentsBySurface` in each surface so badges survive relaunch. func saveAllLayoutSnapshots( agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] diff --git a/supacodeTests/AppFeatureMenuBarNotificationsTests.swift b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift new file mode 100644 index 000000000..97191b3e6 --- /dev/null +++ b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift @@ -0,0 +1,202 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import SupacodeSettingsFeature +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +struct AppFeatureMenuBarNotificationsTests { + @Test(.dependencies) func menuBarNotificationSelectedFocusesSurfaceAndMarksRead() async { + let worktree = makeWorktree() + let tabID = TerminalTabID() + let surfaceID = UUID() + let notificationID = UUID() + let focused = LockIsolated<[TerminalClient.Command]>([]) + let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) + let store = makeStore(worktree: worktree) { + $0.terminalClient.send = { command in + focused.withValue { $0.append(command) } + } + $0.terminalClient.markNotificationRead = { worktreeID, notificationID in + marked.withValue { $0.append((worktreeID, notificationID)) } + } + } + + await store.send( + .menuBarNotificationSelected( + worktreeID: worktree.id, tabID: tabID, surfaceID: surfaceID, notificationID: notificationID + ) + ) + await store.receive(\.repositories.selectWorktree) + await store.finish() + + let focusCommands = focused.value.filter { + if case .focusSurface = $0 { return true } else { return false } + } + #expect( + focusCommands == [.focusSurface(worktree, tabID: tabID, surfaceID: surfaceID, input: nil)] + ) + #expect(marked.value.count == 1) + #expect(marked.value.first?.0 == worktree.id) + #expect(marked.value.first?.1 == notificationID) + } + + @Test(.dependencies) func menuBarNotificationSelectedResolvesTabWhenMissing() async { + let worktree = makeWorktree() + let resolvedTabID = TerminalTabID() + let surfaceID = UUID() + let focused = LockIsolated<[TerminalClient.Command]>([]) + let store = makeStore(worktree: worktree) { + $0.terminalClient.tabID = { _, _ in resolvedTabID } + $0.terminalClient.send = { command in + focused.withValue { $0.append(command) } + } + $0.terminalClient.markNotificationRead = { _, _ in } + } + + await store.send( + .menuBarNotificationSelected( + worktreeID: worktree.id, tabID: nil, surfaceID: surfaceID, notificationID: UUID() + ) + ) + await store.finish() + + let focusCommands = focused.value.filter { + if case .focusSurface = $0 { return true } else { return false } + } + #expect( + focusCommands == [.focusSurface(worktree, tabID: resolvedTabID, surfaceID: surfaceID, input: nil)] + ) + } + + @Test(.dependencies) func menuBarNotificationSelectedStillMarksReadWhenSurfaceGone() async { + let worktree = makeWorktree() + let focused = LockIsolated<[TerminalClient.Command]>([]) + let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) + let notificationID = UUID() + let store = makeStore(worktree: worktree) { + $0.terminalClient.tabID = { _, _ in nil } + $0.terminalClient.send = { command in + focused.withValue { $0.append(command) } + } + $0.terminalClient.markNotificationRead = { worktreeID, notificationID in + marked.withValue { $0.append((worktreeID, notificationID)) } + } + } + + await store.send( + .menuBarNotificationSelected( + worktreeID: worktree.id, tabID: nil, surfaceID: UUID(), notificationID: notificationID + ) + ) + await store.finish() + + let focusCommands = focused.value.filter { + if case .focusSurface = $0 { return true } else { return false } + } + #expect(focusCommands.isEmpty) + #expect(marked.value.count == 1) + #expect(marked.value.first?.1 == notificationID) + } + + @Test(.dependencies) func menuBarWorktreeSelectedSelectsWorktree() async { + let worktree = makeWorktree() + let store = makeStore(worktree: worktree) { _ in } + + await store.send(.menuBarWorktreeSelected(worktreeID: worktree.id)) + await store.receive(\.repositories.selectWorktree) + await store.finish() + + #expect(store.state.repositories.selectedWorktreeID == worktree.id) + } + + @Test(.dependencies) func markAllNotificationsReadForwardsToTerminalClient() async { + let worktree = makeWorktree() + let calls = LockIsolated(0) + let store = makeStore(worktree: worktree) { + $0.terminalClient.markAllNotificationsRead = { + calls.withValue { $0 += 1 } + } + } + + await store.send(.markAllNotificationsRead) + await store.finish() + + #expect(calls.value == 1) + } + + @Test(.dependencies) func showNotificationsPanePresentsNotificationsInspector() async { + let worktree = makeWorktree() + let store = makeStore(worktree: worktree) { _ in } + + await store.send(.showNotificationsPane) + await store.receive(\.repositories.toggleInspectorPane) + await store.finish() + + #expect(store.state.repositories.inspectorPresented) + #expect(store.state.repositories.inspectorPane == .notifications) + } + + @Test(.dependencies) func showNotificationsPaneDoesNotToggleAwayWhenAlreadyPresented() async { + let worktree = makeWorktree() + let store = makeStore(worktree: worktree) { _ in } + + await store.send(.showNotificationsPane) + await store.receive(\.repositories.toggleInspectorPane) + // A second invocation must keep the pane open instead of toggling it closed. + await store.send(.showNotificationsPane) + await store.finish() + + #expect(store.state.repositories.inspectorPresented) + #expect(store.state.repositories.inspectorPane == .notifications) + } + + // MARK: - Helpers. + + private func makeWorktree( + id: String = "/tmp/repo/wt-1", + name: String = "wt-1" + ) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + ) + } + + private func makeStore( + worktree: Worktree, + withAdditionalDependencies: (inout DependencyValues) -> Void + ) -> TestStoreOf { + var repositoriesState = RepositoriesFeature.State() + let repository = Repository( + id: "/tmp/repo", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: [worktree], + ) + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + repositoriesState.isInitialLoadComplete = true + + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { values in + values.terminalClient.tabExists = { _, _ in true } + values.terminalClient.surfaceExists = { _, _, _ in true } + withAdditionalDependencies(&values) + } + store.exhaustivity = .off + return store + } +} diff --git a/supacodeTests/MenuBarNotificationListTests.swift b/supacodeTests/MenuBarNotificationListTests.swift new file mode 100644 index 000000000..63b28e006 --- /dev/null +++ b/supacodeTests/MenuBarNotificationListTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +@MainActor +struct MenuBarNotificationListTests { + @Test func includesWorktreesWithUnreadOrActiveAgentAndExcludesQuietOnes() { + let list = MenuBarNotificationList.compute(rows: [ + makeRow(name: "unread", unreadCount: 2, hasActiveAgent: false), + makeRow(name: "active", unreadCount: 0, hasActiveAgent: true), + makeRow(name: "quiet", unreadCount: 0, hasActiveAgent: false), + ]) + + #expect(list.rows.map(\.worktreeName) == ["unread", "active"]) + } + + @Test func ordersUnreadWorktreesBeforeActiveOnlyOnes() { + let list = MenuBarNotificationList.compute(rows: [ + makeRow(name: "active", unreadCount: 0, hasActiveAgent: true), + makeRow(name: "unread", unreadCount: 1, hasActiveAgent: false), + ]) + + #expect(list.rows.first?.worktreeName == "unread") + #expect(list.rows.last?.worktreeName == "active") + } + + @Test func ordersByUnreadCountDescendingThenName() { + let list = MenuBarNotificationList.compute(rows: [ + makeRow(name: "beta", unreadCount: 1, hasActiveAgent: false), + makeRow(name: "alpha", unreadCount: 3, hasActiveAgent: false), + ]) + + #expect(list.rows.map(\.worktreeName) == ["alpha", "beta"]) + } + + @Test func emptyWhenNothingNeedsAttention() { + let list = MenuBarNotificationList.compute(rows: [ + makeRow(name: "quiet", unreadCount: 0, hasActiveAgent: false) + ]) + + #expect(list.rows.isEmpty) + #expect(!list.hasUnread) + } + + @Test func hasUnreadReflectsAnyUnreadRow() { + let unreadList = MenuBarNotificationList.compute(rows: [ + makeRow(name: "unread", unreadCount: 1, hasActiveAgent: false) + ]) + let activeOnlyList = MenuBarNotificationList.compute(rows: [ + makeRow(name: "active", unreadCount: 0, hasActiveAgent: true) + ]) + + #expect(unreadList.hasUnread) + #expect(!activeOnlyList.hasUnread) + } + + @Test func agentNotificationHeadlinesSessionTitle() { + let notification = WorktreeTerminalNotification( + surfaceID: UUID(), + title: "claude", + body: "needs your permission", + createdAt: Date(timeIntervalSinceReferenceDate: 0) + ) + + #expect(notification.headline(sessionTitle: "fix login bug") == "fix login bug") + } + + @Test func agentNotificationWithoutSessionTitleFallsBackToDisplayName() { + let notification = WorktreeTerminalNotification( + surfaceID: UUID(), + title: "claude", + body: "done", + createdAt: Date(timeIntervalSinceReferenceDate: 0) + ) + + #expect(notification.headline(sessionTitle: nil) == "Claude Code") + } + + private func makeRow( + name: String, + unreadCount: Int, + hasActiveAgent: Bool + ) -> MenuBarWorktreeRow { + MenuBarWorktreeRow( + id: Worktree.ID("/tmp/repo/\(name)"), + repoName: "repo", + worktreeName: name, + unreadCount: unreadCount, + hasActiveAgent: hasActiveAgent + ) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 57cd4a204..98a2d6cdf 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -130,6 +130,43 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func settingAppVisibilityPersistsChanges() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .dockAndMenuBar + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.setAppVisibility(.menuBar)) { + $0.appVisibility = .menuBar + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.appVisibility == .menuBar) + } + + @Test(.dependencies) func setAppVisibilityPersistsOnlyRealFlips() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .dockAndMenuBar + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + // Echo of the current value (MenuBarExtra scene evaluation) is a no-op. + await store.send(.setAppVisibility(.dockAndMenuBar)) + + await store.send(.setAppVisibility(.dock)) { + $0.appVisibility = .dock + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.appVisibility == .dock) + } + @Test(.dependencies) func selectingNotificationSoundPlaysPreview() async { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index ef4cf4d7e..9ddbf1aba 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -409,6 +409,100 @@ struct SettingsFilePersistenceTests { #expect(settings.global.remoteSessionPersistenceEnabled == true) } + @Test(.dependencies) func decodesMissingAppVisibilityAsDock() throws { + // A file predating the visibility model (no `appVisibility`, no legacy + // `showMenuBarIcon`) predates the menu bar feature entirely, so it was + // Dock-only — the menu bar stays opt-in. + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dock) + } + + @Test(.dependencies) func migratesLegacyShowMenuBarIconTrueToDockAndMenuBar() throws { + // The old boolean was always paired with a Dock icon, so "menu bar on" + // maps to both surfaces enabled. + let legacy = LegacySettingsFileWithMenuBarToggle( + global: LegacyGlobalSettingsWithMenuBarToggle( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: true, + updatesAutomaticallyDownloadUpdates: false, + showMenuBarIcon: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dockAndMenuBar) + } + + @Test(.dependencies) func migratesLegacyShowMenuBarIconFalseToDock() throws { + // Menu bar off + the always-present Dock icon collapses to Dock-only. + let legacy = LegacySettingsFileWithMenuBarToggle( + global: LegacyGlobalSettingsWithMenuBarToggle( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: true, + updatesAutomaticallyDownloadUpdates: false, + showMenuBarIcon: false + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.appVisibility == .dock) + } + + @Test(.dependencies) func roundTripsExplicitAppVisibility() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.appVisibility = .menuBar } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + #expect(reloaded.global.appVisibility == .menuBar) + } + @Test(.dependencies) func roundTripsExplicitRemoteSessionPersistenceDisabled() throws { let storage = SettingsTestStorage() @@ -508,3 +602,15 @@ private struct LegacyGlobalSettingsWithQuitToggle: Codable { var updatesAutomaticallyDownloadUpdates: Bool var confirmBeforeQuit: Bool } + +private struct LegacySettingsFileWithMenuBarToggle: Codable { + var global: LegacyGlobalSettingsWithMenuBarToggle + var repositories: [String: RepositorySettings] +} + +private struct LegacyGlobalSettingsWithMenuBarToggle: Codable { + var appearanceMode: AppearanceMode + var updatesAutomaticallyCheckForUpdates: Bool + var updatesAutomaticallyDownloadUpdates: Bool + var showMenuBarIcon: Bool +} From ab7b50769e6843970c8900455377e085d71c4524 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Wed, 15 Jul 2026 01:48:26 +0200 Subject: [PATCH 2/2] Harden the menu bar status item and three-state visibility Decode appVisibility without throwing so a corrupt or hand-edited value falls back to Dock instead of resetting the whole settings file, and drop the migration for a showMenuBarIcon key that never shipped. Reorder the visibility cards to Dock / Menu Bar / Both and give each the app-icon artwork. Route activation-policy changes through an AppLifecycleClient seam that falls back to the previous mode when AppKit refuses the switch, so a mode change never strands the app with no surface, and surface the main window only for real app windows. Render the menu bar dropdown as a window mirroring the sidebar's Pinned, Active, and Unread rows, cached per leaf so a notification or agent tick invalidates only its row. The session list scrolls once it outgrows the screen while the action rows stay pinned. Use the app icon's SC glyph for the status item and dismiss the whole extra on selection. --- .../Reducer/SettingsFeature.swift | 5 +- .../Views/AppVisibilityOptionCardView.swift | 19 +- .../Models/AppVisibility.swift | 35 +- .../Models/GlobalSettings.swift | 22 +- supacode/App/WindowSurfacing.swift | 40 +- supacode/App/supacodeApp.swift | 29 +- .../MenuBarSC.imageset/Contents.json | 16 + .../MenuBarSC.imageset/MenuBarSC.svg | 6 + .../VisibilityBoth.imageset/Contents.json | 22 ++ .../VisibilityBoth.png | Bin 0 -> 5707 bytes .../VisibilityBoth@2x.png | Bin 0 -> 17693 bytes .../VisibilityDock.imageset/Contents.json | 22 ++ .../VisibilityDock.png | Bin 0 -> 5329 bytes .../VisibilityDock@2x.png | Bin 0 -> 15949 bytes .../VisibilityMenuBar.imageset/Contents.json | 22 ++ .../VisibilityMenuBar.png | Bin 0 -> 5250 bytes .../VisibilityMenuBar@2x.png | Bin 0 -> 16277 bytes .../AppLifecycle/AppLifecycleClient.swift | 14 +- .../App/Models/MenuBarNotificationList.swift | 171 +++++---- .../App/Models/WorktreeMenuSnapshot.swift | 3 +- .../Features/App/Reducer/AppFeature.swift | 59 +-- .../App/Views/MenuBarNotificationsMenu.swift | 342 +++++++++++++++--- .../Reducer/CommandPaletteFeature.swift | 2 +- .../BusinessLogic/SidebarStructure.swift | 15 + .../Reducer/RepositoriesFeature.swift | 5 + .../WorktreeTerminalManager.swift | 7 +- .../AppFeatureMenuBarNotificationsTests.swift | 236 +++++++----- supacodeTests/AppVisibilityTests.swift | 44 +++ .../MenuBarNotificationListTests.swift | 264 +++++++++++--- .../SettingsFilePersistenceTests.swift | 57 ++- 30 files changed, 1040 insertions(+), 417 deletions(-) create mode 100644 supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg create mode 100644 supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png create mode 100644 supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png create mode 100644 supacode/Assets.xcassets/VisibilityDock.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock.png create mode 100644 supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png create mode 100644 supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png create mode 100644 supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png create mode 100644 supacodeTests/AppVisibilityTests.swift diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 270e37834..7f0977b70 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -334,9 +334,8 @@ public struct SettingsFeature { return persist(state) case .setAppVisibility(let visibility): - // Deduped here (not just at the caller) because `MenuBarExtra`'s - // `isInserted` binding re-writes the current value on every scene - // evaluation; persisting each echo would loop scene -> persist -> scene. + // MenuBarExtra echoes the current value on every scene evaluation; + // persisting each echo would loop scene -> persist -> scene. guard state.appVisibility != visibility else { return .none } state.appVisibility = visibility state.syncGlobalDefaults(from: state.globalSettings) diff --git a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift index ae46e9212..d5a3d9bbc 100644 --- a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift +++ b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift @@ -1,9 +1,7 @@ import SupacodeSettingsShared import SwiftUI -/// One card in the Dock/menu-bar visibility picker, mirroring -/// `AppearanceOptionCardView`. Uses an SF Symbol placeholder until dedicated -/// artwork lands. +/// One card in the Dock/menu-bar visibility picker, mirroring `AppearanceOptionCardView`. struct AppVisibilityOptionCardView: View { let visibility: AppVisibility let isSelected: Bool @@ -12,14 +10,11 @@ struct AppVisibilityOptionCardView: View { var body: some View { Button(action: action) { VStack(spacing: 4) { - RoundedRectangle(cornerRadius: 8) - .fill(.quaternary) - .aspectRatio(1.6, contentMode: .fit) - .overlay { - Image(systemName: visibility.symbolName) - .font(.system(size: 22)) - .foregroundStyle(isSelected ? Color.accentColor : .secondary) - } + Image(visibility.imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(.rect(cornerRadius: 8)) + .accessibilityLabel(visibility.title) .overlay { RoundedRectangle(cornerRadius: 8) .strokeBorder( @@ -27,7 +22,6 @@ struct AppVisibilityOptionCardView: View { lineWidth: 2 ) } - .accessibilityLabel(visibility.title) Text(visibility.title) .font(.callout) .multilineTextAlignment(.center) @@ -36,5 +30,6 @@ struct AppVisibilityOptionCardView: View { } } .buttonStyle(.plain) + .help(visibility.help) } } diff --git a/SupacodeSettingsShared/Models/AppVisibility.swift b/SupacodeSettingsShared/Models/AppVisibility.swift index d6858e215..519372ce9 100644 --- a/SupacodeSettingsShared/Models/AppVisibility.swift +++ b/SupacodeSettingsShared/Models/AppVisibility.swift @@ -1,38 +1,47 @@ /// Where Supacode shows up on the system: the Dock, the menu bar, or both. -/// At least one surface is always enabled — there is no "hidden everywhere" -/// case — so the app is never unreachable. +/// Every case keeps at least one surface enabled (there is no "hidden +/// everywhere" case), so the app is never unreachable. public enum AppVisibility: String, CaseIterable, Identifiable, Codable, Sendable { case dock - case dockAndMenuBar case menuBar + case dockAndMenuBar public var id: String { rawValue } public var title: String { switch self { case .dock: "Dock" - case .dockAndMenuBar: "Dock & Menu Bar" case .menuBar: "Menu Bar" + case .dockAndMenuBar: "Both" + } + } + + public var help: String { + switch self { + case .dock: "Show Supacode in the Dock only." + case .menuBar: "Show Supacode in the menu bar only, with no Dock icon." + case .dockAndMenuBar: "Show Supacode in both the Dock and the menu bar." } } - /// SF Symbol used by the settings option cards. Placeholder art until a - /// dedicated asset lands; conveys "dock" / "both" / "menu bar" at a glance. - public var symbolName: String { + public var imageName: String { switch self { - case .dock: "dock.rectangle" - case .dockAndMenuBar: "menubar.dock.rectangle" - case .menuBar: "menubar.rectangle" + case .dock: "VisibilityDock" + case .menuBar: "VisibilityMenuBar" + case .dockAndMenuBar: "VisibilityBoth" } } - /// True when this mode keeps the menu bar status item inserted. public var showsMenuBarIcon: Bool { self == .dockAndMenuBar || self == .menuBar } - /// True when this mode hides the Dock icon (runs as an accessory app). + public var showsDockIcon: Bool { + self != .menuBar + } + + /// Hiding the Dock icon means running as an accessory app. public var hidesDockIcon: Bool { - self == .menuBar + !showsDockIcon } } diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index c93788414..41bcc21a4 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -70,9 +70,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// When true, remote surfaces wrap their session in zmx on the host when /// the host has it installed, so the session survives disconnects. public var remoteSessionPersistenceEnabled: Bool - /// Where Supacode appears: Dock, menu bar, or both. The menu bar status item - /// is inserted when this includes the menu bar; the Dock icon is hidden in - /// `.menuBar` mode. + /// Where Supacode appears: Dock, menu bar, or both. public var appVisibility: AppVisibility public static let `default` = GlobalSettings( @@ -344,17 +342,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { remoteSessionPersistenceEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteSessionPersistenceEnabled) ?? Self.default.remoteSessionPersistenceEnabled - // Prefer the new three-state field; fall back to the legacy boolean - // (`showMenuBarIcon`), which always shipped alongside a Dock icon — - // `true` → both surfaces, `false` → Dock only. Missing both → default. - if let visibility = try container.decodeIfPresent(AppVisibility.self, forKey: .appVisibility) { - appVisibility = visibility - } else if let legacyShowMenuBarIcon = try legacy.decodeIfPresent( - Bool.self, forKey: LegacyCodingKey(stringValue: "showMenuBarIcon")!) - { - appVisibility = legacyShowMenuBarIcon ? .dockAndMenuBar : .dock - } else { - appVisibility = Self.default.appVisibility - } + // Reject unrecognized values (and a mistyped key) from corrupted or + // hand-edited settings files: a throw here resets the whole file to defaults. + appVisibility = + ((try? container.decodeIfPresent(String.self, forKey: .appVisibility)) ?? nil) + .flatMap(AppVisibility.init(rawValue:)) + ?? Self.default.appVisibility } } diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index aa2e6f928..b2b94a35e 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -1,15 +1,31 @@ import AppKit import SupacodeSettingsShared +private let visibilityLogger = SupaLogger("AppVisibility") + extension NSApplication { - /// Applies the Dock/menu-bar visibility mode: `.menuBar` runs as an - /// accessory (no Dock icon), every other mode as a regular app. Idempotent — - /// safe to call on every settings change. + /// Applies the Dock/menu-bar visibility mode: `.menuBar` runs as an accessory + /// (no Dock icon), every other mode as a regular app. Returns false when + /// AppKit refuses the switch. @MainActor - func applyActivationPolicy(for visibility: AppVisibility) { + @discardableResult + func applyActivationPolicy(for visibility: AppVisibility) -> Bool { let policy: NSApplication.ActivationPolicy = visibility.hidesDockIcon ? .accessory : .regular - guard activationPolicy() != policy else { return } - setActivationPolicy(policy) + guard activationPolicy() != policy else { return true } + if setActivationPolicy(policy) { return true } + // AppKit refuses `.accessory` -> `.regular` while the app is inactive, so + // retry once activated. Only that direction: force-activating to *hide* the + // Dock icon would pop the app to the front for a quieting action. + guard policy == .regular else { + visibilityLogger.error("setActivationPolicy(.accessory) refused; app stays regular.") + return false + } + activate() + guard setActivationPolicy(policy) else { + visibilityLogger.error("setActivationPolicy(.regular) refused; app stays accessory.") + return false + } + return true } /// Brings the main window forward, deminiaturizing if needed. @@ -36,10 +52,20 @@ extension NSApplication { if let window = windows.first(where: { $0.identifier?.rawValue == WindowID.main }) { return window } - let candidates = windows.filter { !($0 is NSPanel) } + let candidates = windows.filter(\.isSurfaceableAppWindow) if let window = candidates.first(where: { $0.identifier?.rawValue != WindowID.settings }) { return window } return candidates.first } } + +extension NSWindow { + /// A real app window the user can be sent to. Excludes `NSPanel` (the shared + /// color / font panels) and anything that can't take main status, notably the + /// status item's window, which would otherwise pass for a visible main window + /// once the menu bar extra is inserted. + var isSurfaceableAppWindow: Bool { + canBecomeMain && !(self is NSPanel) + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index e3a0b123e..75a94648e 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -76,22 +76,20 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { // the main window. Opt the singleton out per-process so a panel // left open from a previous session can't survive the relaunch. NSColorPanel.shared.isRestorable = false - // Apply the saved Dock/menu-bar visibility before the first window shows, - // so a menu-bar-only user never flashes a Dock icon at launch. - if let visibility = appStore?.state.settings.appVisibility { - NSApplication.shared.applyActivationPolicy(for: visibility) + guard let appStore else { + SupaLogger("App").error("applicationDidFinishLaunching with no store; launch setup skipped.") + return } - appStore?.send(.appLaunched) + // Apply the saved Dock/menu-bar visibility before the first window shows. + NSApplication.shared.applyActivationPolicy(for: appStore.state.settings.appVisibility) + appStore.send(.appLaunched) } func applicationDidBecomeActive(_ notification: Notification) { appStore?.send(.applicationDidBecomeActive) let app = NSApplication.shared - // Filter `NSPanel` out of the visibility check — the system - // color / font panels (and any sheet-attached child panels) are - // not "main windows" that should suppress surfacing. let hasVisibleMainWindow = app.windows.contains { window in - window.isVisible && !(window is NSPanel) + window.isVisible && window.isSurfaceableAppWindow } guard !hasVisibleMainWindow else { return } app.surfaceMainWindow() @@ -585,19 +583,18 @@ struct SupacodeApp: App { } label: { MenuBarNotificationsLabel(store: store) } - .menuBarExtraStyle(.menu) + // `.window`, not `.menu`: a native menu item can't host the sidebar row's + // dots, agent badges, and diff stats. The panel is styled to read like a menu. + .menuBarExtraStyle(.window) } - /// Visibility-backed insertion so switching modes (or Cmd-dragging the icon - /// out of the menu bar) adds/removes the status item live. Dragging the icon - /// out falls back to `.dock`, which keeps at least one surface enabled. + /// Dragging the status item out of the menu bar falls back to `.dock`, so at + /// least one surface stays enabled. private var menuBarInserted: Binding { Binding( get: { store.settings.appVisibility.showsMenuBarIcon }, set: { newValue in - // MenuBarExtra re-writes the current value on every scene evaluation; - // only a real flip may reach the store or the persist round-trip - // re-evaluates the scene and loops the app at launch. + // Ignore MenuBarExtra's scene-evaluation echo; only a real flip should persist. guard newValue != store.settings.appVisibility.showsMenuBarIcon else { return } store.send(.settings(.setAppVisibility(newValue ? .dockAndMenuBar : .dock))) } diff --git a/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json b/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json new file mode 100644 index 000000000..f97c95bd3 --- /dev/null +++ b/supacode/Assets.xcassets/MenuBarSC.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "MenuBarSC.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg b/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg new file mode 100644 index 000000000..9db046846 --- /dev/null +++ b/supacode/Assets.xcassets/MenuBarSC.imageset/MenuBarSC.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json b/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json new file mode 100644 index 000000000..98592421e --- /dev/null +++ b/supacode/Assets.xcassets/VisibilityBoth.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "VisibilityBoth.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VisibilityBoth@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth.png new file mode 100644 index 0000000000000000000000000000000000000000..95bdacd73ff668aa96f702ec4f543e3c9d82fa48 GIT binary patch literal 5707 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 literal 0 HcmV?d00001 diff --git a/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png b/supacode/Assets.xcassets/VisibilityBoth.imageset/VisibilityBoth@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..eba48532328512c4e1f5c47660fbb87242d976de GIT binary patch literal 17693 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 literal 0 HcmV?d00001 diff --git a/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png b/supacode/Assets.xcassets/VisibilityDock.imageset/VisibilityDock@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..72bba572f80fe51db51be29a1d1bb9a6b10ff09a GIT binary patch literal 15949 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 literal 0 HcmV?d00001 diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json new file mode 100644 index 000000000..765f88202 --- /dev/null +++ b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "VisibilityMenuBar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "VisibilityMenuBar@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar.png new file mode 100644 index 0000000000000000000000000000000000000000..e67608e0e52dc4a1a91f0a71e5b572a659397c2c GIT binary patch literal 5250 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 IM6N<$f(4)flmGw# literal 0 HcmV?d00001 diff --git a/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png b/supacode/Assets.xcassets/VisibilityMenuBar.imageset/VisibilityMenuBar@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e8e1a104ab643599575bf53a22369885607c5778 GIT binary patch literal 16277 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 LNkvXXu0mjfcE4<; literal 0 HcmV?d00001 diff --git a/supacode/Clients/AppLifecycle/AppLifecycleClient.swift b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift index 6550dccea..b5e52e2e7 100644 --- a/supacode/Clients/AppLifecycle/AppLifecycleClient.swift +++ b/supacode/Clients/AppLifecycle/AppLifecycleClient.swift @@ -1,17 +1,27 @@ import AppKit import ComposableArchitecture +import SupacodeSettingsShared struct AppLifecycleClient { var terminate: @MainActor @Sendable () -> Void + /// Applies the Dock/menu-bar visibility mode. Returns false when AppKit + /// refuses the switch, which would otherwise leave the app with no surface. + var applyVisibility: @MainActor @Sendable (AppVisibility) -> Bool + /// Brings the main window forward. Returns false when there is no window to surface. + var surfaceMainWindow: @MainActor @Sendable () -> Bool } extension AppLifecycleClient: DependencyKey { static let liveValue = AppLifecycleClient( - terminate: { NSApplication.shared.terminate(nil) } + terminate: { NSApplication.shared.terminate(nil) }, + applyVisibility: { NSApplication.shared.applyActivationPolicy(for: $0) }, + surfaceMainWindow: { NSApplication.shared.surfaceMainWindow() } ) static let testValue = AppLifecycleClient( - terminate: unimplemented("AppLifecycleClient.terminate") + terminate: unimplemented("AppLifecycleClient.terminate"), + applyVisibility: unimplemented("AppLifecycleClient.applyVisibility", placeholder: true), + surfaceMainWindow: unimplemented("AppLifecycleClient.surfaceMainWindow", placeholder: true) ) } diff --git a/supacode/Features/App/Models/MenuBarNotificationList.swift b/supacode/Features/App/Models/MenuBarNotificationList.swift index e7527682e..3bbf01d63 100644 --- a/supacode/Features/App/Models/MenuBarNotificationList.swift +++ b/supacode/Features/App/Models/MenuBarNotificationList.swift @@ -3,47 +3,97 @@ import IdentifiedCollections import OrderedCollections import SupacodeSettingsShared -/// One worktree that currently wants attention in the menu bar dropdown: -/// it has unread notifications, a live agent, or both. -struct MenuBarWorktreeRow: Identifiable, Equatable { - let id: Worktree.ID - let repoName: String - let worktreeName: String - let unreadCount: Int - let hasActiveAgent: Bool +/// The menu bar's sections, mirroring the sidebar: the Pinned and Active rows +/// it hoists, then an Unread section for anything unread that neither one +/// already shows. Only row IDs are cached; each row observes its own leaf, +/// exactly like the sidebar does. +struct MenuBarSections: Equatable { + var pinned: [Worktree.ID] = [] + var active: [Worktree.ID] = [] + var unread: [Worktree.ID] = [] + /// Repo tags for the `repo · worktree` subtitle. `sidebarStructure` only + /// builds these for repos contributing a highlight row, so unread-only repos + /// need their own. + var repositoryTagByID: [Repository.ID: SidebarHighlightRepoTag] = [:] + /// Any unread row at all, hoisted or not. Drives the status item's dot and + /// the "Mark All as Read" gate, which must not go dark just because the only + /// unread row is also Active. + var hasUnread = false + + var isEmpty: Bool { pinned.isEmpty && active.isEmpty && unread.isEmpty } + + /// Headers and rows flattened into the order the menu renders them. + var entries: [MenuBarEntry] { + var entries: [MenuBarEntry] = [] + appendSection(&entries, title: "Pinned", rowIDs: pinned, dotColor: .pinned) + appendSection(&entries, title: "Active", rowIDs: active, dotColor: .active) + appendSection(&entries, title: "Unread", rowIDs: unread, dotColor: nil) + return entries + } + + private func appendSection( + _ entries: inout [MenuBarEntry], + title: String, + rowIDs: [Worktree.ID], + dotColor: SidebarStructure.HighlightKind? + ) { + guard !rowIDs.isEmpty else { return } + entries.append(MenuBarEntry(id: .header(title), content: .header(title, dotColor))) + entries.append(contentsOf: rowIDs.map { MenuBarEntry(id: .row($0), content: .worktree($0)) }) + } } -/// Menu-sized projection of the worktrees needing attention: those with unread -/// notifications or an active agent, unread ones first. The dropdown lists -/// *which* sessions want attention (system notifications already carry the -/// message text), so this intentionally does not repeat notification bodies. -struct MenuBarNotificationList: Equatable { - var rows: [MenuBarWorktreeRow] = [] - /// True when any listed worktree has unread notifications, so "Mark All as - /// Read" can gate on it. - var hasUnread = false +/// One line of the menu: a section header or a worktree row. +struct MenuBarEntry: Identifiable, Equatable { + enum Key: Hashable { + case header(String) + case row(Worktree.ID) + } - static func compute(rows input: [MenuBarWorktreeRow]) -> Self { - let attention = input.filter { $0.unreadCount > 0 || $0.hasActiveAgent } - let sorted = attention.sorted { lhs, rhs in - let lhsUnread = lhs.unreadCount > 0 - let rhsUnread = rhs.unreadCount > 0 - // Unread worktrees float above active-only ones. - if lhsUnread != rhsUnread { return lhsUnread } - if lhs.unreadCount != rhs.unreadCount { return lhs.unreadCount > rhs.unreadCount } - return lhs.worktreeName.localizedCaseInsensitiveCompare(rhs.worktreeName) == .orderedAscending - } - return Self(rows: sorted, hasUnread: attention.contains { $0.unreadCount > 0 }) + enum Content: Equatable { + case header(String, SidebarStructure.HighlightKind?) + case worktree(Worktree.ID) } + + let id: Key + let content: Content } extension RepositoriesFeature.State { - /// Candidate rows for the menu bar dropdown — one per worktree that has a - /// sidebar row, carrying its unread count and agent-activity flag. Mirrors the - /// repository grouping of `computeToolbarNotificationGroups` so repo/worktree - /// names match the sidebar. `MenuBarNotificationList.compute` does the - /// attention filtering and ordering. - func menuBarWorktreeRows() -> [MenuBarWorktreeRow] { + /// Cached on `menuBarSectionsCache`; the menu bar scene reads the cache + /// rather than walking `sidebarItems` from its body. + func computeMenuBarSections() -> MenuBarSections { + var sections = MenuBarSections(repositoryTagByID: sidebarStructure.repositoryHighlightByID) + for section in sidebarStructure.sections { + guard case .highlight(let kind, let rowIDs) = section else { continue } + switch kind { + case .pinned: sections.pinned = rowIDs + case .active: sections.active = rowIDs + } + } + let unread = unreadRowIDs() + sections.hasUnread = !unread.isEmpty + // The highlight sections already show their rows; listing them again under + // Unread would double them up. + sections.unread = unread.filter { !sidebarStructure.hoistedRowIDs.contains($0) } + for rowID in sections.unread { + guard let repositoryID = sidebarItems[id: rowID]?.repositoryID, + sections.repositoryTagByID[repositoryID] == nil, + let repository = repositories[id: repositoryID] + else { continue } + let section = sidebar.sections[repositoryID] + sections.repositoryTagByID[repositoryID] = SidebarHighlightRepoTag( + repoName: Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name), + repoColor: section?.color, + hostInfo: repository.host?.displayAuthority + ) + } + return sections + } + + /// Rows carrying unread notifications, in sidebar order. Folders included: + /// their notifications land on the synthetic row standing in for the folder. + private func unreadRowIDs() -> [Worktree.ID] { let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) var orderedIDs = orderedRepositoryIDs() let coveredIDs = Set(orderedIDs) @@ -51,47 +101,24 @@ extension RepositoriesFeature.State { orderedIDs.append(repository.id) } - var rows: [MenuBarWorktreeRow] = [] + let archived = archivedWorktreeIDSet + var rowIDs: [Worktree.ID] = [] for repositoryID in orderedIDs { guard let repository = repositoriesByID[repositoryID] else { continue } - let isFolder = !repository.isGitRepository - let folderRow = isFolder ? sidebarItems[id: Repository.folderWorktreeID(for: repository.rootURL)] : nil - let section = sidebar.sections[repositoryID] - let repoName = - isFolder - ? (folderRow?.resolvedSidebarTitle ?? repository.name) - : Repository.sidebarDisplayName(custom: section?.title, fallback: repository.name) - - for worktree in orderedWorktrees(in: repository) { - guard let row = sidebarItems[id: worktree.id] else { continue } - let unread = row.notifications.count { !$0.isRead } - guard unread > 0 || row.hasAgentActivity else { continue } - rows.append( - MenuBarWorktreeRow( - id: worktree.id, - repoName: repoName, - worktreeName: row.resolvedSidebarTitle ?? worktree.name, - unreadCount: unread, - hasActiveAgent: row.hasAgentActivity - ) - ) + guard repository.isGitRepository else { + let folderID = Repository.folderWorktreeID(for: repository.rootURL) + if sidebarItems[id: folderID]?.hasUnseenNotifications == true { + rowIDs.append(folderID) + } + continue + } + for worktree in orderedWorktrees(in: repository) + where !archived.contains(worktree.id) + && sidebarItems[id: worktree.id]?.hasUnseenNotifications == true + { + rowIDs.append(worktree.id) } } - return rows - } -} - -extension WorktreeTerminalNotification { - /// Display headline: agent notifications carry the agent slug as `title`, so - /// headline with the live session (tab) title when one exists, else the agent - /// display name. Non-agent notifications keep their own title (it's real - /// content, not a slug). - func headline(sessionTitle: String?) -> String { - let agent = SkillAgent(rawValue: title.lowercased()) - let fallbackTitle = agent?.displayName ?? (title.isEmpty ? "Terminal" : title) - // An unrenamed tab is titled with the bare process name ("claude"); map - // that back to the display name rather than headlining the raw slug. - let resolvedSessionTitle = sessionTitle.map { SkillAgent(rawValue: $0.lowercased())?.displayName ?? $0 } - return (agent != nil ? resolvedSessionTitle : nil) ?? fallbackTitle + return rowIDs } } diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 1a20772cf..431da6e11 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -124,8 +124,7 @@ extension AppFeature.Action { .openWorktree, .openWorktreeFailed, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, .selectTerminalTabAtIndex, .splitTerminal, .jumpToLatestUnread, - .menuBarNotificationSelected, .menuBarWorktreeSelected, .showNotificationsPane, - .markAllNotificationsRead, .runScript, .runNamedScript, + .menuBarWorktreeSelected, .markAllNotificationsRead, .runScript, .runNamedScript, .stopScript, .stopRunScripts, .closeTab, .closeSurface, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 1d3fa9750..f2c1a9bf6 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -210,10 +210,7 @@ struct AppFeature { case selectTerminalTabAtIndex(Int) case splitTerminal(TerminalSplitMenuDirection) case jumpToLatestUnread - case menuBarNotificationSelected( - worktreeID: Worktree.ID, tabID: TerminalTabID?, surfaceID: UUID, notificationID: UUID) case menuBarWorktreeSelected(worktreeID: Worktree.ID) - case showNotificationsPane case markAllNotificationsRead case runScript case runNamedScript(ScriptDefinition) @@ -532,6 +529,7 @@ struct AppFeature { settings.agentPresenceBadgesEnabled != state.lastKnownAgentPresenceBadgesEnabled state.lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled let visibilityChanged = settings.appVisibility != state.lastKnownAppVisibility + let previousVisibility = state.lastKnownAppVisibility // Surface the main window when the Dock icon comes back, so leaving // menu-bar-only mode never strands the user without a window. let dockIconReappeared = @@ -595,10 +593,17 @@ struct AppFeature { ] if visibilityChanged { effects.append( - .run { @MainActor _ in - NSApplication.shared.applyActivationPolicy(for: settings.appVisibility) + .run { @MainActor send in + // The status item is already gone by now (the `MenuBarExtra` + // binding reads the new value on the same scene pass), so a + // refused policy switch would leave no surface at all. Fall back + // to the previous mode, which puts one of them back. + guard appLifecycleClient.applyVisibility(settings.appVisibility) else { + await send(.settings(.setAppVisibility(previousVisibility))) + return + } if dockIconReappeared { - NSApplication.shared.surfaceMainWindow() + _ = appLifecycleClient.surfaceMainWindow() } } ) @@ -769,53 +774,23 @@ struct AppFeature { } ) - case .menuBarNotificationSelected(let worktreeID, let tabID, let surfaceID, let notificationID): - guard let worktree = state.repositories.worktree(for: worktreeID) else { - jumpLogger.warning( - "menuBarNotificationSelected: worktree \(worktreeID) vanished between menu render and click." - ) - return .none - } - analyticsClient.capture("menu_bar_notification_selected", nil) - // The notification may predate a tab move; re-resolve the surface's - // current tab when the recorded one is gone. A nil resolution means - // the surface closed — still surface the app and mark the item read. - let resolvedTabID = tabID ?? terminalClient.tabID(worktreeID, surfaceID) - return .merge( - .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), - .run { @MainActor _ in - NSApplication.shared.surfaceMainWindow() - if let resolvedTabID { - terminalClient.send(.focusSurface(worktree, tabID: resolvedTabID, surfaceID: surfaceID)) - } - terminalClient.markNotificationRead(worktreeID, notificationID) - } - ) - case .menuBarWorktreeSelected(let worktreeID): + // The menu snapshots its rows when it opens, so the worktree can be + // archived or deleted before the click lands. Surface the app anyway: + // in menu bar mode a dead click is indistinguishable from a hang. guard state.repositories.worktree(for: worktreeID) != nil else { jumpLogger.warning( "menuBarWorktreeSelected: worktree \(worktreeID) vanished between menu render and click." ) - return .none + analyticsClient.capture("menu_bar_worktree_selected_stale", nil) + return .run { @MainActor _ in _ = appLifecycleClient.surfaceMainWindow() } } analyticsClient.capture("menu_bar_worktree_selected", nil) return .merge( .send(.repositories(.selectWorktree(worktreeID, focusTerminal: true))), - .run { @MainActor _ in NSApplication.shared.surfaceMainWindow() } + .run { @MainActor _ in _ = appLifecycleClient.surfaceMainWindow() } ) - case .showNotificationsPane: - let surfaceWindow: Effect = .run { @MainActor _ in - NSApplication.shared.surfaceMainWindow() - } - // Toggle only when the notifications pane isn't already up, so a - // second menu invocation surfaces the window instead of closing it. - if state.repositories.inspectorPresented, state.repositories.inspectorPane == .notifications { - return surfaceWindow - } - return .merge(surfaceWindow, .send(.repositories(.toggleInspectorPane(.notifications)))) - case .markAllNotificationsRead: analyticsClient.capture("notifications_mark_all_read", nil) return .run { _ in await terminalClient.markAllNotificationsRead() } diff --git a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift index 9f0704396..5add284f7 100644 --- a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift +++ b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift @@ -1,83 +1,333 @@ import AppKit import ComposableArchitecture import SupacodeSettingsFeature +import SupacodeSettingsShared import SwiftUI -/// Contents of the menu bar extra: the worktrees that currently want attention -/// (unread notifications or an active agent) on top, then quick actions. -/// Rendered with the native `.menu` style, so rows are plain menu items -/// (title + subtitle Texts). It lists *which* sessions need attention rather -/// than repeating notification bodies — system notifications already carry -/// the message text. +private let menuBarLogger = SupaLogger("MenuBar") + +/// Contents of the menu bar extra: the sidebar's Pinned, Active, and Unread +/// rows, then quick actions. Rendered as a window rather than a native menu so +/// the rows can be the real `SidebarItemView`, with the same icon, title, +/// subtitle, notification and script dots, agent badges, and diff stats. struct MenuBarNotificationsMenu: View { + @Environment(\.openWindow) private var openWindow let store: StoreOf var body: some View { - let list = MenuBarNotificationList.compute(rows: store.repositories.menuBarWorktreeRows()) - Group { - if list.rows.isEmpty { - Text("No Sessions Need Attention") - } else { - ForEach(list.rows) { row in - Button { - store.send(.menuBarWorktreeSelected(worktreeID: row.id)) - } label: { - Text(row.worktreeName) - Text(verbatim: subtitle(for: row)) + let sections = store.repositories.menuBarSectionsCache + let repositories = store.scope(state: \.repositories, action: \.repositories) + // The session list scrolls once it outgrows the screen so the action rows + // below stay reachable; the action rows never scroll, exactly like a menu. + VStack(alignment: .leading, spacing: 0) { + MenuBarSessionList { + if sections.isEmpty { + Text("No Sessions Need Attention") + .foregroundStyle(.secondary) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 5) + } else { + ForEach(sections.entries) { entry in + MenuBarEntryRow( + entry: entry, + repositories: repositories, + sections: sections, + onOpen: openWorktree + ) } - .help("Open this worktree.") } } - Divider() - Button("Mark All as Read") { + MenuBarDivider() + MenuBarActionRow(title: "Mark All as Read", isEnabled: sections.hasUnread) { + dismissMenuBarExtra() store.send(.markAllNotificationsRead) } - .disabled(!list.hasUnread) - .help("Mark every notification as read.") - Button("Show Main Window") { - NSApplication.shared.surfaceMainWindow() + MenuBarActionRow(title: "Show Main Window") { + showMainWindow() } - .help("Bring the main Supacode window to the front.") - Button("Settings...") { + MenuBarActionRow(title: "Settings...") { + dismissMenuBarExtra() store.send(.settings(.setSelection(.general))) + openWindow(id: WindowID.settings) + NSApplication.shared.activate() } - .help("Open Supacode settings.") - Divider() - Button("Quit Supacode") { + MenuBarDivider() + MenuBarActionRow(title: "Quit Supacode") { + // The quit confirmation is an alert hosted by the main window, so it + // needs one on screen before it can be answered. + showMainWindow() store.send(.requestQuit) } - .help("Quit Supacode.") } + .padding(.vertical, MenuBarMetrics.panelPadding) + .frame(width: MenuBarMetrics.width) + // Gives the rows' `ConcentricRectangle` highlight the panel's corners to + // stay concentric with; nothing else supplies a container shape here. + .containerShape(.rect(cornerRadius: MenuBarMetrics.panelCornerRadius)) + } + + private func openWorktree(_ worktreeID: Worktree.ID) { + showMainWindow() + store.send(.menuBarWorktreeSelected(worktreeID: worktreeID)) + } + + /// `openWindow` re-creates the scene the user closed, which `surfaceMainWindow()` + /// cannot do (and it reports success after raising any other window, so it + /// can't gate this either). Surfacing afterwards deminiaturizes it. + private func showMainWindow() { + dismissMenuBarExtra() + openWindow(id: WindowID.main) + NSApplication.shared.surfaceMainWindow() + } + + /// Selecting anything must dismiss the whole menu bar extra, not just its + /// panel: closing the panel window directly leaves the status item stuck + /// highlighted, so the next click only deselects it. Clicking the status + /// button toggles the extra off through the system, which closes the panel + /// and clears the highlight together. + private func dismissMenuBarExtra() { + guard let button = Self.statusBarButton() else { + // Defense in depth: if a future OS moves the status item out of reach the + // panel would silently stay open on every selection, so make it loud. + menuBarLogger.warning("Status item button not found; panel stays open on selection.") + return + } + button.performClick(nil) + } + + /// The menu bar extra's `NSStatusBarButton`, found by walking the app's + /// windows. SwiftUI's `MenuBarExtra` never exposes its status item. + private static func statusBarButton() -> NSStatusBarButton? { + for window in NSApp.windows { + if let button = statusBarButton(in: window.contentView) { return button } + } + return nil + } + + private static func statusBarButton(in view: NSView?) -> NSStatusBarButton? { + guard let view else { return nil } + if let button = view as? NSStatusBarButton { return button } + for subview in view.subviews { + if let button = statusBarButton(in: subview) { return button } + } + return nil + } +} + +enum MenuBarMetrics { + static let width: CGFloat = 320 + static let panelPadding: CGFloat = 5 + static let sectionSpacing: CGFloat = 6 + /// Inset of a row's content, matching where a menu starts its titles. + static let rowPadding: CGFloat = 14 + /// Inset of a highlighted row from the panel edge, so its corners sit + /// concentric with the panel's. + static let highlightInset: CGFloat = 5 + /// The menu bar extra's window corner radius, which the system rounds the + /// panel to but never exposes. + static let panelCornerRadius: CGFloat = 14 + static let highlightCornerRadius = panelCornerRadius - highlightInset + /// Height the session list caps at before it scrolls, leaving the action + /// rows and a margin below the menu bar on screen. + static var sessionListMaxHeight: CGFloat { + let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800 + return max(160, visibleHeight - 220) + } +} + +/// The session area. It hugs its content until that would run past the screen, +/// then caps and scrolls, so the pinned action rows below stay on screen no +/// matter how many worktrees are listed. `ViewThatFits` picks the plain stack +/// while it fits the cap and the scrolling one only once it doesn't, so short +/// lists never reserve empty height. +private struct MenuBarSessionList: View { + let content: Content + // The menu bar window proposes no height, so the list must carry an explicit + // one: the rows' natural height until it hits the cap, then scroll. `nil` + // (before the first measurement) falls back to the cap so rows always render + // rather than collapsing to zero. + @State private var contentHeight: CGFloat? + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { contentHeight = $0 } + } + .frame(height: min(contentHeight ?? .infinity, MenuBarMetrics.sessionListMaxHeight)) + // No rubber-banding while everything fits. + .scrollBounceBehavior(.basedOnSize) + } +} + +/// Full-bleed separator with the vertical breathing room a menu gives it. +private struct MenuBarDivider: View { + var body: some View { + Divider() + .padding(.vertical, MenuBarMetrics.panelPadding) + } +} + +private struct MenuBarEntryRow: View { + let entry: MenuBarEntry + let repositories: StoreOf + let sections: MenuBarSections + let onOpen: (Worktree.ID) -> Void + + var body: some View { + switch entry.content { + case .header(let title, let kind): + MenuBarSectionHeader(title: title, dotColor: kind?.indicatorColor) + case .worktree(let rowID): + MenuBarWorktreeRowView( + rowID: rowID, + repositories: repositories, + sections: sections, + onOpen: onOpen + ) + } + } +} + +/// Mirrors the sidebar's highlight header: the title plus the section's dot. +private struct MenuBarSectionHeader: View { + let title: String + let dotColor: Color? + + var body: some View { + HStack(spacing: 4) { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + if let dotColor { + SidebarHighlightHeaderDot(color: dotColor) + } + } + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.top, MenuBarMetrics.sectionSpacing) + .padding(.bottom, 2) } +} + +/// Scopes to the single leaf and renders the real sidebar row, so per-row +/// notification and agent churn invalidates only this row. +private struct MenuBarWorktreeRowView: View { + let rowID: Worktree.ID + let repositories: StoreOf + let sections: MenuBarSections + let onOpen: (Worktree.ID) -> Void + @State private var isHovering = false + @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true - private func subtitle(for row: MenuBarWorktreeRow) -> String { - var parts: [String] = [row.repoName] - if row.unreadCount > 0 { - parts.append(row.unreadCount == 1 ? "1 unread" : "\(row.unreadCount) unread") + var body: some View { + if let itemStore = repositories.scope( + state: \.sidebarItems[id: rowID], + action: \.sidebarItems[id: rowID] + ) { + Button { + onOpen(rowID) + } label: { + SidebarItemView( + store: itemStore, + hideSubtitle: false, + hideSubtitleOnMatch: hideSubtitleOnMatch, + showsPullRequestInfo: true, + shortcutHint: nil, + highlightSubtitle: sections.repositoryTagByID[itemStore.repositoryID] + ) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .menuBarRowHighlight(isHovering) + .background(MenuBarRowHighlight(isHighlighted: isHovering)) + .onHover { isHovering = $0 } } - if row.hasActiveAgent { - parts.append("agent active") + } +} + +/// Menu-style action row, sharing the worktree rows' highlight so the whole +/// panel reads as one menu. +private struct MenuBarActionRow: View { + let title: String + var isEnabled: Bool = true + let action: () -> Void + @State private var isHovering = false + + var body: some View { + let isHighlighted = isHovering && isEnabled + Button(action: action) { + Text(title) + .padding(.horizontal, MenuBarMetrics.rowPadding) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) } - return parts.joined(separator: " · ") + .buttonStyle(.plain) + .disabled(!isEnabled) + .foregroundStyle(isEnabled ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary)) + .menuBarRowHighlight(isHighlighted) + .background(MenuBarRowHighlight(isHighlighted: isHighlighted)) + .onHover { isHovering = $0 } + } +} + +/// The menu's selection fill: accent-colored, inset from the panel edge, its +/// corners concentric with the panel's. +private struct MenuBarRowHighlight: View { + let isHighlighted: Bool + + var body: some View { + // A row in the middle of the panel is far from its corners, so concentricity + // alone would round it to nothing: the floor is what actually shows. + ConcentricRectangle( + corners: .concentric(minimum: .fixed(MenuBarMetrics.highlightCornerRadius)), + isUniform: true + ) + .fill(isHighlighted ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear)) + .padding(.horizontal, MenuBarMetrics.highlightInset) + } +} + +extension View { + /// Flips the row's contents to their emphasized palette, the same way the + /// sidebar recolors a selected row. + fileprivate func menuBarRowHighlight(_ isHighlighted: Bool) -> some View { + environment(\.backgroundProminence, isHighlighted ? .increased : .standard) } } -/// Status item label: an "SC" monogram that adapts to the menu bar's light/dark -/// appearance, with a red dot while anything is unread. Kept as its own view so -/// notification churn invalidates just this label. +/// Status item label: the app icon's "SC" monogram with a red dot while +/// anything is unread. struct MenuBarNotificationsLabel: View { let store: StoreOf var body: some View { - let hasUnread = store.repositories.toolbarNotificationGroupsCache.contains { $0.unseenWorktreeCount > 0 } - ZStack(alignment: .topTrailing) { - Text("SC") - .font(.system(size: 12, weight: .bold, design: .rounded)) + let hasUnread = store.repositories.menuBarSectionsCache.hasUnread + HStack { + // The glyph is lifted from the app icon so the monogram matches its + // typeface; template rendering tints its white fill to the menu bar's + // label color instead of leaving it white-on-white. The asset bakes in + // SF-Symbol-like margins, so it fills the menu bar height as-is. + Image("MenuBarSC") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: .infinity) if hasUnread { Circle() - .fill(.red) + .fill(.orange) .frame(width: 5, height: 5) - .offset(x: 4, y: -2) + .fixedSize() } } .accessibilityLabel(hasUnread ? "Supacode, unread notifications" : "Supacode") diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 566a208d0..f3f342382 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -392,7 +392,7 @@ struct CommandPaletteFeature { let ordered = idleRows.enumerated().sorted { lhs, rhs in switch (mruRank[lhs.element.id], mruRank[rhs.element.id]) { - case let (lhsRank?, rhsRank?): return lhsRank < rhsRank + case (let lhsRank?, let rhsRank?): return lhsRank < rhsRank case (_?, nil): return true case (nil, _?): return false case (nil, nil): return lhs.offset < rhs.offset diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index f68501bcd..847429301 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -341,6 +341,16 @@ extension RepositoriesFeature.State { toolbarNotificationGroupsCache = new } } + + /// Equatable-diffs the menu bar sections against the cache so the status menu + /// only rebuilds when the rows it renders actually change. Runs after + /// `recomputeSidebarStructureIfChanged()`, whose highlight sections it reads. + mutating func recomputeMenuBarSectionsIfChanged() { + let new = computeMenuBarSections() + if new != menuBarSectionsCache { + menuBarSectionsCache = new + } + } } /// Per-cache invalidation flag set returned by every reducer action. Exhaustive @@ -533,6 +543,11 @@ extension RepositoriesFeature.State { if invalidations.contains(.toolbarNotificationGroups) { recomputeToolbarNotificationGroupsIfChanged() } + // The menu bar rows key off notifications *and* agent activity, and agent + // snapshots only invalidate `.sidebarStructure`, so they need both flags. + if !invalidations.isDisjoint(with: [.sidebarStructure, .toolbarNotificationGroups]) { + recomputeMenuBarSectionsIfChanged() + } } /// Pinned worktree IDs across every repository in the user's repo order. diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a4d581b91..9128ef2ac 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -200,6 +200,11 @@ struct RepositoriesFeature { /// mutation across all worktrees). Recomputed via /// `recomputeToolbarNotificationGroupsIfChanged()`. var toolbarNotificationGroupsCache: [ToolbarNotificationRepositoryGroup] = [] + /// Cached menu bar sections. The `MenuBarExtra` scene reads this instead of + /// `sidebarItems`, which would subscribe the status menu to every per-row + /// notification and agent tick. Recomputed via + /// `recomputeMenuBarSectionsIfChanged()`. + var menuBarSectionsCache = MenuBarSections() @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var repositoryCustomization: RepositoryCustomizationFeature.State? @Presents var worktreeCustomization: WorktreeCustomizationFeature.State? diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index a9c7f1466..112b3330c 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1064,9 +1064,12 @@ final class WorktreeTerminalManager { emitProjection(for: worktreeID) } - /// Marks every notification in every managed worktree read. Indicator and - /// projection updates propagate via each state's notification callbacks. + /// Indicator and projection updates propagate via each state's notification + /// callbacks. Every state is swept, not just the unread ones, so a surface + /// whose unseen mirror drifted out of sync with its notifications is repaired. func markAllNotificationsRead() { + let unread = states.values.count(where: \.hasUnseenNotification) + terminalLogger.info("markAllNotificationsRead: clearing unread in \(unread) worktree(s).") for state in states.values { state.markAllNotificationsRead() } diff --git a/supacodeTests/AppFeatureMenuBarNotificationsTests.swift b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift index 97191b3e6..f09b85daf 100644 --- a/supacodeTests/AppFeatureMenuBarNotificationsTests.swift +++ b/supacodeTests/AppFeatureMenuBarNotificationsTests.swift @@ -9,149 +9,194 @@ import Testing @MainActor struct AppFeatureMenuBarNotificationsTests { - @Test(.dependencies) func menuBarNotificationSelectedFocusesSurfaceAndMarksRead() async { + @Test(.dependencies) func menuBarWorktreeSelectedSelectsWorktreeAndSurfacesTheWindow() async { let worktree = makeWorktree() - let tabID = TerminalTabID() - let surfaceID = UUID() - let notificationID = UUID() - let focused = LockIsolated<[TerminalClient.Command]>([]) - let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) + let surfaced = LockIsolated(0) let store = makeStore(worktree: worktree) { - $0.terminalClient.send = { command in - focused.withValue { $0.append(command) } - } - $0.terminalClient.markNotificationRead = { worktreeID, notificationID in - marked.withValue { $0.append((worktreeID, notificationID)) } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true } } - await store.send( - .menuBarNotificationSelected( - worktreeID: worktree.id, tabID: tabID, surfaceID: surfaceID, notificationID: notificationID - ) - ) + await store.send(.menuBarWorktreeSelected(worktreeID: worktree.id)) await store.receive(\.repositories.selectWorktree) await store.finish() - let focusCommands = focused.value.filter { - if case .focusSurface = $0 { return true } else { return false } - } - #expect( - focusCommands == [.focusSurface(worktree, tabID: tabID, surfaceID: surfaceID, input: nil)] - ) - #expect(marked.value.count == 1) - #expect(marked.value.first?.0 == worktree.id) - #expect(marked.value.first?.1 == notificationID) + #expect(store.state.repositories.selectedWorktreeID == worktree.id) + #expect(surfaced.value == 1) } - @Test(.dependencies) func menuBarNotificationSelectedResolvesTabWhenMissing() async { + @Test(.dependencies) func menuBarWorktreeSelectedStillSurfacesTheWindowWhenTheWorktreeIsGone() async { let worktree = makeWorktree() - let resolvedTabID = TerminalTabID() - let surfaceID = UUID() - let focused = LockIsolated<[TerminalClient.Command]>([]) + let surfaced = LockIsolated(0) let store = makeStore(worktree: worktree) { - $0.terminalClient.tabID = { _, _ in resolvedTabID } - $0.terminalClient.send = { command in - focused.withValue { $0.append(command) } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true } - $0.terminalClient.markNotificationRead = { _, _ in } } - await store.send( - .menuBarNotificationSelected( - worktreeID: worktree.id, tabID: nil, surfaceID: surfaceID, notificationID: UUID() - ) - ) + // A stale click must never be a no-op: in menu bar mode that is + // indistinguishable from a hung app. + await store.send(.menuBarWorktreeSelected(worktreeID: Worktree.ID("/tmp/repo/vanished"))) await store.finish() - let focusCommands = focused.value.filter { - if case .focusSurface = $0 { return true } else { return false } - } - #expect( - focusCommands == [.focusSurface(worktree, tabID: resolvedTabID, surfaceID: surfaceID, input: nil)] - ) + #expect(surfaced.value == 1) } - @Test(.dependencies) func menuBarNotificationSelectedStillMarksReadWhenSurfaceGone() async { + @Test(.dependencies) func markAllNotificationsReadForwardsToTerminalClient() async { let worktree = makeWorktree() - let focused = LockIsolated<[TerminalClient.Command]>([]) - let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) - let notificationID = UUID() + let calls = LockIsolated(0) let store = makeStore(worktree: worktree) { - $0.terminalClient.tabID = { _, _ in nil } - $0.terminalClient.send = { command in - focused.withValue { $0.append(command) } - } - $0.terminalClient.markNotificationRead = { worktreeID, notificationID in - marked.withValue { $0.append((worktreeID, notificationID)) } + $0.terminalClient.markAllNotificationsRead = { + calls.withValue { $0 += 1 } } } - await store.send( - .menuBarNotificationSelected( - worktreeID: worktree.id, tabID: nil, surfaceID: UUID(), notificationID: notificationID - ) - ) + await store.send(.markAllNotificationsRead) await store.finish() - let focusCommands = focused.value.filter { - if case .focusSurface = $0 { return true } else { return false } + #expect(calls.value == 1) + } + + // MARK: - Activation policy. + + @Test(.dependencies) func switchingToMenuBarOnlyAppliesTheAccessoryPolicy() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } } - #expect(focusCommands.isEmpty) - #expect(marked.value.count == 1) - #expect(marked.value.first?.1 == notificationID) + + var settings = GlobalSettings.default + settings.appVisibility = .menuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.menuBar]) + // Nothing to surface: the Dock icon is going away, not coming back. + #expect(surfaced.value == 0) } - @Test(.dependencies) func menuBarWorktreeSelectedSelectsWorktree() async { - let worktree = makeWorktree() - let store = makeStore(worktree: worktree) { _ in } + @Test(.dependencies) func leavingMenuBarOnlyRestoresTheDockIconAndSurfacesTheWindow() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true + } + } - await store.send(.menuBarWorktreeSelected(worktreeID: worktree.id)) - await store.receive(\.repositories.selectWorktree) + var settings = GlobalSettings.default + settings.appVisibility = .dock + await store.send(.settings(.delegate(.settingsChanged(settings)))) await store.finish() - #expect(store.state.repositories.selectedWorktreeID == worktree.id) + #expect(applied.value == [.dock]) + // Without this the user leaves menu bar mode with a Dock icon and no window. + #expect(surfaced.value == 1) } - @Test(.dependencies) func markAllNotificationsReadForwardsToTerminalClient() async { - let worktree = makeWorktree() - let calls = LockIsolated(0) - let store = makeStore(worktree: worktree) { - $0.terminalClient.markAllNotificationsRead = { - calls.withValue { $0 += 1 } + @Test(.dependencies) func leavingMenuBarOnlyForBothAlsoSurfacesTheWindow() async { + let applied = LockIsolated<[AppVisibility]>([]) + let surfaced = LockIsolated(0) + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { + surfaced.withValue { $0 += 1 } + return true } } - await store.send(.markAllNotificationsRead) + var settings = GlobalSettings.default + settings.appVisibility = .dockAndMenuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) await store.finish() - #expect(calls.value == 1) + #expect(applied.value == [.dockAndMenuBar]) + // The Dock icon comes back here too, so the window must follow. + #expect(surfaced.value == 1) } - @Test(.dependencies) func showNotificationsPanePresentsNotificationsInspector() async { - let worktree = makeWorktree() - let store = makeStore(worktree: worktree) { _ in } + @Test(.dependencies) func aRefusedPolicySwitchFallsBackToTheModeThatStillHasASurface() async { + var initialSettings = GlobalSettings.default + initialSettings.appVisibility = .menuBar + let store = makeStore(worktree: makeWorktree(), settings: initialSettings) { + // AppKit refuses `.accessory` -> `.regular`: without a fallback the status + // item is already gone and no Dock icon arrives, leaving no surface at all. + $0.appLifecycleClient.applyVisibility = { _ in false } + $0.appLifecycleClient.surfaceMainWindow = { true } + } - await store.send(.showNotificationsPane) - await store.receive(\.repositories.toggleInspectorPane) + var settings = GlobalSettings.default + settings.appVisibility = .dock + await store.send(.settings(.delegate(.settingsChanged(settings)))) + // The fallback must carry `.menuBar` specifically: a wrong direction here + // (e.g. re-sending `.dock`) would strand the user with no surface. + await store.receive(\.settings.setAppVisibility, .menuBar) await store.finish() - #expect(store.state.repositories.inspectorPresented) - #expect(store.state.repositories.inspectorPane == .notifications) + #expect(store.state.settings.appVisibility == .menuBar) } - @Test(.dependencies) func showNotificationsPaneDoesNotToggleAwayWhenAlreadyPresented() async { - let worktree = makeWorktree() - let store = makeStore(worktree: worktree) { _ in } + @Test(.dependencies) func repeatedSettingsChangesDoNotReapplyTheSameVisibility() async { + let applied = LockIsolated<[AppVisibility]>([]) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + $0.appLifecycleClient.surfaceMainWindow = { true } + } + + var settings = GlobalSettings.default + settings.appVisibility = .menuBar + await store.send(.settings(.delegate(.settingsChanged(settings)))) + // A second, identical delegate must not re-apply the policy: `activate()` on + // the retry path would steal focus on every unrelated settings edit. + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + + #expect(applied.value == [.menuBar]) + #expect(store.state.lastKnownAppVisibility == .menuBar) + } + + @Test(.dependencies) func unchangedVisibilityTouchesTheActivationPolicyNotAtAll() async { + let applied = LockIsolated<[AppVisibility]>([]) + let store = makeStore(worktree: makeWorktree()) { + $0.appLifecycleClient.applyVisibility = { visibility in + applied.withValue { $0.append(visibility) } + return true + } + } - await store.send(.showNotificationsPane) - await store.receive(\.repositories.toggleInspectorPane) - // A second invocation must keep the pane open instead of toggling it closed. - await store.send(.showNotificationsPane) + var settings = GlobalSettings.default + settings.appVisibility = .dock + settings.agentPresenceBadgesEnabled.toggle() + await store.send(.settings(.delegate(.settingsChanged(settings)))) await store.finish() - #expect(store.state.repositories.inspectorPresented) - #expect(store.state.repositories.inspectorPane == .notifications) + #expect(applied.value.isEmpty) } // MARK: - Helpers. @@ -171,6 +216,7 @@ struct AppFeatureMenuBarNotificationsTests { private func makeStore( worktree: Worktree, + settings: GlobalSettings = .default, withAdditionalDependencies: (inout DependencyValues) -> Void ) -> TestStoreOf { var repositoriesState = RepositoriesFeature.State() @@ -187,7 +233,7 @@ struct AppFeatureMenuBarNotificationsTests { let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, - settings: SettingsFeature.State() + settings: SettingsFeature.State(settings: settings) ) ) { AppFeature() diff --git a/supacodeTests/AppVisibilityTests.swift b/supacodeTests/AppVisibilityTests.swift new file mode 100644 index 000000000..a87acc7a1 --- /dev/null +++ b/supacodeTests/AppVisibilityTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import SupacodeSettingsShared + +struct AppVisibilityTests { + /// The whole point of the three-case model: no mode may hide the app from + /// both surfaces at once, which would leave it unreachable. + @Test func everyModeKeepsAtLeastOneSurfaceEnabled() { + for visibility in AppVisibility.allCases { + #expect(visibility.showsDockIcon || visibility.showsMenuBarIcon) + } + } + + @Test func onlyMenuBarModeRunsAsAnAccessory() { + #expect(AppVisibility.menuBar.hidesDockIcon) + #expect(!AppVisibility.dock.hidesDockIcon) + #expect(!AppVisibility.dockAndMenuBar.hidesDockIcon) + } + + @Test func menuBarIconIsInsertedForBothMenuBarModes() { + #expect(AppVisibility.menuBar.showsMenuBarIcon) + #expect(AppVisibility.dockAndMenuBar.showsMenuBarIcon) + #expect(!AppVisibility.dock.showsMenuBarIcon) + } + + /// Raw values are persisted, so reordering or renaming a case must not + /// silently repoint an existing settings file at a different mode. + @Test func rawValuesAreStable() { + #expect(AppVisibility(rawValue: "dock") == .dock) + #expect(AppVisibility(rawValue: "menuBar") == .menuBar) + #expect(AppVisibility(rawValue: "dockAndMenuBar") == .dockAndMenuBar) + } + + @Test func cardsReadDockThenMenuBarThenBoth() { + #expect(AppVisibility.allCases.map(\.title) == ["Dock", "Menu Bar", "Both"]) + } + + @Test func everyCardHasArtwork() { + #expect( + AppVisibility.allCases.map(\.imageName) + == ["VisibilityDock", "VisibilityMenuBar", "VisibilityBoth"] + ) + } +} diff --git a/supacodeTests/MenuBarNotificationListTests.swift b/supacodeTests/MenuBarNotificationListTests.swift index 63b28e006..25a83be33 100644 --- a/supacodeTests/MenuBarNotificationListTests.swift +++ b/supacodeTests/MenuBarNotificationListTests.swift @@ -1,4 +1,6 @@ import Foundation +import IdentifiedCollections +import Sharing import Testing @testable import SupacodeSettingsShared @@ -6,89 +8,233 @@ import Testing @MainActor struct MenuBarNotificationListTests { - @Test func includesWorktreesWithUnreadOrActiveAgentAndExcludesQuietOnes() { - let list = MenuBarNotificationList.compute(rows: [ - makeRow(name: "unread", unreadCount: 2, hasActiveAgent: false), - makeRow(name: "active", unreadCount: 0, hasActiveAgent: true), - makeRow(name: "quiet", unreadCount: 0, hasActiveAgent: false), - ]) - - #expect(list.rows.map(\.worktreeName) == ["unread", "active"]) + @Test func listsUnreadWorktreesWithTheirCountAndRepoSubtitle() { + let noisy = makeWorktree(id: "/tmp/repo/noisy", name: "noisy") + let quiet = makeWorktree(id: "/tmp/repo/quiet", name: "quiet") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [noisy, quiet])] + ) + setRowNotifications( + &state, id: noisy.id, + notifications: [ + makeNotification(isRead: false), + makeNotification(isRead: false), + makeNotification(isRead: true), + ] + ) + + let sections = state.computeMenuBarSections() + + #expect(sections.unread == [noisy.id]) + #expect(sections.repositoryTagByID[Repository.ID("/tmp/repo/")]?.repoName == "repo") + #expect(sections.hasUnread) } - @Test func ordersUnreadWorktreesBeforeActiveOnlyOnes() { - let list = MenuBarNotificationList.compute(rows: [ - makeRow(name: "active", unreadCount: 0, hasActiveAgent: true), - makeRow(name: "unread", unreadCount: 1, hasActiveAgent: false), - ]) + @Test func isEmptyWhenEveryNotificationIsRead() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] + ) + setRowNotifications(&state, id: worktree.id, notifications: [makeNotification(isRead: true)]) + + let sections = state.computeMenuBarSections() - #expect(list.rows.first?.worktreeName == "unread") - #expect(list.rows.last?.worktreeName == "active") + #expect(sections.unread.isEmpty) + #expect(!sections.hasUnread) } - @Test func ordersByUnreadCountDescendingThenName() { - let list = MenuBarNotificationList.compute(rows: [ - makeRow(name: "beta", unreadCount: 1, hasActiveAgent: false), - makeRow(name: "alpha", unreadCount: 3, hasActiveAgent: false), - ]) + @Test func mirrorsTheSidebarActiveSection() { + let working = makeWorktree(id: "/tmp/repo/working", name: "working") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [working])] + ) + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: working.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() - #expect(list.rows.map(\.worktreeName) == ["alpha", "beta"]) + // The sidebar hoists a tracked agent into Active, so the menu must too. + #expect(state.sidebarStructure.hoistedRowIDs.contains(working.id)) + #expect(sections.active == [working.id]) + #expect(sections.unread.isEmpty) } - @Test func emptyWhenNothingNeedsAttention() { - let list = MenuBarNotificationList.compute(rows: [ - makeRow(name: "quiet", unreadCount: 0, hasActiveAgent: false) - ]) + @Test func unreadSectionExcludesRowsTheHighlightSectionsAlreadyShow() { + let hoisted = makeWorktree(id: "/tmp/repo/hoisted", name: "hoisted") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [hoisted])] + ) + // Unread plus a tracked agent classifies as Active, so the row is hoisted. + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: hoisted.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + setRowNotifications(&state, id: hoisted.id, notifications: [makeNotification(isRead: false)]) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() + + #expect(sections.active == [hoisted.id]) + #expect(sections.unread.isEmpty) + // The dot and "Mark All as Read" must still see the unread row. + #expect(sections.hasUnread) + } + + @Test func highlightSectionsAreTakenStraightFromTheSidebarStructure() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] + ) + state.reconcileSidebarForTesting() + + // Nothing hoisted: the menu must not invent rows the sidebar doesn't show. + #expect(state.sidebarStructure.hoistedRowIDs.isEmpty) + let sections = state.computeMenuBarSections() + #expect(sections.pinned.isEmpty) + #expect(sections.active.isEmpty) + #expect(sections.isEmpty) + } - #expect(list.rows.isEmpty) - #expect(!list.hasUnread) + @Test func entriesRenderInPinnedThenActiveThenUnreadOrder() { + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned") + let working = makeWorktree(id: "/tmp/repo/working", name: "working") + let noisy = makeWorktree(id: "/tmp/repo/noisy", name: "noisy") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [pinned, working, noisy])] + ) + state.$sidebar.withLock { sidebar in + sidebar.insert(worktree: pinned.id, in: Repository.ID("/tmp/repo/"), bucket: .pinned) + } + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: working.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + setRowNotifications(&state, id: noisy.id, notifications: [makeNotification(isRead: false)]) + state.reconcileSidebarForTesting() + + let sections = state.computeMenuBarSections() + #expect(sections.pinned == [pinned.id]) + #expect(sections.active == [working.id]) + #expect(sections.unread == [noisy.id]) + + // The user-visible section order is the whole point of the flattening. + #expect( + sections.entries.map(\.id) == [ + .header("Pinned"), .row(pinned.id), + .header("Active"), .row(working.id), + .header("Unread"), .row(noisy.id), + ] + ) } - @Test func hasUnreadReflectsAnyUnreadRow() { - let unreadList = MenuBarNotificationList.compute(rows: [ - makeRow(name: "unread", unreadCount: 1, hasActiveAgent: false) - ]) - let activeOnlyList = MenuBarNotificationList.compute(rows: [ - makeRow(name: "active", unreadCount: 0, hasActiveAgent: true) - ]) + @Test func listsUnreadFolderRepositoriesByTheirSyntheticRow() { + let folderURL = URL(fileURLWithPath: "/tmp/folder") + let folderID = Repository.folderWorktreeID(for: folderURL) + var state = RepositoriesFeature.State( + reconciledRepositories: [ + Repository( + id: RepositoryID(folderURL.path(percentEncoded: false) + "/"), + rootURL: folderURL, + name: "folder", + worktrees: [ + Worktree( + id: folderID, + name: "folder", + detail: "", + workingDirectory: folderURL, + repositoryRootURL: folderURL + ) + ], + isGitRepository: false + ) + ] + ) + setRowNotifications(&state, id: folderID, notifications: [makeNotification(isRead: false)]) - #expect(unreadList.hasUnread) - #expect(!activeOnlyList.hasUnread) + // A folder's notifications land on its synthetic folder row, which the + // non-git branch must still surface. + #expect(state.computeMenuBarSections().unread == [folderID]) } - @Test func agentNotificationHeadlinesSessionTitle() { - let notification = WorktreeTerminalNotification( - surfaceID: UUID(), - title: "claude", - body: "needs your permission", - createdAt: Date(timeIntervalSinceReferenceDate: 0) + @Test func excludesArchivedWorktreesFromUnread() { + let archived = makeWorktree(id: "/tmp/repo/archived", name: "archived") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [archived])] + ) + setRowNotifications(&state, id: archived.id, notifications: [makeNotification(isRead: false)]) + state.$sidebar.withLock { sidebar in + sidebar.insert( + worktree: archived.id, + in: Repository.ID("/tmp/repo/"), + bucket: .archived, + item: .init(archivedAt: Date(timeIntervalSince1970: 1_000_000)) + ) + } + state.reconcileSidebarForTesting() + + // An archived row has nothing actionable behind it, so it must not light + // the status item or appear in the menu. + #expect(state.computeMenuBarSections().unread.isEmpty) + } + + @Test func postReduceHookRefreshesTheCache() { + let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") + var state = RepositoriesFeature.State( + reconciledRepositories: [makeRepository(worktrees: [worktree])] ) + #expect(state.menuBarSectionsCache.unread.isEmpty) + + setRowNotifications(&state, id: worktree.id, notifications: [makeNotification(isRead: false)]) + state.applyPostReduceCacheRecomputes(.toolbarNotificationGroups) + + #expect(state.menuBarSectionsCache.unread == [worktree.id]) - #expect(notification.headline(sessionTitle: "fix login bug") == "fix login bug") + // Agent activity only raises `.sidebarStructure`, so the cache must key off + // that flag too or an agent-only row would never reach the menu. + let instance = AgentPresenceFeature.AgentInstance(agent: .claude, activity: .busy) + state.sidebarItems[id: worktree.id]?.agentSnapshot = .init(agents: [instance], isWorking: true) + setRowNotifications(&state, id: worktree.id, notifications: []) + state.applyPostReduceCacheRecomputes(.sidebarStructure) + + #expect(state.menuBarSectionsCache.unread.isEmpty) + #expect(state.menuBarSectionsCache.active == [worktree.id]) } - @Test func agentNotificationWithoutSessionTitleFallsBackToDisplayName() { - let notification = WorktreeTerminalNotification( + // MARK: - Helpers. + + private func makeNotification(isRead: Bool) -> WorktreeTerminalNotification { + WorktreeTerminalNotification( surfaceID: UUID(), title: "claude", - body: "done", - createdAt: Date(timeIntervalSinceReferenceDate: 0) + body: "needs your permission", + createdAt: .distantPast, + isRead: isRead ) + } - #expect(notification.headline(sessionTitle: nil) == "Claude Code") + private func setRowNotifications( + _ state: inout RepositoriesFeature.State, + id: SidebarItemID, + notifications: [WorktreeTerminalNotification] + ) { + state.sidebarItems[id: id]?.notifications = IdentifiedArrayOf(uniqueElements: notifications) + state.sidebarItems[id: id]?.hasUnseenNotifications = notifications.contains { !$0.isRead } + } + + private func makeWorktree(id: String, name: String) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) } - private func makeRow( - name: String, - unreadCount: Int, - hasActiveAgent: Bool - ) -> MenuBarWorktreeRow { - MenuBarWorktreeRow( - id: Worktree.ID("/tmp/repo/\(name)"), - repoName: "repo", - worktreeName: name, - unreadCount: unreadCount, - hasActiveAgent: hasActiveAgent + private func makeRepository(worktrees: [Worktree]) -> Repository { + Repository( + // `Repository.id` keeps its trailing slash; the worktree IDs don't. + id: "/tmp/repo/", + rootURL: URL(fileURLWithPath: "/tmp/repo"), + name: "repo", + worktrees: IdentifiedArray(uniqueElements: worktrees) ) } } diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 9ddbf1aba..3d52bc504 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -410,9 +410,8 @@ struct SettingsFilePersistenceTests { } @Test(.dependencies) func decodesMissingAppVisibilityAsDock() throws { - // A file predating the visibility model (no `appVisibility`, no legacy - // `showMenuBarIcon`) predates the menu bar feature entirely, so it was - // Dock-only — the menu bar stays opt-in. + // A file predating the menu bar feature was Dock-only, and the menu bar + // stays opt-in. let legacy = LegacySettingsFile( global: LegacyGlobalSettings( appearanceMode: .dark, @@ -434,19 +433,19 @@ struct SettingsFilePersistenceTests { #expect(settings.global.appVisibility == .dock) } - @Test(.dependencies) func migratesLegacyShowMenuBarIconTrueToDockAndMenuBar() throws { - // The old boolean was always paired with a Dock icon, so "menu bar on" - // maps to both surfaces enabled. - let legacy = LegacySettingsFileWithMenuBarToggle( - global: LegacyGlobalSettingsWithMenuBarToggle( - appearanceMode: .dark, - updatesAutomaticallyCheckForUpdates: true, + @Test(.dependencies) func decodesUnrecognizedAppVisibilityAsDockWithoutDiscardingTheFile() throws { + // A throw here would reset the whole file to defaults and write it back, so + // a hand-edited or newer-than-us value must fall through to the default. + let file = SettingsFileWithRawAppVisibility( + global: GlobalSettingsWithRawAppVisibility( + appearanceMode: .light, + updatesAutomaticallyCheckForUpdates: false, updatesAutomaticallyDownloadUpdates: false, - showMenuBarIcon: true + appVisibility: "menubar" ), repositories: [:] ) - let data = try JSONEncoder().encode(legacy) + let data = try JSONEncoder().encode(file) let storage = MutableTestStorage(initialData: data) let settings: SettingsFile = withDependencies { @@ -456,22 +455,19 @@ struct SettingsFilePersistenceTests { return settings } - #expect(settings.global.appVisibility == .dockAndMenuBar) + #expect(settings.global.appVisibility == .dock) + // Both differ from `GlobalSettings.default`, so a reset-to-defaults would fail here. + #expect(settings.global.appearanceMode == .light) + #expect(settings.global.updatesAutomaticallyCheckForUpdates == false) } - @Test(.dependencies) func migratesLegacyShowMenuBarIconFalseToDock() throws { - // Menu bar off + the always-present Dock icon collapses to Dock-only. - let legacy = LegacySettingsFileWithMenuBarToggle( - global: LegacyGlobalSettingsWithMenuBarToggle( - appearanceMode: .dark, - updatesAutomaticallyCheckForUpdates: true, - updatesAutomaticallyDownloadUpdates: false, - showMenuBarIcon: false - ), - repositories: [:] - ) - let data = try JSONEncoder().encode(legacy) - let storage = MutableTestStorage(initialData: data) + @Test(.dependencies) func decodesMistypedAppVisibilityAsDockWithoutDiscardingTheFile() throws { + // A hand-edit can produce the wrong JSON type, not just an unknown string. + let json = """ + {"global":{"appearanceMode":"light","updatesAutomaticallyCheckForUpdates":false,\ + "updatesAutomaticallyDownloadUpdates":false,"appVisibility":3},"repositories":{}} + """ + let storage = MutableTestStorage(initialData: Data(json.utf8)) let settings: SettingsFile = withDependencies { $0.settingsFileStorage = storage.storage @@ -481,6 +477,7 @@ struct SettingsFilePersistenceTests { } #expect(settings.global.appVisibility == .dock) + #expect(settings.global.appearanceMode == .light) } @Test(.dependencies) func roundTripsExplicitAppVisibility() throws { @@ -603,14 +600,14 @@ private struct LegacyGlobalSettingsWithQuitToggle: Codable { var confirmBeforeQuit: Bool } -private struct LegacySettingsFileWithMenuBarToggle: Codable { - var global: LegacyGlobalSettingsWithMenuBarToggle +private struct SettingsFileWithRawAppVisibility: Codable { + var global: GlobalSettingsWithRawAppVisibility var repositories: [String: RepositorySettings] } -private struct LegacyGlobalSettingsWithMenuBarToggle: Codable { +private struct GlobalSettingsWithRawAppVisibility: Codable { var appearanceMode: AppearanceMode var updatesAutomaticallyCheckForUpdates: Bool var updatesAutomaticallyDownloadUpdates: Bool - var showMenuBarIcon: Bool + var appVisibility: String }