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>
This commit is contained in:
Neil
2026-06-27 23:32:02 -07:00
co-authored by gatsby74
parent b45cb545c5
commit 2d61fd5f6a
4 changed files with 119 additions and 28 deletions
@@ -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 && (
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
<WindowLabel w={p.session} label={sessionLabel} />
)}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
@@ -1113,9 +1134,7 @@ function ProviderSegment({
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{p.session && !compact && <MiniBar leftPct={Math.max(0, 100 - p.session.usedPercent)} />}
{p.session && (
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
)}
{p.session && <WindowLabel w={p.session} label={sessionLabel} />}
{p.session && p.weekly && <span className="text-muted-foreground">·</span>}
{p.weekly && <WindowLabel w={p.weekly} label={formatWindowLabel(p.weekly.windowMinutes)} />}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
@@ -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')
@@ -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')
})
})
+34
View File
@@ -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}`
}