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:
Peter Steinberger
2026-07-16 08:52:43 -07:00
committed by GitHub
parent 0fa77124e9
commit 65bdd1bf08
3 changed files with 172 additions and 0 deletions

View File

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

View File

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

View File

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