fix(mac): sessions error UI + sleeping icon
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
apps/macos/Sources/Clawdis/MenuSessionsHeaderView.swift
Normal file
44
apps/macos/Sources/Clawdis/MenuSessionsHeaderView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user