fix(mac): sessions error UI + sleeping icon

This commit is contained in:
Peter Steinberger
2025-12-22 21:02:26 +01:00
parent a11a204b8e
commit 9d47b15575
5 changed files with 178 additions and 44 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"
}
}

View File

@@ -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)
}
}