mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user