fix(mac): sessions error UI + sleeping icon

main
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 { struct CritterStatusLabel: View {
var isPaused: Bool var isPaused: Bool
var isSleeping: Bool
var isWorking: Bool var isWorking: Bool
var earBoostActive: Bool var earBoostActive: Bool
var blinkTick: Int var blinkTick: Int
@ -25,6 +26,10 @@ struct CritterStatusLabel: View {
self.iconState.isWorking || self.isWorking self.iconState.isWorking || self.isWorking
} }
private var effectiveAnimationsEnabled: Bool {
self.animationsEnabled && !self.isSleeping
}
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
self.iconImage 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 // 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. // triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) { .task(id: self.tickTaskID) {
guard self.animationsEnabled, !self.earBoostActive else { guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() } await MainActor.run { self.resetMotion() }
return return
} }
@ -47,24 +52,27 @@ struct CritterStatusLabel: View {
} }
.onChange(of: self.isPaused) { _, _ in self.resetMotion() } .onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in .onChange(of: self.blinkTick) { _, _ in
guard !self.earBoostActive else { return } guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.blink() self.blink()
} }
.onChange(of: self.sendCelebrationTick) { _, _ in .onChange(of: self.sendCelebrationTick) { _, _ in
guard !self.earBoostActive else { return } guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.wiggleLegs() self.wiggleLegs()
} }
.onChange(of: self.animationsEnabled) { _, enabled in .onChange(of: self.animationsEnabled) { _, enabled in
if enabled { if enabled, !self.isSleeping {
self.scheduleRandomTimers(from: Date()) self.scheduleRandomTimers(from: Date())
} else { } else {
self.resetMotion() self.resetMotion()
} }
} }
.onChange(of: self.isSleeping) { _, _ in
self.resetMotion()
}
.onChange(of: self.earBoostActive) { _, active in .onChange(of: self.earBoostActive) { _, active in
if active { if active {
self.resetMotion() self.resetMotion()
} else if self.animationsEnabled { } else if self.effectiveAnimationsEnabled {
self.scheduleRandomTimers(from: Date()) self.scheduleRandomTimers(from: Date())
} }
} }
@ -81,11 +89,11 @@ struct CritterStatusLabel: View {
private var tickTaskID: Int { private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change. // 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) { private func tick(_ now: Date) {
guard self.animationsEnabled, !self.earBoostActive else { guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
self.resetMotion() self.resetMotion()
return return
} }
@ -128,6 +136,10 @@ struct CritterStatusLabel: View {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil)) 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( return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount, blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
@ -216,11 +228,12 @@ struct CritterStatusLabel: View {
} }
private var gatewayNeedsAttention: Bool { private var gatewayNeedsAttention: Bool {
if self.isSleeping { return false }
switch self.gatewayStatus { switch self.gatewayStatus {
case .failed, .stopped: case .failed, .stopped:
!self.isPaused return !self.isPaused
case .starting, .running, .attachedExisting: case .starting, .running, .attachedExisting:
false return false
} }
} }

View File

@ -11,6 +11,7 @@ struct ClawdisApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var state: AppState @State private var state: AppState
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared private let activityStore = WorkActivityStore.shared
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@ -35,29 +36,36 @@ struct ClawdisApp: App {
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
CritterStatusLabel( CritterStatusLabel(
isPaused: self.state.isPaused, isPaused: self.state.isPaused,
isSleeping: self.isGatewaySleeping,
isWorking: self.state.isWorking, isWorking: self.state.isWorking,
earBoostActive: self.state.earBoostActive, earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick, blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick, sendCelebrationTick: self.state.sendCelebrationTick,
gatewayStatus: self.gatewayManager.status, gatewayStatus: self.gatewayManager.status,
animationsEnabled: self.state.iconAnimationsEnabled, animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
iconState: self.effectiveIconState) iconState: self.effectiveIconState)
} }
.menuBarExtraStyle(.menu) .menuBarExtraStyle(.menu)
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
self.statusItem = item self.statusItem = item
self.applyStatusItemAppearance(paused: self.state.isPaused) self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
self.installStatusItemMouseHandler(for: item) self.installStatusItemMouseHandler(for: item)
self.updateHoverHUDSuppression() self.updateHoverHUDSuppression()
} }
.onChange(of: self.state.isPaused) { _, paused in .onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused) self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
if self.state.connectionMode == .local { if self.state.connectionMode == .local {
self.gatewayManager.setActive(!paused) self.gatewayManager.setActive(!paused)
} else { } else {
self.gatewayManager.stop() 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 .onChange(of: self.state.connectionMode) { _, mode in
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
} }
@ -75,8 +83,27 @@ struct ClawdisApp: App {
} }
} }
private func applyStatusItemAppearance(paused: Bool) { private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
self.statusItem?.button?.appearsDisabled = paused 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 @MainActor

View File

@ -18,6 +18,8 @@ struct MenuContent: View {
@State private var loadingMics = false @State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = [] @State private var sessionMenu: [SessionRow] = []
@State private var sessionStorePath: String? @State private var sessionStorePath: String?
@State private var sessionLoading = true
@State private var sessionErrorText: String?
@State private var browserControlEnabled = true @State private var browserControlEnabled = true
private let sessionMenuItemWidth: CGFloat = 320 private let sessionMenuItemWidth: CGFloat = 320
private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60 private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60
@ -31,7 +33,9 @@ struct MenuContent: View {
} }
} }
.disabled(self.state.connectionMode == .unconfigured) .disabled(self.state.connectionMode == .unconfigured)
self.sessionsSection self.sessionsSection
Divider() Divider()
Toggle(isOn: self.heartbeatsBinding) { Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -196,12 +200,39 @@ struct MenuContent: View {
private var sessionsSection: some View { private var sessionsSection: some View {
Group { 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 { if self.sessionMenu.isEmpty, !self.sessionLoading, let error = self.sessionErrorText, !error.isEmpty {
Text("No active sessions") MenuHostedItem(
.font(.caption) width: self.sessionMenuItemWidth,
.foregroundStyle(.secondary) 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) .disabled(true)
} else { } else {
ForEach(self.sessionMenu) { row in 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) { private func open(tab: SettingsTab) {
SettingsTabRouter.request(tab) SettingsTabRouter.request(tab)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
@ -561,28 +631,6 @@ struct MenuContent: View {
return "System default" 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 @MainActor
private func loadMicrophones(force: Bool = false) async { private func loadMicrophones(force: Bool = false) async {
guard self.showVoiceWakeMicPicker else { 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 { struct SessionMenuLabelView: View {
let row: SessionRow let row: SessionRow
let width: CGFloat let width: CGFloat
private let horizontalPadding: CGFloat = 8 private let paddingLeading: CGFloat = 20
private let paddingTrailing: CGFloat = 10
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
ContextUsageBar( ContextUsageBar(
usedTokens: row.tokens.total, usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens, contextTokens: row.tokens.contextTokens,
width: max(1, self.width - (self.horizontalPadding * 2)), width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: 4) height: 4)
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
@ -31,6 +32,7 @@ struct SessionMenuLabelView: View {
} }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.horizontal, self.horizontalPadding) .padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
} }
} }