mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 15:01:13 +00:00
feat(mac): menu-bar critter celebrates gateway recovery and finished work (#108866)
Two one-shot micro-beats for the 18pt menu-bar critter: a happy-eyes ear-wiggle when the Gateway comes back from a failure (judged against the last settled status, so failed -> starting -> running still reads as a comeback while cold starts and deliberate stop/start cycles stay quiet), and a brief celebration when sustained work (>= 10s) finishes. Short blips stay silent so the menu bar never gets noisy.
This commit is contained in:
committed by
GitHub
parent
0fa77124e9
commit
65bdd1bf08
@@ -43,6 +43,12 @@ extension CritterStatusLabel {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
self.celebrate()
|
||||
}
|
||||
.onChange(of: self.gatewayStatus) { oldStatus, newStatus in
|
||||
self.handleGatewayStatusChange(from: oldStatus, to: newStatus)
|
||||
}
|
||||
.onChange(of: self.isWorkingNow) { wasWorking, isWorking in
|
||||
self.handleWorkingChange(from: wasWorking, to: isWorking, at: Date())
|
||||
}
|
||||
.onChange(of: self.animationsEnabled) { _, enabled in
|
||||
if enabled, !self.isSleeping {
|
||||
self.scheduleRandomTimers(from: Date())
|
||||
@@ -104,6 +110,76 @@ extension CritterStatusLabel {
|
||||
return max(0.05, nextDeadline.timeIntervalSince(now))
|
||||
}
|
||||
|
||||
static func reconnectBeat(
|
||||
lastSettled: GatewayProcessManager.Status?,
|
||||
to new: GatewayProcessManager.Status) -> Bool
|
||||
{
|
||||
// Only a comeback from .failed celebrates: cold starts settle from
|
||||
// nil/.stopped and deliberate stop -> start cycles stay quiet.
|
||||
guard case .failed = lastSettled else { return false }
|
||||
switch new {
|
||||
case .running, .attachedExisting:
|
||||
return true
|
||||
case .failed, .stopped, .starting:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func workCompletionBeat(
|
||||
startedAt: Date?,
|
||||
endedAt: Date,
|
||||
minimumDuration: TimeInterval) -> Bool
|
||||
{
|
||||
guard let startedAt else { return false }
|
||||
return endedAt.timeIntervalSince(startedAt) >= minimumDuration
|
||||
}
|
||||
|
||||
private func handleGatewayStatusChange(
|
||||
from oldStatus: GatewayProcessManager.Status,
|
||||
to newStatus: GatewayProcessManager.Status)
|
||||
{
|
||||
// Seed from the pre-change status on the first observed transition so
|
||||
// a crash that predates this view's appearance still counts as broken.
|
||||
let lastSettled = self.lastSettledGatewayStatus
|
||||
?? (Self.isSettled(oldStatus) ? oldStatus : nil)
|
||||
let beat = Self.reconnectBeat(lastSettled: lastSettled, to: newStatus)
|
||||
if Self.isSettled(newStatus) {
|
||||
self.lastSettledGatewayStatus = newStatus
|
||||
} else if self.lastSettledGatewayStatus == nil {
|
||||
self.lastSettledGatewayStatus = lastSettled
|
||||
}
|
||||
guard beat, self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
|
||||
|
||||
self.celebrate()
|
||||
self.wiggleEars()
|
||||
}
|
||||
|
||||
/// `.starting` is transitional; every other status is a settled state the
|
||||
/// next recovery judgment can compare against.
|
||||
static func isSettled(_ status: GatewayProcessManager.Status) -> Bool {
|
||||
if case .starting = status { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleWorkingChange(from wasWorking: Bool, to isWorking: Bool, at date: Date) {
|
||||
if isWorking {
|
||||
// Hand the false -> true timestamp to the eventual completion transition.
|
||||
if !wasWorking { self.workStartedAt = date }
|
||||
return
|
||||
}
|
||||
|
||||
guard wasWorking else { return }
|
||||
let startedAt = self.workStartedAt
|
||||
self.workStartedAt = nil
|
||||
// Require sustained work so short menu-bar blips do not create celebration noise.
|
||||
guard self.effectiveAnimationsEnabled,
|
||||
!self.earBoostActive,
|
||||
Self.workCompletionBeat(startedAt: startedAt, endedAt: date, minimumDuration: 10)
|
||||
else { return }
|
||||
|
||||
self.celebrate()
|
||||
}
|
||||
|
||||
private func tick(_ now: Date) {
|
||||
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
|
||||
self.resetMotion()
|
||||
@@ -306,6 +382,7 @@ extension CritterStatusLabel {
|
||||
_ = label.body
|
||||
_ = label.iconImage
|
||||
_ = label.tickTaskID
|
||||
_ = label.isWorkingNow
|
||||
label.tick(Date())
|
||||
label.resetMotion()
|
||||
label.blink()
|
||||
@@ -315,6 +392,13 @@ extension CritterStatusLabel {
|
||||
label.scurry()
|
||||
label.celebrate()
|
||||
label.scheduleRandomTimers(from: Date())
|
||||
label.handleGatewayStatusChange(from: .failed("boom"), to: .running(details: nil))
|
||||
let workStartedAt = Date(timeIntervalSinceReferenceDate: 100)
|
||||
label.handleWorkingChange(from: false, to: true, at: workStartedAt)
|
||||
label.handleWorkingChange(from: true, to: false, at: workStartedAt.addingTimeInterval(10))
|
||||
_ = Self.reconnectBeat(lastSettled: .failed("boom"), to: .running(details: nil))
|
||||
_ = Self.isSettled(.starting)
|
||||
_ = Self.workCompletionBeat(startedAt: nil, endedAt: Date(), minimumDuration: 10)
|
||||
_ = label.gatewayNeedsAttention
|
||||
_ = label.gatewayBadgeColor
|
||||
|
||||
|
||||
@@ -23,4 +23,8 @@ struct CritterStatusLabel: View {
|
||||
@State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
|
||||
@State var earWiggle: CGFloat = 0
|
||||
@State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||
@State var workStartedAt: Date?
|
||||
/// Last non-`.starting` gateway status; recovery beats are judged against
|
||||
/// this so failed -> starting -> running still reads as a comeback.
|
||||
@State var lastSettledGatewayStatus: GatewayProcessManager.Status?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
struct CritterStatusBeatsTests {
|
||||
@Test func `failed gateway recovery triggers reconnect beat`() {
|
||||
#expect(CritterStatusLabel.reconnectBeat(
|
||||
lastSettled: .failed("boom"),
|
||||
to: .running(details: nil)))
|
||||
}
|
||||
|
||||
@Test func `recovery through starting still reads as a comeback`() {
|
||||
// failed -> starting -> running: .starting never settles, so the
|
||||
// running transition is still judged against the failure.
|
||||
#expect(!CritterStatusLabel.isSettled(.starting))
|
||||
#expect(CritterStatusLabel.reconnectBeat(
|
||||
lastSettled: .failed("boom"),
|
||||
to: .attachedExisting(details: nil)))
|
||||
}
|
||||
|
||||
@Test func `cold start stays quiet`() {
|
||||
#expect(!CritterStatusLabel.reconnectBeat(lastSettled: nil, to: .running(details: nil)))
|
||||
}
|
||||
|
||||
@Test func `deliberate stop then start stays quiet`() {
|
||||
#expect(!CritterStatusLabel.reconnectBeat(lastSettled: .stopped, to: .running(details: nil)))
|
||||
}
|
||||
|
||||
@Test func `already running gateway stays quiet`() {
|
||||
#expect(!CritterStatusLabel.reconnectBeat(
|
||||
lastSettled: .running(details: nil),
|
||||
to: .running(details: nil)))
|
||||
}
|
||||
|
||||
@Test func `failure after failure stays quiet`() {
|
||||
#expect(!CritterStatusLabel.reconnectBeat(lastSettled: .failed("boom"), to: .failed("again")))
|
||||
#expect(!CritterStatusLabel.reconnectBeat(lastSettled: .failed("boom"), to: .stopped))
|
||||
#expect(!CritterStatusLabel.reconnectBeat(lastSettled: .failed("boom"), to: .starting))
|
||||
}
|
||||
|
||||
@Test func `every non-starting status settles`() {
|
||||
#expect(CritterStatusLabel.isSettled(.stopped))
|
||||
#expect(CritterStatusLabel.isSettled(.running(details: nil)))
|
||||
#expect(CritterStatusLabel.isSettled(.attachedExisting(details: "pid 1")))
|
||||
#expect(CritterStatusLabel.isSettled(.failed("boom")))
|
||||
}
|
||||
|
||||
@Test func `missing work start stays quiet`() {
|
||||
let endedAt = Date(timeIntervalSinceReferenceDate: 100)
|
||||
|
||||
#expect(!CritterStatusLabel.workCompletionBeat(
|
||||
startedAt: nil,
|
||||
endedAt: endedAt,
|
||||
minimumDuration: 10))
|
||||
}
|
||||
|
||||
@Test func `short work stays quiet`() {
|
||||
let startedAt = Date(timeIntervalSinceReferenceDate: 100)
|
||||
|
||||
#expect(!CritterStatusLabel.workCompletionBeat(
|
||||
startedAt: startedAt,
|
||||
endedAt: startedAt.addingTimeInterval(5),
|
||||
minimumDuration: 10))
|
||||
}
|
||||
|
||||
@Test func `long work triggers completion beat`() {
|
||||
let startedAt = Date(timeIntervalSinceReferenceDate: 100)
|
||||
|
||||
#expect(CritterStatusLabel.workCompletionBeat(
|
||||
startedAt: startedAt,
|
||||
endedAt: startedAt.addingTimeInterval(15),
|
||||
minimumDuration: 10))
|
||||
}
|
||||
|
||||
@Test func `minimum work duration triggers completion beat`() {
|
||||
let startedAt = Date(timeIntervalSinceReferenceDate: 100)
|
||||
|
||||
#expect(CritterStatusLabel.workCompletionBeat(
|
||||
startedAt: startedAt,
|
||||
endedAt: startedAt.addingTimeInterval(10),
|
||||
minimumDuration: 10))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user