From b4a5c142a72344a263756da71038dc2b21f754c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 16 May 2026 10:56:01 -0700 Subject: [PATCH] Reduce sidebar cache timer churn (#2080) --- .../src/components/sidebar/CacheTimer.tsx | 46 +++------------- .../prompt-cache-countdown-clock.test.ts | 37 +++++++++++++ .../sidebar/prompt-cache-countdown-clock.ts | 53 +++++++++++++++++++ .../prompt-cache-timer-selection.test.ts | 25 +++++++++ .../sidebar/prompt-cache-timer-selection.ts | 30 +++++++++++ 5 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts create mode 100644 src/renderer/src/components/sidebar/prompt-cache-countdown-clock.ts create mode 100644 src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts create mode 100644 src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts diff --git a/src/renderer/src/components/sidebar/CacheTimer.tsx b/src/renderer/src/components/sidebar/CacheTimer.tsx index 2f4ee986e..b1e11876d 100644 --- a/src/renderer/src/components/sidebar/CacheTimer.tsx +++ b/src/renderer/src/components/sidebar/CacheTimer.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { Timer } from 'lucide-react' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' +import { usePromptCacheCountdownNow } from './prompt-cache-countdown-clock' +import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection' /** * Per-worktree prompt-cache countdown, shown in the sidebar worktree card. @@ -24,48 +25,13 @@ export default function CacheTimer({ const enabled = useAppStore((s) => s.settings?.promptCacheTimerEnabled ?? false) const ttlMs = useAppStore((s) => s.settings?.promptCacheTtlMs ?? 0) - // Find the most urgent (minimum remaining) cache timer across all panes in this worktree. const mostUrgentStartedAt = useAppStore((s) => { - const tabs = s.tabsByWorktree[worktreeId] - if (!tabs) { - return null - } - let oldest: number | null = null - for (const tab of tabs) { - // Why: cache timer keys are `${tabId}:${leafId}` composites, so we check - // all keys that belong to this tab's panes. - for (const key of Object.keys(s.cacheTimerByKey)) { - if (!key.startsWith(`${tab.id}:`)) { - continue - } - const ts = s.cacheTimerByKey[key] - if (ts != null && (oldest === null || ts < oldest)) { - // Why: smaller startedAt = started earlier = more elapsed time = less remaining = more urgent. - oldest = ts - } - } - } - return oldest + return getMostUrgentPromptCacheStartedAt(s.tabsByWorktree[worktreeId], s.cacheTimerByKey) }) - const [remainingMs, setRemainingMs] = useState(null) - - useEffect(() => { - if (!enabled || !mostUrgentStartedAt || ttlMs <= 0) { - setRemainingMs(null) - return - } - - const tick = (): void => { - const elapsed = Date.now() - mostUrgentStartedAt - const remaining = Math.max(0, ttlMs - elapsed) - setRemainingMs(remaining) - } - - tick() - const interval = setInterval(tick, 1000) - return () => clearInterval(interval) - }, [enabled, mostUrgentStartedAt, ttlMs]) + const countdownActive = enabled && mostUrgentStartedAt != null && ttlMs > 0 + const now = usePromptCacheCountdownNow(countdownActive) + const remainingMs = countdownActive ? Math.max(0, ttlMs - (now - mostUrgentStartedAt)) : null if (remainingMs === null) { return null diff --git a/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts b/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts new file mode 100644 index 000000000..7d4594af7 --- /dev/null +++ b/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { subscribePromptCacheCountdownClock } from './prompt-cache-countdown-clock' + +describe('subscribePromptCacheCountdownClock', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('uses one interval for all prompt-cache countdown subscribers', () => { + const first = vi.fn() + const second = vi.fn() + const unsubscribeFirst = subscribePromptCacheCountdownClock(first) + const unsubscribeSecond = subscribePromptCacheCountdownClock(second) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1_000) + + expect(first).toHaveBeenCalledTimes(2) + expect(second).toHaveBeenCalledTimes(2) + unsubscribeFirst() + vi.advanceTimersByTime(1_000) + + expect(first).toHaveBeenCalledTimes(2) + expect(second).toHaveBeenCalledTimes(3) + unsubscribeSecond() + vi.advanceTimersByTime(1_000) + + expect(second).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.ts b/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.ts new file mode 100644 index 000000000..9bc978b4e --- /dev/null +++ b/src/renderer/src/components/sidebar/prompt-cache-countdown-clock.ts @@ -0,0 +1,53 @@ +import { useSyncExternalStore } from 'react' + +type Listener = () => void + +let currentNow = Date.now() +let timer: ReturnType | null = null +const listeners = new Set() + +function publishTick(): void { + currentNow = Date.now() + for (const listener of listeners) { + listener() + } +} + +export function subscribePromptCacheCountdownClock(listener: Listener): () => void { + listeners.add(listener) + // Why: the clock only runs while a countdown is visible. Refresh immediately + // on subscribe so a card mounted after a long idle period does not render a + // stale module-load timestamp for one second. + currentNow = Date.now() + listener() + if (timer === null) { + timer = setInterval(publishTick, 1000) + } + return () => { + listeners.delete(listener) + if (listeners.size === 0 && timer !== null) { + clearInterval(timer) + timer = null + } + } +} + +function getPromptCacheCountdownNow(): number { + return currentNow +} + +function subscribeInactivePromptCacheCountdown(): () => void { + return () => {} +} + +function getInactivePromptCacheCountdownNow(): number { + return 0 +} + +export function usePromptCacheCountdownNow(active: boolean): number { + return useSyncExternalStore( + active ? subscribePromptCacheCountdownClock : subscribeInactivePromptCacheCountdown, + active ? getPromptCacheCountdownNow : getInactivePromptCacheCountdownNow, + active ? getPromptCacheCountdownNow : getInactivePromptCacheCountdownNow + ) +} diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts new file mode 100644 index 000000000..a5aba378a --- /dev/null +++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { getMostUrgentPromptCacheStartedAt } from './prompt-cache-timer-selection' + +describe('getMostUrgentPromptCacheStartedAt', () => { + it('selects the oldest non-null timer for the worktree tabs in one cache pass', () => { + const startedAt = getMostUrgentPromptCacheStartedAt([{ id: 'tab-1' }, { id: 'tab-2' }], { + 'tab-1:pane-a': 300, + 'tab-1:pane-b': null, + 'tab-2:seed': 200, + 'tab-3:pane-a': 100 + }) + + expect(startedAt).toBe(200) + }) + + it('does not match tab id prefixes or malformed keys', () => { + const startedAt = getMostUrgentPromptCacheStartedAt([{ id: 'tab-1' }], { + 'tab-10:pane-a': 100, + 'tab-1': 50, + 'tab-1:pane-a': 300 + }) + + expect(startedAt).toBe(300) + }) +}) diff --git a/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts new file mode 100644 index 000000000..87c63da1a --- /dev/null +++ b/src/renderer/src/components/sidebar/prompt-cache-timer-selection.ts @@ -0,0 +1,30 @@ +import type { TerminalTab } from '../../../../shared/types' + +function getCacheTimerTabId(key: string): string | null { + const separator = key.indexOf(':') + return separator > 0 ? key.slice(0, separator) : null +} + +export function getMostUrgentPromptCacheStartedAt( + tabs: readonly Pick[] | undefined, + cacheTimerByKey: Record +): number | null { + if (!tabs || tabs.length === 0) { + return null + } + const tabIds = new Set(tabs.map((tab) => tab.id)) + let oldest: number | null = null + for (const [key, startedAt] of Object.entries(cacheTimerByKey)) { + if (startedAt == null) { + continue + } + const tabId = getCacheTimerTabId(key) + if (!tabId || !tabIds.has(tabId)) { + continue + } + if (oldest === null || startedAt < oldest) { + oldest = startedAt + } + } + return oldest +}