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}`
+}