Skip to content

Commit 39b960e

Browse files
hiskudinclaude
andcommitted
fix(widget): keep mascot idle micro-loops alive at 0.5s; bg poll 10s
Two follow-ups on top of the energy fix: - Mascots: the 60s idle TimelineView fallback froze the per-mascot idle micro-loops added in v1.15.0 (robot head-tilt every ~12s, cat ear-flick every ~10s, eye blink every ~9s, ghost bob). At 60s ticks the only sampling is random aliasing — animations either don't render or snap to arbitrary frames once a minute. Use 0.5s (2fps) for the idle branch instead. Pair with widened blink / ear-flick / squint windows so each animation is reliably sampled at the slower cadence. Still ~25x cheaper than the original 0.05s always-on. - SessionStore background interval: 15s → 10s. The pill's mascot reflects busy/idle for sidecar-driven state changes (Claude liveStatus) on this cadence; 10s keeps perceived latency under "feels live" while preserving most of the energy win (combined with batched discover() it's still ~91% cheaper than the original 3s × 22-fork design). Hook-driven events still arrive instantly via the socket. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c5e098c commit 39b960e

2 files changed

Lines changed: 78 additions & 12 deletions

File tree

panel/CompactView.swift

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,19 @@ private struct RobotMascot: View {
614614
}
615615
}
616616

617+
// Three-tier interval keeps the heavy 10fps cadence for actively
618+
// changing visuals while still leaving headroom for the idle micro-
619+
// loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
620+
// ghost bob) — those run off TimelineView ticks and would freeze
621+
// entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
622+
// idle accents and still ~25× cheaper than the original 0.05s.
623+
// Two-tier interval. The 0.5s idle cadence is paired with widened blink /
624+
// ear-flick / micro-loop windows below so each animation is reliably
625+
// sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
626+
// we added in the mascot-reactions release; going faster wastes cycles
627+
// when nothing visibly changing depends on a higher framerate.
617628
private var timelineInterval: TimeInterval {
618-
(state == .busy || hovered || pulse > 0) ? 0.1 : 60
629+
(state == .busy || hovered || pulse > 0) ? 0.1 : 0.5
619630
}
620631

