diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift index 08c128990431..1ac8cbc1768e 100644 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift +++ b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift index 2742e32dc734..0e6d55000ab6 100644 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift +++ b/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift @@ -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? } diff --git a/apps/macos/Tests/OpenClawIPCTests/CritterStatusBeatsTests.swift b/apps/macos/Tests/OpenClawIPCTests/CritterStatusBeatsTests.swift new file mode 100644 index 000000000000..e18b773aacdc --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/CritterStatusBeatsTests.swift @@ -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)) + } +}