feat(mobile): show usage reset countdown on accounts screen (#7954)
* feat(mobile): show usage reset countdown on accounts screen
Surface the rate-limit reset time ("5h resets in 3h 54m · 7d resets in
6d 7h") under the usage bars on the mobile accounts screen, matching the
desktop status-bar tooltip copy. The resetsAt timestamps already arrive
in the accounts.subscribe snapshot; this only adds the presentation.
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* docs(mobile): JSDoc for new usage reset selectors
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* refactor(mobile): per-bar reset countdown instead of combined line
Drop the redundant "5h/7d" prefixes — each countdown now renders under
its own bar ("Resets in 3h 54m"), matching the desktop tooltip copy
exactly.
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* Extract shared reset-countdown formatter for desktop and mobile
- Move duration/countdown formatting out of tooltip.tsx into
src/shared/rate-limit-reset-format.ts so mobile's account-usage-state
can reuse it instead of a duplicated copy (with tests).
- Re-export formatResetCountdown from tooltip.tsx to avoid touching
existing import paths.
- Resend the pairing deep link once more in start-emulator.mjs since
the first can arrive before the Expo app's JS router is ready.
---------
Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
1c6098c214
commit
c408a3d852
|
|
@ -23,6 +23,7 @@ import {
|
|||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
getWindowResetLabel,
|
||||
hasActiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
|
|
@ -40,6 +41,14 @@ export default function AccountsScreen() {
|
|||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
|
||||
// Why: the reset countdown must stay fresh while the screen sits open —
|
||||
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
return
|
||||
|
|
@ -163,12 +172,14 @@ export default function AccountsScreen() {
|
|||
usedPercent={activeSessionBar.usedPercent}
|
||||
unavailable={activeSessionBar.unavailable}
|
||||
loading={activeSessionBar.loading}
|
||||
resetText={getWindowResetLabel(activeUsage, 'session', now)}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={activeWeeklyBar.usedPercent}
|
||||
unavailable={activeWeeklyBar.unavailable}
|
||||
loading={activeWeeklyBar.loading}
|
||||
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
|
@ -211,12 +222,14 @@ export default function AccountsScreen() {
|
|||
usedPercent={sessionBar.usedPercent}
|
||||
unavailable={sessionBar.unavailable}
|
||||
loading={sessionBar.loading}
|
||||
resetText={getWindowResetLabel(usage, 'session', now)}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weeklyBar.usedPercent}
|
||||
unavailable={weeklyBar.unavailable}
|
||||
loading={weeklyBar.loading}
|
||||
resetText={getWindowResetLabel(usage, 'weekly', now)}
|
||||
/>
|
||||
</View>
|
||||
{usage?.error ? (
|
||||
|
|
|
|||
|
|
@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr
|
|||
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// Why: the first deep link can arrive while the freshly opened Expo app is
|
||||
// still mounting, so resend it once the JS router is ready to receive URLs.
|
||||
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// Why: the mobile app intentionally asks for a trust confirmation before
|
||||
// saving a host. This lands on the Pair button on current iPhone simulators.
|
||||
await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export {
|
|||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
getWindowResetLabel,
|
||||
hasActiveProviderUsage,
|
||||
hasRenderableUsage
|
||||
} from './account-usage-state'
|
||||
|
|
@ -28,12 +29,14 @@ export function UsageBar({
|
|||
label,
|
||||
usedPercent,
|
||||
unavailable,
|
||||
loading
|
||||
loading,
|
||||
resetText
|
||||
}: {
|
||||
label: string
|
||||
usedPercent: number | null
|
||||
unavailable: boolean
|
||||
loading?: boolean
|
||||
resetText?: string | null
|
||||
}) {
|
||||
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
|
||||
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
|
||||
|
|
@ -47,34 +50,48 @@ export function UsageBar({
|
|||
? colors.statusAmber
|
||||
: colors.statusGreen
|
||||
return (
|
||||
<View style={styles.usageBar}>
|
||||
<Text style={styles.usageLabel}>{label}</Text>
|
||||
<View style={styles.usageTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.usageFill,
|
||||
{
|
||||
width: `${used ?? 0}%`,
|
||||
backgroundColor: unavailable ? colors.textMuted : barColor
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<View style={styles.usageBarColumn}>
|
||||
<View style={styles.usageBar}>
|
||||
<Text style={styles.usageLabel}>{label}</Text>
|
||||
<View style={styles.usageTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.usageFill,
|
||||
{
|
||||
width: `${used ?? 0}%`,
|
||||
backgroundColor: unavailable ? colors.textMuted : barColor
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.textSecondary}
|
||||
style={styles.usageSpinner}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
|
||||
) : (
|
||||
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
|
||||
)}
|
||||
{resetText ? (
|
||||
<Text style={styles.usageResetText} numberOfLines={1}>
|
||||
{resetText}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
usageBarColumn: {
|
||||
flex: 1,
|
||||
gap: 2
|
||||
},
|
||||
usageBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
flex: 1
|
||||
gap: spacing.xs
|
||||
},
|
||||
usageLabel: {
|
||||
fontSize: typography.metaSize,
|
||||
|
|
@ -100,5 +117,12 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
usageSpinner: {
|
||||
width: 36
|
||||
},
|
||||
// Why: indented past the window label so the countdown aligns with the
|
||||
// start of the track above it.
|
||||
usageResetText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginLeft: 22 + spacing.xs
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
|||
import {
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
getWindowResetLabel,
|
||||
hasActiveProviderUsage,
|
||||
hasRenderableUsage,
|
||||
type AccountsSnapshot,
|
||||
|
|
@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('getWindowResetLabel', () => {
|
||||
const now = 1_700_000_000_000
|
||||
const min = 60_000
|
||||
const hour = 60 * min
|
||||
const day = 24 * hour
|
||||
|
||||
function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] {
|
||||
return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null }
|
||||
}
|
||||
|
||||
it('is null when there are no limits or the window has no reset timestamp', () => {
|
||||
expect(getWindowResetLabel(null, 'session', now)).toBe(null)
|
||||
expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null)
|
||||
expect(
|
||||
getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now)
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => {
|
||||
expect(
|
||||
getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now)
|
||||
).toBe('Resets in 47m')
|
||||
expect(
|
||||
getWindowResetLabel(
|
||||
makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }),
|
||||
'session',
|
||||
now
|
||||
)
|
||||
).toBe('Resets in 3h 54m')
|
||||
expect(
|
||||
getWindowResetLabel(
|
||||
makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }),
|
||||
'weekly',
|
||||
now
|
||||
)
|
||||
).toBe('Resets in 6d 7h')
|
||||
})
|
||||
|
||||
it('formats exact hours and exact days without a zero remainder', () => {
|
||||
expect(
|
||||
getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now)
|
||||
).toBe('Resets in 2h')
|
||||
expect(
|
||||
getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now)
|
||||
).toBe('Resets in 7d')
|
||||
})
|
||||
|
||||
it('reports "Resets now" for a reset timestamp in the past', () => {
|
||||
expect(
|
||||
getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now)
|
||||
).toBe('Resets now')
|
||||
})
|
||||
|
||||
it('reads the requested window only', () => {
|
||||
const limits = makeLimits({ session: makeWindow(now + hour) })
|
||||
expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsageBarState', () => {
|
||||
it('keeps stale window data visible during a transient error', () => {
|
||||
const bar = getUsageBarState(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
// Pure state/selectors live here (no React Native imports) so they can be
|
||||
// unit-tested directly; AccountUsage.tsx re-exports them alongside the
|
||||
// UsageBar component.
|
||||
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
|
||||
|
||||
export type RateLimitWindow = {
|
||||
usedPercent: number
|
||||
windowMinutes: number
|
||||
|
|
@ -117,6 +119,27 @@ export function getUsageBarState(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now",
|
||||
* or null when the window has no reset timestamp (so the UI degrades to
|
||||
* today's bars-only layout).
|
||||
*
|
||||
* Why: shares formatResetCountdown with the desktop status-bar tooltip so the
|
||||
* copy stays identical across surfaces. `now` is a parameter so the function
|
||||
* stays pure and unit-testable.
|
||||
*/
|
||||
export function getWindowResetLabel(
|
||||
limits: ProviderRateLimits | null,
|
||||
windowKey: 'session' | 'weekly',
|
||||
now: number
|
||||
): string | null {
|
||||
const resetsAt = limits?.[windowKey]?.resetsAt
|
||||
if (resetsAt == null) {
|
||||
return null
|
||||
}
|
||||
return formatResetCountdown(resetsAt - now)
|
||||
}
|
||||
|
||||
// Why: the usage UI must render for the system-default login, not only for
|
||||
// Orca-managed accounts. Show a provider when it has at least one managed
|
||||
// account OR active rate-limit data for the system-default target.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
|
||||
import {
|
||||
formatResetCountdown,
|
||||
formatResetDuration
|
||||
} from '../../../../shared/rate-limit-reset-format'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
|
@ -39,28 +43,9 @@ 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}`
|
||||
}
|
||||
// Re-export so existing tooltip consumers/tests keep their import path; the
|
||||
// implementation is shared with mobile in src/shared/rate-limit-reset-format.
|
||||
export { formatResetCountdown }
|
||||
|
||||
export function formatResetCreditExpiry(
|
||||
expiresAt: number | null | undefined,
|
||||
|
|
@ -69,7 +54,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,30 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatResetCountdown, formatResetDuration } from './rate-limit-reset-format'
|
||||
|
||||
const MIN = 60_000
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
describe('formatResetDuration', () => {
|
||||
it('returns "now" for non-positive deltas', () => {
|
||||
expect(formatResetDuration(0)).toBe('now')
|
||||
expect(formatResetDuration(-1)).toBe('now')
|
||||
})
|
||||
|
||||
it('floors to whole units and drops zero remainders', () => {
|
||||
expect(formatResetDuration(47 * MIN)).toBe('47m')
|
||||
expect(formatResetDuration(3 * HOUR + 54 * MIN)).toBe('3h 54m')
|
||||
expect(formatResetDuration(2 * HOUR)).toBe('2h')
|
||||
expect(formatResetDuration(6 * DAY + 7 * HOUR)).toBe('6d 7h')
|
||||
expect(formatResetDuration(7 * DAY)).toBe('7d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatResetCountdown', () => {
|
||||
it('prefixes the duration or reports "Resets now"', () => {
|
||||
expect(formatResetCountdown(0)).toBe('Resets now')
|
||||
expect(formatResetCountdown(3 * HOUR + 54 * MIN)).toBe('Resets in 3h 54m')
|
||||
expect(formatResetCountdown(6 * DAY + 7 * HOUR)).toBe('Resets in 6d 7h')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Why: shared by the desktop status-bar tooltip and the mobile accounts screen
|
||||
// so rate-limit reset/expiry countdown copy stays identical across surfaces.
|
||||
// Pure (no platform imports) — safe to bundle in both the renderer and mobile.
|
||||
|
||||
/**
|
||||
* Compact human duration for a rate-limit window, flooring to whole units:
|
||||
* "47m", "3h 54m", "6d 7h". Returns "now" for a non-positive delta so callers
|
||||
* can special-case the "already reset" copy.
|
||||
*/
|
||||
export function formatResetDuration(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`
|
||||
}
|
||||
|
||||
/** "Resets in 3h 54m" / "Resets now" for a window's time-until-reset (ms). */
|
||||
export function formatResetCountdown(ms: number): string {
|
||||
const duration = formatResetDuration(ms)
|
||||
return duration === 'now' ? 'Resets now' : `Resets in ${duration}`
|
||||
}
|
||||
Loading…
Reference in New Issue