From 2d61fd5f6aacac175b88eab6bc42a4ccc95c45dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:32:02 -0700 Subject: [PATCH] fix(status-bar): show live reset countdown instead of fixed window length The collapsed status-bar session badge rendered the fixed window length (formatWindowLabel -> "5h") instead of the time-until-reset that the expanded usage panel already showed, so it was frozen at "5h" while the panel ticked down to the real countdown. Extract the shared duration/countdown formatting into src/renderer/src/lib/reset-countdown.ts (re-exported from tooltip.tsx so existing imports keep working) and have ProviderSegment render the live time-until-reset from session.resetsAt, ticking every 30s, with a fallback to the window-length label when resetsAt is unknown. Fixes #5399 Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com> --- .../src/components/status-bar/StatusBar.tsx | 27 +++++++-- .../src/components/status-bar/tooltip.tsx | 27 +-------- src/renderer/src/lib/reset-countdown.test.ts | 59 +++++++++++++++++++ src/renderer/src/lib/reset-countdown.ts | 34 +++++++++++ 4 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/lib/reset-countdown.test.ts create mode 100644 src/renderer/src/lib/reset-countdown.ts diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 4b15511ec7a..ebd9c3fd00e 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -55,6 +55,7 @@ import { } from './tooltip' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' +import { formatResetDuration } from '@/lib/reset-countdown' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' @@ -1024,6 +1025,17 @@ function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React ) } +// Why: rate-limit data only refetches every ~15min, so a reset countdown +// computed once would freeze; tick locally to keep the collapsed badge current. +function useNow(intervalMs: number): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), intervalMs) + return () => window.clearInterval(id) + }, [intervalMs]) + return now +} + // --------------------------------------------------------------------------- // Provider segment // --------------------------------------------------------------------------- @@ -1041,6 +1053,15 @@ function ProviderSegment({ }): React.JSX.Element { const provider = p?.provider ?? 'claude' const statusLabel = p ? getProviderUsageStatusLabel(p) : '' + const now = useNow(30_000) + + // Why: show the live time-until-reset for the session window instead of the + // fixed window length (issue #5399) so users see it without opening the panel. + const sessionLabel = p?.session + ? p.session.resetsAt != null + ? formatResetDuration(p.session.resetsAt - now) + : formatWindowLabel(p.session.windowMinutes) + : '' // Idle / initial load if (!p || p.status === 'idle') { @@ -1102,7 +1123,7 @@ function ProviderSegment({ ) })} {visibleBuckets.length === 0 && p.session && ( - + )} {isStale && } @@ -1113,9 +1134,7 @@ function ProviderSegment({ {p.session && !compact && } - {p.session && ( - - )} + {p.session && } {p.session && p.weekly && ·} {p.weekly && } {isStale && } diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 44b3e0d432a..b22a89a8d92 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -2,12 +2,14 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rat import { AgentIcon } from '@/lib/agent-catalog' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' +import { formatResetCountdown, formatResetDuration } from '@/lib/reset-countdown' import { getProviderDisplayName, getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' +export { formatResetCountdown } from '@/lib/reset-countdown' export { getProviderDisplayName, getProviderUsageErrorMessage, @@ -31,29 +33,6 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } -function formatDuration(ms: number): string { - if (ms <= 0) { - return 'now' - } - const totalMins = Math.floor(ms / 60_000) - if (totalMins < 60) { - return `${totalMins}m` - } - const hours = Math.floor(totalMins / 60) - const mins = totalMins % 60 - if (hours >= 24) { - const days = Math.floor(hours / 24) - const remHours = hours % 24 - return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` - } - return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` -} - -export function formatResetCountdown(ms: number): string { - const duration = formatDuration(ms) - return duration === 'now' ? 'Resets now' : `Resets in ${duration}` -} - export function formatResetCreditExpiry( expiresAt: number | null | undefined, count: number @@ -61,7 +40,7 @@ export function formatResetCreditExpiry( if (!expiresAt) { return null } - const duration = formatDuration(expiresAt - Date.now()) + const duration = formatResetDuration(expiresAt - Date.now()) if (duration === 'now') { return count > 1 ? translate('auto.components.status.bar.tooltip.7ec6e030a0', 'Next expires now') diff --git a/src/renderer/src/lib/reset-countdown.test.ts b/src/renderer/src/lib/reset-countdown.test.ts new file mode 100644 index 00000000000..61fab55a87f --- /dev/null +++ b/src/renderer/src/lib/reset-countdown.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { formatResetCountdown, formatResetDuration } from './reset-countdown' + +const HOUR = 60 * 60_000 +const MINUTE = 60_000 + +describe('formatResetDuration', () => { + it('returns "now" for zero or negative durations', () => { + expect(formatResetDuration(0)).toBe('now') + expect(formatResetDuration(-1)).toBe('now') + }) + + it('returns "<1m" for sub-minute positive durations, not "0m"', () => { + // Why: the 30s tick makes the final minute before reset reachable; "0m" + // would read as already-reset. + expect(formatResetDuration(1)).toBe('<1m') + expect(formatResetDuration(30_000)).toBe('<1m') + expect(formatResetDuration(59_999)).toBe('<1m') + }) + + it('returns whole minutes under an hour', () => { + expect(formatResetDuration(45 * MINUTE)).toBe('45m') + expect(formatResetDuration(MINUTE)).toBe('1m') + }) + + it('floors partial minutes', () => { + expect(formatResetDuration(90_000)).toBe('1m') + }) + + it('shows hours and minutes — the issue #5399 session case', () => { + // Session window with 3h 31m left should read "3h 31m", not a fixed "5h". + expect(formatResetDuration(3 * HOUR + 31 * MINUTE)).toBe('3h 31m') + expect(formatResetDuration(HOUR + 20 * MINUTE)).toBe('1h 20m') + }) + + it('omits minutes on a whole hour', () => { + expect(formatResetDuration(5 * HOUR)).toBe('5h') + }) + + it('shows days and hours past 24h (weekly windows)', () => { + expect(formatResetDuration(2 * 24 * HOUR + 4 * HOUR)).toBe('2d 4h') + expect(formatResetDuration(3 * 24 * HOUR)).toBe('3d') + }) +}) + +describe('formatResetCountdown', () => { + it('prefixes "Resets in" for positive durations', () => { + expect(formatResetCountdown(3 * HOUR + 31 * MINUTE)).toBe('Resets in 3h 31m') + }) + + it('reads "Resets in <1m" in the final minute', () => { + expect(formatResetCountdown(30_000)).toBe('Resets in <1m') + }) + + it('reads "Resets now" once elapsed', () => { + expect(formatResetCountdown(0)).toBe('Resets now') + expect(formatResetCountdown(-1)).toBe('Resets now') + }) +}) diff --git a/src/renderer/src/lib/reset-countdown.ts b/src/renderer/src/lib/reset-countdown.ts new file mode 100644 index 00000000000..86784a90018 --- /dev/null +++ b/src/renderer/src/lib/reset-countdown.ts @@ -0,0 +1,34 @@ +/** + * Formats the time remaining until a usage window resets. + * + * Shared by the expanded usage popover and the collapsed status-bar badge so + * both render the same live countdown (e.g. "3h 31m", "45m", "2d 4h") instead + * of a fixed window-length label. + */ +export function formatResetDuration(ms: number): string { + if (ms <= 0) { + return 'now' + } + const totalMins = Math.floor(ms / 60_000) + // Why: sub-minute durations floor to 0, and "0m" reads as already-reset; show + // "<1m" so the final minute before reset stays distinct from "now" (ms <= 0). + if (totalMins === 0) { + return '<1m' + } + if (totalMins < 60) { + return `${totalMins}m` + } + const hours = Math.floor(totalMins / 60) + const mins = totalMins % 60 + if (hours >= 24) { + const days = Math.floor(hours / 24) + const remHours = hours % 24 + return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` + } + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` +} + +export function formatResetCountdown(ms: number): string { + const duration = formatResetDuration(ms) + return duration === 'now' ? 'Resets now' : `Resets in ${duration}` +}