621632
// Deterministic seed so multi-monitor pills don't sync. Offsets the
@@ -745,7 +756,10 @@ private struct RobotMascot: View {
745756
private func eyes(at t: TimeInterval) -> some View {
746757
// Blink: scale the eyes vertically toward 0 every ~3.2s for ~150ms.
747758
let cycle = t.truncatingRemainder(dividingBy: 3.2)
748-
let blinking = state != .alert && cycle < 0.15
759+
// 0.5s window matches the idle TimelineView cadence so each blink
760+
// is guaranteed to be sampled at least once. The wider window
761+
// reads as a sleepy slow blink, fitting the idle state.
762+
let blinking = state != .alert && cycle < 0.5
749763
let scaleY: CGFloat = blinking ? 0.15 : 1.0
750764

751765
return HStack(spacing: 4.5) {
@@ -885,16 +899,30 @@ private struct CatMascot: View {
885899
}
886900
}
887901

902+
// Three-tier interval keeps the heavy 10fps cadence for actively
903+
// changing visuals while still leaving headroom for the idle micro-
904+
// loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
905+
// ghost bob) — those run off TimelineView ticks and would freeze
906+
// entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
907+
// idle accents and still ~25× cheaper than the original 0.05s.
908+
// Two-tier interval. The 0.5s idle cadence is paired with widened blink /
909+
// ear-flick / micro-loop windows below so each animation is reliably
910+
// sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
911+
// we added in the mascot-reactions release; going faster wastes cycles
912+
// when nothing visibly changing depends on a higher framerate.
888913
private var timelineInterval: TimeInterval {
889-
(state == .busy || hovered || pulse > 0) ? 0.1 : 60
914+
(state == .busy || hovered || pulse > 0) ? 0.1 : 0.5
890915
}
891916

892917
// Tiny vertical bump every ~10s — reads as "ear flick".
893918
private static let idleSeed: Double = 2.7 // offset from robot's phase
894919
private func idleEarFlick(at t: TimeInterval) -> CGFloat {
895920
let phase = (t + Self.idleSeed).truncatingRemainder(dividingBy: 10.0)
896-
guard phase > 9.4 else { return 0 }
897-
return CGFloat(-sin((phase - 9.4) / 0.6 * .pi)) * 0.8
921+
// Active arc widened from 0.6s → 1.5s so a 0.5s idle tick lands at
922+
// least 1–2 samples inside it. Reads as a slower, more deliberate
923+
// ear-flick which suits the resting state.
924+
guard phase > 8.5 else { return 0 }
925+
return CGFloat(-sin((phase - 8.5) / 1.5 * .pi)) * 0.8
898926
}
899927

900928
@ViewBuilder
@@ -995,7 +1023,10 @@ private struct CatMascot: View {
9951023

9961024
private func eyes(at t: TimeInterval) -> some View {
9971025
let cycle = t.truncatingRemainder(dividingBy: 3.5)
998-
let blinking = state != .alert && cycle < 0.15
1026+
// 0.5s window matches the idle TimelineView cadence so each blink
1027+
// is guaranteed to be sampled at least once. The wider window
1028+
// reads as a sleepy slow blink, fitting the idle state.
1029+
let blinking = state != .alert && cycle < 0.5
9991030
let scaleY: CGFloat = blinking ? 0.15 : 1.0
10001031
// While hovered, scale the left eye flat → looks like a wink.
10011032
let leftScale: CGFloat = hovered ? 0.15 : scaleY
@@ -1186,17 +1217,31 @@ private struct EyeMascot: View {
11861217
}
11871218
}
11881219

1220+
// Three-tier interval keeps the heavy 10fps cadence for actively
1221+
// changing visuals while still leaving headroom for the idle micro-
1222+
// loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
1223+
// ghost bob) — those run off TimelineView ticks and would freeze
1224+
// entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
1225+
// idle accents and still ~25× cheaper than the original 0.05s.
1226+
// Two-tier interval. The 0.5s idle cadence is paired with widened blink /
1227+
// ear-flick / micro-loop windows below so each animation is reliably
1228+
// sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
1229+
// we added in the mascot-reactions release; going faster wastes cycles
1230+
// when nothing visibly changing depends on a higher framerate.
11891231
private var timelineInterval: TimeInterval {
1190-
(state == .busy || hovered || pulse > 0) ? 0.1 : 60
1232+
(state == .busy || hovered || pulse > 0) ? 0.1 : 0.5
11911233
}
11921234

11931235
// Squint-and-open every ~9s. Returns vertical scale that dips toward
11941236
// 0.4 briefly so the lens looks like it's closing for a moment.
11951237
private static let idleSeed: Double = 5.1
11961238
private func idleBlinkScale(at t: TimeInterval) -> CGFloat {
11971239
let phase = (t + Self.idleSeed).truncatingRemainder(dividingBy: 9.0)
1198-
guard phase > 8.7 else { return 1 }
1199-
let local = (phase - 8.7) / 0.3 // 0…1 over 300ms
1240+
// Active window widened from 0.3s → 1.0s so a 0.5s idle tick lands
1241+
// at least one sample inside the squint. Reads as a longer, more
1242+
// pronounced lens-narrow when idle.
1243+
guard phase > 8.0 else { return 1 }
1244+
let local = (phase - 8.0) / 1.0
12001245
return CGFloat(1.0 - 0.6 * sin(local * .pi))
12011246
}
12021247

@@ -1380,8 +1425,19 @@ private struct GhostMascot: View {
13801425
}
13811426
}
13821427

1428+
// Three-tier interval keeps the heavy 10fps cadence for actively
1429+
// changing visuals while still leaving headroom for the idle micro-
1430+
// loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
1431+
// ghost bob) — those run off TimelineView ticks and would freeze
1432+
// entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
1433+
// idle accents and still ~25× cheaper than the original 0.05s.
1434+
// Two-tier interval. The 0.5s idle cadence is paired with widened blink /
1435+
// ear-flick / micro-loop windows below so each animation is reliably
1436+
// sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
1437+
// we added in the mascot-reactions release; going faster wastes cycles
1438+
// when nothing visibly changing depends on a higher framerate.
13831439
private var timelineInterval: TimeInterval {
1384-
(state == .busy || hovered || pulse > 0) ? 0.1 : 60
1440+
(state == .busy || hovered || pulse > 0) ? 0.1 : 0.5
13851441
}
13861442

13871443
private var trailingSparkle: some View {
@@ -1457,7 +1513,10 @@ private struct GhostMascot: View {
14571513

14581514
private func eyes(at t: TimeInterval) -> some View {
14591515
let cycle = t.truncatingRemainder(dividingBy: 3.0)
1460-
let blinking = state != .alert && cycle < 0.15
1516+
// 0.5s window matches the idle TimelineView cadence so each blink
1517+
// is guaranteed to be sampled at least once. The wider window
1518+
// reads as a sleepy slow blink, fitting the idle state.
1519+
let blinking = state != .alert && cycle < 0.5
14611520
let scaleY: CGFloat = blinking ? 0.15 : 1.0
14621521
return HStack(spacing: 4) { eyeShape; eyeShape }
14631522
.scaleEffect(x: 1, y: scaleY)

panel/SessionStore.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,14 @@ final class SessionStore: ObservableObject {
6767
private let queue = DispatchQueue(label: "stack-nudge.sessions", qos: .utility)
6868
private static let agentBinaries: Set<String> = ["claude", "gemini", "codex", "agy"]
6969
private static let foregroundPollInterval: TimeInterval = 3.0
70-
private static let backgroundPollInterval: TimeInterval = 15.0
70+
// Pill-only cadence when the Sessions tab isn't open. 10s keeps the
71+
// mascot's busy/idle reflection on hook-less sidecar state changes
72+
// (Claude liveStatus) with a worst-case latency that still feels
73+
// live, while combined with the batched discover() (3 forks/scan
74+
// instead of ~22) it cuts steady-state subprocess load by ~91% vs
75+
// the original 3s, ~22-fork design. Hook events still surface
76+
// immediately via the socket regardless of this interval.
77+
private static let backgroundPollInterval: TimeInterval = 10.0
7178

7279
init(persistence: SessionPersistence = .shared) {
7380
self.persistence = persistence

0 commit comments

Comments
 (0)