diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts new file mode 100644 index 000000000..0698c7265 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CountdownScheduler } from "./countdownScheduler"; + +describe("CountdownScheduler", () => { + let documentTarget: EventTarget & { visibilityState: string }; + let now: number; + + beforeEach(() => { + vi.useFakeTimers(); + now = 1_000; + documentTarget = Object.assign(new EventTarget(), { + visibilityState: "visible", + }); + vi.stubGlobal( + "window", + Object.assign(new EventTarget(), { + clearTimeout: globalThis.clearTimeout, + setTimeout: globalThis.setTimeout, + }) + ); + vi.stubGlobal("document", documentTarget); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("updates at most once per second and stops at expiry", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(3_500, onUpdate, () => now); + scheduler.start(); + + expect(onUpdate).toHaveBeenLastCalledWith(2_500); + now = 2_000; + vi.advanceTimersByTime(1_000); + expect(onUpdate).toHaveBeenLastCalledWith(1_500); + now = 3_500; + vi.advanceTimersByTime(1_000); + expect(onUpdate).toHaveBeenLastCalledWith(0); + + vi.advanceTimersByTime(10_000); + expect(onUpdate).toHaveBeenCalledTimes(3); + scheduler.stop(); + }); + + it("pauses while hidden and recalculates once when visible", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(10_000, onUpdate, () => now); + scheduler.start(); + + documentTarget.visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + now = 7_000; + vi.advanceTimersByTime(10_000); + expect(onUpdate).toHaveBeenCalledTimes(1); + + documentTarget.visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + expect(onUpdate).toHaveBeenLastCalledWith(3_000); + expect(onUpdate).toHaveBeenCalledTimes(2); + scheduler.stop(); + }); + + it("removes timers and listeners on stop", () => { + const onUpdate = vi.fn(); + const scheduler = new CountdownScheduler(10_000, onUpdate, () => now); + scheduler.start(); + scheduler.stop(); + + now = 5_000; + vi.advanceTimersByTime(10_000); + document.dispatchEvent(new Event("visibilitychange")); + expect(onUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts new file mode 100644 index 000000000..0ad8b94d4 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/countdownScheduler.ts @@ -0,0 +1,68 @@ +export function getCountdownRemaining( + expiresAt: number, + now: () => number = Date.now +): number { + return Math.max(0, expiresAt - now()); +} + +/** + * Owns the proposal countdown's single timer and visibility listener. + * + * The UI label only changes once per second, so frame-rate updates would + * needlessly re-render the full proposal creator. Hidden windows keep no timer; + * returning to the foreground recalculates from the absolute expiry time. + */ +export class CountdownScheduler { + private timeoutId: number | undefined; + private running = false; + + constructor( + private readonly expiresAt: number, + private readonly onUpdate: (remaining: number) => void, + private readonly now: () => number = Date.now + ) {} + + start(): void { + this.stop(); + this.running = true; + document.addEventListener("visibilitychange", this.handleVisibilityChange); + this.updateAndSchedule(); + } + + stop(): void { + this.running = false; + this.clearScheduledUpdate(); + document.removeEventListener( + "visibilitychange", + this.handleVisibilityChange + ); + } + + private clearScheduledUpdate(): void { + if (this.timeoutId === undefined) return; + window.clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + private updateAndSchedule = (): void => { + this.clearScheduledUpdate(); + if (!this.running) return; + + const remaining = getCountdownRemaining(this.expiresAt, this.now); + this.onUpdate(remaining); + if (remaining > 0 && document.visibilityState !== "hidden") { + this.timeoutId = window.setTimeout( + this.updateAndSchedule, + Math.min(1000, remaining) + ); + } + }; + + private handleVisibilityChange = (): void => { + if (document.visibilityState === "hidden") { + this.clearScheduledUpdate(); + return; + } + this.updateAndSchedule(); + }; +} diff --git a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx index fd08baed3..bbd07532f 100644 --- a/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/AgentControlPalette/index.tsx @@ -1,6 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { DraftingCompass } from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { sendAdeActionResult } from "@src/api/tauri/agent"; import { DISPATCH_CATEGORY } from "@src/api/tauri/session"; @@ -14,6 +14,10 @@ import { PaletteBody, SpotlightShell } from "../../shell"; import { AgentControlInputTrailing } from "./AgentControlInputTrailing"; import { AgentControlStatus } from "./AgentControlStatus"; import { AgentControlToolbar } from "./AgentControlToolbar"; +import { + CountdownScheduler, + getCountdownRemaining, +} from "./countdownScheduler"; import { useAgentControlPalette } from "./useAgentControlPalette"; export type { AdeManagerSubmitDetail } from "./types"; @@ -28,20 +32,15 @@ const TOTAL_MS = 5 * 60 * 1000; function useCountdown(expiresAt: number) { const [remaining, setRemaining] = useState(() => - Math.max(0, expiresAt - Date.now()) + getCountdownRemaining(expiresAt) ); - const rafRef = useRef(null); + useEffect(() => { - const tick = () => { - const left = Math.max(0, expiresAt - Date.now()); - setRemaining(left); - if (left > 0) rafRef.current = requestAnimationFrame(tick); - }; - rafRef.current = requestAnimationFrame(tick); - return () => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - }; + const scheduler = new CountdownScheduler(expiresAt, setRemaining); + scheduler.start(); + return () => scheduler.stop(); }, [expiresAt]); + const seconds = Math.ceil(remaining / 1000); const pct = remaining / TOTAL_MS; const mins = Math.floor(seconds / 60);