Reduce sidebar cache timer churn (#2080)

This commit is contained in:
Neil 2026-05-16 10:56:01 -07:00 committed by GitHub
parent 9409c43bec
commit b4a5c142a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 151 additions and 40 deletions

View File

@ -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<number | null>(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

View File

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

View File

@ -0,0 +1,53 @@
import { useSyncExternalStore } from 'react'
type Listener = () => void
let currentNow = Date.now()
let timer: ReturnType<typeof setInterval> | null = null
const listeners = new Set<Listener>()
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
)
}

View File

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

View File

@ -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<TerminalTab, 'id'>[] | undefined,
cacheTimerByKey: Record<string, number | null>
): 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
}