Reduce sidebar cache timer churn (#2080)
This commit is contained in:
parent
9409c43bec
commit
b4a5c142a7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue