diff --git a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift index 6d69dbc97..2ccddb797 100644 --- a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift +++ b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift @@ -3,6 +3,7 @@ import SwiftUI struct CritterStatusLabel: View { var isPaused: Bool + var isSleeping: Bool var isWorking: Bool var earBoostActive: Bool var blinkTick: Int @@ -25,6 +26,10 @@ struct CritterStatusLabel: View { self.iconState.isWorking || self.isWorking } + private var effectiveAnimationsEnabled: Bool { + self.animationsEnabled && !self.isSleeping + } + var body: some View { ZStack(alignment: .topTrailing) { self.iconImage @@ -34,7 +39,7 @@ struct CritterStatusLabel: View { // Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks // triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead. .task(id: self.tickTaskID) { - guard self.animationsEnabled, !self.earBoostActive else { + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { await MainActor.run { self.resetMotion() } return } @@ -47,24 +52,27 @@ struct CritterStatusLabel: View { } .onChange(of: self.isPaused) { _, _ in self.resetMotion() } .onChange(of: self.blinkTick) { _, _ in - guard !self.earBoostActive else { return } + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } self.blink() } .onChange(of: self.sendCelebrationTick) { _, _ in - guard !self.earBoostActive else { return } + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } self.wiggleLegs() } .onChange(of: self.animationsEnabled) { _, enabled in - if enabled { + if enabled, !self.isSleeping { self.scheduleRandomTimers(from: Date()) } else { self.resetMotion() } } + .onChange(of: self.isSleeping) { _, _ in + self.resetMotion() + } .onChange(of: self.earBoostActive) { _, active in if active { self.resetMotion() - } else if self.animationsEnabled { + } else if self.effectiveAnimationsEnabled { self.scheduleRandomTimers(from: Date()) } } @@ -81,11 +89,11 @@ struct CritterStatusLabel: View { private var tickTaskID: Int { // Ensure SwiftUI restarts (and cancels) the task when these change. - (self.animationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0) + (self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0) } private func tick(_ now: Date) { - guard self.animationsEnabled, !self.earBoostActive else { + guard self.effectiveAnimationsEnabled, !self.earBoostActive else { self.resetMotion() return } @@ -128,6 +136,10 @@ struct CritterStatusLabel: View { return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil)) } + if self.isSleeping { + return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, badge: nil)) + } + return Image(nsImage: CritterIconRenderer.makeIcon( blink: self.blinkAmount, legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), @@ -216,11 +228,12 @@ struct CritterStatusLabel: View { } private var gatewayNeedsAttention: Bool { + if self.isSleeping { return false } switch self.gatewayStatus { case .failed, .stopped: - !self.isPaused + return !self.isPaused case .starting, .running, .attachedExisting: - false + return false } } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 924adec02..e1de26698 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -11,6 +11,7 @@ struct ClawdisApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @State private var state: AppState private let gatewayManager = GatewayProcessManager.shared + private let controlChannel = ControlChannel.shared private let activityStore = WorkActivityStore.shared @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false @@ -35,29 +36,36 @@ struct ClawdisApp: App { MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { CritterStatusLabel( isPaused: self.state.isPaused, + isSleeping: self.isGatewaySleeping, isWorking: self.state.isWorking, earBoostActive: self.state.earBoostActive, blinkTick: self.state.blinkTick, sendCelebrationTick: self.state.sendCelebrationTick, gatewayStatus: self.gatewayManager.status, - animationsEnabled: self.state.iconAnimationsEnabled, + animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping, iconState: self.effectiveIconState) } .menuBarExtraStyle(.menu) .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in self.statusItem = item - self.applyStatusItemAppearance(paused: self.state.isPaused) + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) self.installStatusItemMouseHandler(for: item) self.updateHoverHUDSuppression() } .onChange(of: self.state.isPaused) { _, paused in - self.applyStatusItemAppearance(paused: paused) + self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping) if self.state.connectionMode == .local { self.gatewayManager.setActive(!paused) } else { self.gatewayManager.stop() } } + .onChange(of: self.controlChannel.state) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } + .onChange(of: self.gatewayManager.status) { _, _ in + self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) + } .onChange(of: self.state.connectionMode) { _, mode in Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } } @@ -75,8 +83,27 @@ struct ClawdisApp: App { } } - private func applyStatusItemAppearance(paused: Bool) { - self.statusItem?.button?.appearsDisabled = paused + private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) { + self.statusItem?.button?.appearsDisabled = paused || sleeping + } + + private var isGatewaySleeping: Bool { + if self.state.isPaused { return false } + switch self.state.connectionMode { + case .unconfigured: + return true + case .remote: + if case .connected = self.controlChannel.state { return false } + return true + case .local: + switch self.gatewayManager.status { + case .running, .starting, .attachedExisting: + if case .connected = self.controlChannel.state { return false } + return true + case .failed, .stopped: + return true + } + } } @MainActor diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index aa172513e..e38db2ad8 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -18,6 +18,8 @@ struct MenuContent: View { @State private var loadingMics = false @State private var sessionMenu: [SessionRow] = [] @State private var sessionStorePath: String? + @State private var sessionLoading = true + @State private var sessionErrorText: String? @State private var browserControlEnabled = true private let sessionMenuItemWidth: CGFloat = 320 private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60 @@ -31,7 +33,9 @@ struct MenuContent: View { } } .disabled(self.state.connectionMode == .unconfigured) + self.sessionsSection + Divider() Toggle(isOn: self.heartbeatsBinding) { VStack(alignment: .leading, spacing: 2) { @@ -196,12 +200,39 @@ struct MenuContent: View { private var sessionsSection: some View { Group { - Divider() + MenuHostedItem( + width: self.sessionMenuItemWidth, + rootView: AnyView(MenuSessionsHeaderView( + count: self.sessionMenu.count, + statusText: self.sessionLoading + ? "Loading sessions…" + : (self.sessionMenu.isEmpty ? nil : self.sessionErrorText)))) + .disabled(true) - if self.sessionMenu.isEmpty { - Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) + if self.sessionMenu.isEmpty, !self.sessionLoading, let error = self.sessionErrorText, !error.isEmpty { + MenuHostedItem( + width: self.sessionMenuItemWidth, + rootView: AnyView( + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 20) + .padding(.trailing, 10) + .padding(.vertical, 6) + .frame(minWidth: 300, alignment: .leading))) + .disabled(true) + } else if self.sessionMenu.isEmpty, !self.sessionLoading, self.sessionErrorText == nil { + MenuHostedItem( + width: self.sessionMenuItemWidth, + rootView: AnyView(Text("No active sessions") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 20) + .padding(.trailing, 10) + .padding(.vertical, 6) + .frame(minWidth: 300, alignment: .leading))) .disabled(true) } else { ForEach(self.sessionMenu) { row in @@ -375,6 +406,45 @@ struct MenuContent: View { } } + @MainActor + private func reloadSessionMenu() async { + self.sessionLoading = true + self.sessionErrorText = nil + + do { + let snapshot = try await SessionLoader.loadSnapshot(limit: 32) + self.sessionStorePath = snapshot.storePath + let now = Date() + let active = snapshot.rows.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.sessionMenuActiveWindowSeconds + } + self.sessionMenu = active.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + } catch { + // Keep the previous snapshot (if any) so the menu doesn't go empty while the gateway is flaky. + self.sessionErrorText = self.compactSessionError(error) + } + + self.sessionLoading = false + } + + private func compactSessionError(_ error: Error) -> String { + if let loadError = error as? SessionLoadError { + switch loadError { + case .gatewayUnavailable: + return "Sessions unavailable — gateway unreachable" + case .decodeFailed: + return "Sessions unavailable — invalid payload" + } + } + return "Sessions unavailable" + } + private func open(tab: SettingsTab) { SettingsTabRouter.request(tab) NSApp.activate(ignoringOtherApps: true) @@ -561,28 +631,6 @@ struct MenuContent: View { return "System default" } - @MainActor - private func reloadSessionMenu() async { - do { - let snapshot = try await SessionLoader.loadSnapshot(limit: 32) - self.sessionStorePath = snapshot.storePath - let now = Date() - let active = snapshot.rows.filter { row in - if row.key == "main" { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.sessionMenuActiveWindowSeconds - } - self.sessionMenu = active.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - } catch { - self.sessionStorePath = nil - self.sessionMenu = [] - } - } - @MainActor private func loadMicrophones(force: Bool = false) async { guard self.showVoiceWakeMicPicker else { diff --git a/apps/macos/Sources/Clawdis/MenuSessionsHeaderView.swift b/apps/macos/Sources/Clawdis/MenuSessionsHeaderView.swift new file mode 100644 index 000000000..e96cea53b --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuSessionsHeaderView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct MenuSessionsHeaderView: View { + let count: Int + let statusText: String? + + private let paddingTop: CGFloat = 8 + private let paddingBottom: CGFloat = 6 + private let paddingTrailing: CGFloat = 10 + private let paddingLeading: CGFloat = 20 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.top, self.paddingTop) + .padding(.bottom, self.paddingBottom) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } + + private var subtitle: String { + if self.count == 1 { return "1 session · 24h" } + return "\(self.count) sessions · 24h" + } +} diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index d826f33c6..9c59863a5 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -3,14 +3,15 @@ import SwiftUI struct SessionMenuLabelView: View { let row: SessionRow let width: CGFloat - private let horizontalPadding: CGFloat = 8 + private let paddingLeading: CGFloat = 20 + private let paddingTrailing: CGFloat = 10 var body: some View { VStack(alignment: .leading, spacing: 5) { ContextUsageBar( usedTokens: row.tokens.total, contextTokens: row.tokens.contextTokens, - width: max(1, self.width - (self.horizontalPadding * 2)), + width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), height: 4) HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -31,6 +32,7 @@ struct SessionMenuLabelView: View { } } .padding(.vertical, 4) - .padding(.horizontal, self.horizontalPadding) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) } }