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:
Kaynan Sampaio de Camargo 2026-07-13 18:56:36 -07:00 committed by GitHub
parent 1c6098c214
commit c408a3d852
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 215 additions and 43 deletions

View File

@ -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 ? (

View File

@ -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'], {

View File

@ -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
}
})

View File

@ -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(

View File

@ -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.

View File

@ -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')

View File

@ -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')
})
})

View File

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