From c408a3d85268181f22be9c84dee2de32fe97c3b7 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:56:36 -0700 Subject: [PATCH] feat(mobile): show usage reset countdown on accounts screen (#7954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- mobile/app/h/[hostId]/accounts.tsx | 13 ++++ mobile/scripts/start-emulator.mjs | 5 ++ mobile/src/components/AccountUsage.tsx | 64 +++++++++++++------ .../components/account-usage-state.test.ts | 60 +++++++++++++++++ mobile/src/components/account-usage-state.ts | 23 +++++++ .../src/components/status-bar/tooltip.tsx | 31 +++------ src/shared/rate-limit-reset-format.test.ts | 30 +++++++++ src/shared/rate-limit-reset-format.ts | 32 ++++++++++ 8 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 src/shared/rate-limit-reset-format.test.ts create mode 100644 src/shared/rate-limit-reset-format.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abf..652dfffee 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -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(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)} /> ) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} /> {usage?.error ? ( diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45..d6489b530 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -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'], { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e6074..713a2dd8a 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -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 ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } 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 } }) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f56714358..3e128b7b0 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -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( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731c..fa5b4a516 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -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. diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c2fde5de9..849dc7bed 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -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') diff --git a/src/shared/rate-limit-reset-format.test.ts b/src/shared/rate-limit-reset-format.test.ts new file mode 100644 index 000000000..1598c4c42 --- /dev/null +++ b/src/shared/rate-limit-reset-format.test.ts @@ -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') + }) +}) diff --git a/src/shared/rate-limit-reset-format.ts b/src/shared/rate-limit-reset-format.ts new file mode 100644 index 000000000..2c1b48809 --- /dev/null +++ b/src/shared/rate-limit-reset-format.ts @@ -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}` +}