feat(mobile): show usage for every agent desktop tracks on the Accounts screen (#38187)

* feat(mobile): show every desktop usage provider on Accounts

* refactor(status-bar): import moved usage helpers from shared

* refactor(mobile): reuse shared usage rules for provider names and data checks

* test(mobile): type account-slot fixtures as Claude/Codex limits
This commit is contained in:
Kelvin Amoaba
2026-11-04 19:29:54 +00:00
committed by GitHub
parent d3d157756a
commit 0ffb87fc18
31 changed files with 488 additions and 305 deletions
+53 -44
View File
@@ -18,11 +18,12 @@ import {
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
UsageBar
getOtherUsageProviders,
hasActiveProviderUsage
} from '../components/AccountUsage'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { getProviderDisplayName } from '../../../src/shared/usage-provider-windows'
import { ProviderUsageWindows } from './ProviderUsageWindows'
import {
getActiveCodexAccountIdForRateLimitTarget,
getCodexResetCreditSummary
@@ -201,7 +202,7 @@ export function MobileAccountsScreen() {
[client, refresh, snapshot, t]
)
const renderProviderSection = (provider: ProviderKey, title: string) => {
const renderProviderSection = (provider: ProviderKey) => {
if (!snapshot) {
return null
}
@@ -211,15 +212,17 @@ export function MobileAccountsScreen() {
? getActiveCodexAccountIdForRateLimitTarget(snapshot)
: state.activeAccountId
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const activeSessionBar = getUsageBarState(activeUsage, 'session')
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
const switchBlocked = busyAccountId !== null || resettingCodex || connState !== 'connected'
const spinner = <ActivityIndicator size="small" color={colors.textSecondary} />
return (
<View>
<SectionHeader title={title} icon={<Icon size={14} />} first={provider === 'claude'} />
<SectionHeader
title={getProviderDisplayName(provider)}
icon={<Icon size={14} />}
first={provider === 'claude'}
/>
<ListGroup style={styles.group}>
{/* A blocked row drops its press instead of dimming, so the picked one keeps full contrast. */}
<ListRow
@@ -229,22 +232,7 @@ export function MobileAccountsScreen() {
detail={
// Show system-default login usage for users without managed accounts.
activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
<View style={styles.usageRow}>
<UsageBar
label={t('accounts.screen.sessionWindow', '5h')}
usedPercent={activeSessionBar.usedPercent}
unavailable={activeSessionBar.unavailable}
loading={activeSessionBar.loading}
resetText={getWindowResetLabel(activeUsage, 'session', now)}
/>
<UsageBar
label={t('accounts.screen.weeklyWindow', '7d')}
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
/>
</View>
<ProviderUsageWindows limits={activeUsage} now={now} showEmptyWindows />
) : undefined
}
accessory={busyAccountId === `${provider}:default` ? spinner : undefined}
@@ -260,30 +248,18 @@ export function MobileAccountsScreen() {
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
const sessionBar = getUsageBarState(usage, 'session', isFetching)
const weeklyBar = getUsageBarState(usage, 'weekly', isFetching)
return (
<ListRow
key={account.id}
title={account.email}
detail={
<>
<View style={styles.usageRow}>
<UsageBar
label={t('accounts.screen.sessionWindow', '5h')}
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
resetText={getWindowResetLabel(usage, 'session', now)}
/>
<UsageBar
label={t('accounts.screen.weeklyWindow', '7d')}
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
resetText={getWindowResetLabel(usage, 'weekly', now)}
/>
</View>
<ProviderUsageWindows
limits={usage}
now={now}
isFetching={isFetching}
showEmptyWindows
/>
{usage?.error ? (
<Text style={styles.errorText} numberOfLines={1}>
{usage.error}
@@ -316,6 +292,38 @@ export function MobileAccountsScreen() {
)
}
// Every other provider desktop shows usage for; usage only, since accounts are managed on desktop.
const renderOtherProviders = () => {
const providers = snapshot ? getOtherUsageProviders(snapshot) : []
if (providers.length === 0) {
return null
}
return (
<View>
<SectionHeader title={t('accounts.screen.otherAgents', 'Other agents')} />
<ListGroup style={styles.group}>
{providers.map((limits) => (
<ListRow
key={limits.provider}
title={getProviderDisplayName(limits.provider)}
leading={<MobileAgentIcon agentId={limits.provider} size={16} />}
detail={
<>
<ProviderUsageWindows limits={limits} now={now} />
{limits.error ? (
<Text style={styles.errorText} numberOfLines={1}>
{limits.error}
</Text>
) : null}
</>
}
/>
))}
</ListGroup>
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<NavBar
@@ -356,8 +364,9 @@ export function MobileAccountsScreen() {
/>
) : (
<>
{renderProviderSection('claude', 'Claude')}
{renderProviderSection('codex', 'Codex')}
{renderProviderSection('claude')}
{renderProviderSection('codex')}
{renderOtherProviders()}
<View style={styles.footerHint}>
<User size={14} color={colors.textMuted} />
<Text style={styles.footerHintText}>
@@ -0,0 +1,108 @@
import { View } from 'react-native'
import { formatWindowLabel } from '../../../src/shared/rate-limit-window-label'
import {
getUsageWindowSections,
type UsageWindowSection
} from '../../../src/shared/usage-provider-windows'
import {
getUsageBarState,
getWindowResetLabel,
UsageBar,
type ProviderRateLimits
} from '../components/AccountUsage'
import type { Translate } from '../i18n/mobile-i18n'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
import { spacing } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
const SESSION_WINDOW_MINUTES = 300
const WEEKLY_WINDOW_MINUTES = 10_080
const EMPTY_SECTIONS: UsageWindowSection[] = [
{ kind: 'session', window: null },
{ kind: 'weekly', window: null }
]
// The desktop roster's labels, except 5h and 7d use mobile's translated copy (desktop shows 7d as "wk").
function getWindowLabel(section: UsageWindowSection, t: Translate): string {
if (section.kind === 'bucket') {
return section.name
}
if (section.kind === 'fableWeekly') {
return t('accounts.screen.fableWindow', 'Fable')
}
if (section.kind === 'mcp') {
return t('accounts.screen.mcpWindow', 'MCP')
}
const minutes =
section.window?.windowMinutes ??
(section.kind === 'session' ? SESSION_WINDOW_MINUTES : WEEKLY_WINDOW_MINUTES)
if (minutes === SESSION_WINDOW_MINUTES) {
return t('accounts.screen.sessionWindow', '5h')
}
if (minutes === WEEKLY_WINDOW_MINUTES) {
return t('accounts.screen.weeklyWindow', '7d')
}
return formatWindowLabel(minutes)
}
/** One bar per usage window desktop shows for this provider. */
export function ProviderUsageWindows({
limits,
now,
isFetching,
showEmptyWindows = false
}: {
limits: ProviderRateLimits | null
now: number
isFetching?: boolean
/** Account rows keep empty session/weekly bars so a not-yet-read account still shows its windows. */
showEmptyWindows?: boolean
}) {
const t = useMobileTranslation()
const sections = (limits ? getUsageWindowSections(limits) : EMPTY_SECTIONS).filter(
(section) => showEmptyWindows || section.window !== null
)
// Pool names can repeat when agy leaves a window unnamed, so count each name's occurrences.
const seen = new Map<string, number>()
return (
<View style={styles.windows}>
{sections.map((section) => {
const name = section.kind === 'bucket' ? `bucket:${section.name}` : section.kind
const occurrence = seen.get(name) ?? 0
seen.set(name, occurrence + 1)
const bar = getUsageBarState(limits, section.window, isFetching)
const wide = section.kind === 'bucket'
return (
<UsageBar
key={`${name}:${occurrence}`}
label={getWindowLabel(section, t)}
usedPercent={bar.usedPercent}
unavailable={bar.unavailable}
loading={bar.loading}
resetText={getWindowResetLabel(section.window, now)}
wideLabel={wide}
style={wide ? styles.wideWindow : styles.window}
/>
)
})}
</View>
)
}
const styles = themedStyleSheet(() => ({
windows: {
flexDirection: 'row',
flexWrap: 'wrap',
columnGap: spacing.md,
rowGap: spacing.sm,
marginTop: spacing.xs
},
// Two windows share a line; a third wraps to its own.
window: {
flexBasis: '40%',
flexGrow: 1
},
wideWindow: {
flexBasis: '100%'
}
}))
@@ -15,11 +15,6 @@ export const styles = themedStyleSheet(() => ({
group: {
backgroundColor: colors.bgPanel
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.xs
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
+53 -34
View File
@@ -1,4 +1,4 @@
import { View, Text, ActivityIndicator } from 'react-native'
import { View, Text, ActivityIndicator, type StyleProp, type ViewStyle } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
@@ -18,6 +18,7 @@ export {
decodeAccountsSnapshot,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getOtherUsageProviders,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
@@ -30,13 +31,18 @@ export function UsageBar({
usedPercent,
unavailable,
loading,
resetText
resetText,
wideLabel = false,
style
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
resetText?: string | null
/** For long pool names: the label sits on its own line above the track. */
wideLabel?: boolean
style?: StyleProp<ViewStyle>
}) {
useMobileColorScheme()
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
@@ -51,44 +57,59 @@ export function UsageBar({
? colors.statusAmber
: colors.statusGreen
return (
<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 style={[wideLabel ? styles.usageBarStack : styles.usageBarRow, style]}>
<Text style={styles.usageLabel} numberOfLines={1}>
{label}
</Text>
<View style={[styles.usageBarBody, !wideLabel && styles.usageBarBodyBeside]}>
<View style={styles.usageBar}>
<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>
{resetText ? (
<Text style={styles.usageResetText} numberOfLines={1}>
{resetText}
</Text>
) : null}
</View>
)
}
const styles = themedStyleSheet(() => ({
usageBarColumn: {
usageBarRow: {
flex: 1,
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs
},
usageBarStack: {
gap: 2
},
usageBarBody: {
gap: 2
},
usageBarBodyBeside: {
flex: 1
},
usageBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -97,7 +118,7 @@ const styles = themedStyleSheet(() => ({
usageLabel: {
fontSize: typography.metaSize,
color: colors.textMuted,
width: 22
minWidth: 22
},
usageTrack: {
flex: 1,
@@ -119,10 +140,8 @@ const styles = themedStyleSheet(() => ({
usageSpinner: {
width: 36
},
// Indent countdown past window label to align with track.
usageResetText: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginLeft: 22 + spacing.xs
color: colors.textMuted
}
}))
@@ -2,16 +2,18 @@ import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
decodeAccountsSnapshot,
getOtherUsageProviders,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage,
type AccountProviderRateLimits,
type AccountsSnapshot,
type InactiveAccountUsage,
type ProviderRateLimits
} from './account-usage-state'
function makeLimits(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLimits {
function makeLimits(overrides: Partial<AccountProviderRateLimits> = {}): AccountProviderRateLimits {
return {
provider: 'claude',
session: null,
@@ -26,8 +28,8 @@ function makeLimits(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLi
function makeSnapshot(
overrides: {
claudeLimits?: ProviderRateLimits | null
codexLimits?: ProviderRateLimits | null
claudeLimits?: AccountProviderRateLimits | null
codexLimits?: AccountProviderRateLimits | null
claudeAccounts?: AccountsSnapshot['claude']['accounts']
codexAccounts?: AccountsSnapshot['codex']['accounts']
inactiveClaudeAccounts?: InactiveAccountUsage[]
@@ -127,84 +129,77 @@ 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 session = { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
const bar = getUsageBarState(
makeLimits({
status: 'error',
error: 'temporarily unavailable',
session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
}),
'session'
makeLimits({ status: 'error', error: 'temporarily unavailable', session }),
session
)
expect(bar).toEqual({ usedPercent: 72, unavailable: false, loading: false })
})
it('shows loading for a fetching provider without a window', () => {
expect(getUsageBarState(makeLimits({ status: 'fetching' }), 'weekly')).toEqual({
expect(getUsageBarState(makeLimits({ status: 'fetching' }), null)).toEqual({
usedPercent: null,
unavailable: false,
loading: true
})
})
})
function providerLimits(
provider: string,
overrides: Partial<ProviderRateLimits>
): ProviderRateLimits {
return { ...makeLimits(), ...overrides, provider }
}
describe('getOtherUsageProviders', () => {
it('lists every set-up provider the host sends, in host order, beside Claude and Codex', () => {
const week = { usedPercent: 12, windowMinutes: 10_080, resetsAt: null, resetDescription: null }
const host = makeSnapshot({ claudeLimits: makeLimits({ status: 'ok', weekly: week }) })
const snapshot = decodeAccountsSnapshot({
...host,
rateLimits: {
...host.rateLimits,
gemini: providerLimits('gemini', {
status: 'unavailable',
error: 'OAuth disabled'
}),
antigravity: providerLimits('antigravity', {
status: 'ok',
buckets: [
{ name: 'Gemini Models', group: 'Gemini Models', ...week },
{ name: 'Claude and GPT models', ...week, windowMinutes: 0 }
]
}),
kimi: providerLimits('kimi', {
status: 'idle'
}),
grok: providerLimits('grok', {
status: 'ok',
weekly: week
}),
cursor: providerLimits('cursor', {
status: 'error',
error: 'Cursor is down'
}),
'future-agent': providerLimits('future-agent', {
status: 'ok',
monthly: week
}),
broken: { provider: 'broken', status: 'ok', weekly: { usedPercent: 'lots' } },
grokAuthConfigured: true
}
})
expect(getOtherUsageProviders(snapshot).map((limits) => limits.provider)).toEqual([
'antigravity',
'grok',
'cursor',
'future-agent'
])
})
})
+29 -26
View File
@@ -1,10 +1,13 @@
// Keep shapes aligned with shared types/rate-limit-types without pulling Electron-coupled desktop graph into mobile.
// Pure RN-free selectors stay directly testable; AccountUsage re-exports them with UsageBar.
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
import type {
AccountsSnapshot,
InactiveAccountUsage,
ProviderRateLimits
import { hasUsageData } from '../../../src/shared/usage-provider-windows'
import {
ProviderRateLimitsSchema,
type AccountsSnapshot,
type InactiveAccountUsage,
type ProviderRateLimits,
type RateLimitWindow
} from './accounts-snapshot'
export {
@@ -12,6 +15,7 @@ export {
decodeAccountsSnapshot,
ProviderRateLimitsSchema,
RateLimitRuntimeTargetSchema,
type AccountProviderRateLimits,
type AccountsSnapshot,
type ClaudeAccountSummary,
type CodexAccountSummary,
@@ -50,27 +54,15 @@ export function getInactiveProviderUsage(
// System-default logins still fetch quota without managed accounts. Show successful provider or any window data; empty unavailable/error means no credentials.
export function hasActiveProviderUsage(limits: ProviderRateLimits | null): boolean {
if (!limits) {
return false
}
if (
limits.session != null ||
limits.weekly != null ||
limits.monthly != null ||
(limits.buckets && limits.buckets.length > 0)
) {
return true
}
return limits.status === 'ok'
return limits !== null && (hasUsageData(limits) || limits.status === 'ok')
}
// Determine availability per window because transient provider errors retain successful window data.
export function getUsageBarState(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
window: RateLimitWindow | null,
isFetchingOverride?: boolean
): UsageBarState {
const window = limits?.[windowKey] ?? null
const fetching =
isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle')
return {
@@ -81,16 +73,27 @@ export function getUsageBarState(
}
// Use desktop formatResetCountdown copy with injectable now; return null without reset timestamp to retain bars-only UI.
export function getWindowResetLabel(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
now: number
): string | null {
const resetsAt = limits?.[windowKey]?.resetsAt
if (resetsAt == null) {
export function getWindowResetLabel(window: RateLimitWindow | null, now: number): string | null {
if (window?.resetsAt == null) {
return null
}
return formatResetCountdown(resetsAt - now)
return formatResetCountdown(window.resetsAt - now)
}
/** Every other provider the host reports usage for, in the host's order; Claude and Codex have their own sections. */
export function getOtherUsageProviders(snapshot: AccountsSnapshot): ProviderRateLimits[] {
const providers: ProviderRateLimits[] = []
for (const [slot, value] of Object.entries(snapshot.rateLimits)) {
if (slot === 'claude' || slot === 'codex') {
continue
}
const parsed = ProviderRateLimitsSchema.safeParse(value)
// Hidden or signed-out providers come back idle or unavailable with no windows.
if (parsed.success && (hasUsageData(parsed.data) || parsed.data.status === 'error')) {
providers.push(parsed.data)
}
}
return providers
}
// Show provider with managed accounts or active system-default quota; management is not required for usage UI.
+13 -15
View File
@@ -7,7 +7,8 @@ const AccountIdSchema = z.string().min(1)
const RateLimitWindowSchema = z
.object({
usedPercent: z.number().finite().min(0).max(100),
windowMinutes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
// agy reports windows of unknown length as 0.
windowMinutes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
resetsAt: TimestampSchema.nullable(),
resetDescription: z.string().nullable()
})
@@ -32,17 +33,8 @@ const RateLimitResetCreditsSchema = z
export const ProviderRateLimitsSchema = z
.object({
provider: z.enum([
'claude',
'codex',
'gemini',
'opencode-go',
'kimi',
'minimax',
'grok',
'antigravity',
'cursor'
]),
// Any provider the host polls, so a provider added on desktop decodes here too.
provider: z.string().min(1),
session: RateLimitWindowSchema.nullable(),
weekly: RateLimitWindowSchema.nullable(),
fableWeekly: RateLimitWindowSchema.nullable().optional(),
@@ -57,10 +49,15 @@ export const ProviderRateLimitsSchema = z
})
.passthrough()
// The slots that carry accounts; their exact identity is checked against the slot below.
const AccountProviderRateLimitsSchema = ProviderRateLimitsSchema.extend({
provider: z.enum(['claude', 'codex'])
})
const InactiveAccountUsageSchema = z
.object({
accountId: AccountIdSchema,
rateLimits: ProviderRateLimitsSchema.nullable(),
rateLimits: AccountProviderRateLimitsSchema.nullable(),
updatedAt: TimestampSchema,
isFetching: z.boolean()
})
@@ -174,8 +171,8 @@ export const AccountsSnapshotSchema = z
.passthrough(),
rateLimits: z
.object({
claude: ProviderRateLimitsSchema.nullable(),
codex: ProviderRateLimitsSchema.nullable(),
claude: AccountProviderRateLimitsSchema.nullable(),
codex: AccountProviderRateLimitsSchema.nullable(),
// Older compatible hosts omit runtime target fields and retain host-only selection semantics.
claudeTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
codexTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
@@ -222,6 +219,7 @@ export const AccountsSnapshotSchema = z
export type RateLimitWindow = z.infer<typeof RateLimitWindowSchema>
export type ProviderRateLimits = z.infer<typeof ProviderRateLimitsSchema>
export type AccountProviderRateLimits = z.infer<typeof AccountProviderRateLimitsSchema>
export type InactiveAccountUsage = z.infer<typeof InactiveAccountUsageSchema>
export type RateLimitRuntimeTarget = z.infer<typeof RateLimitRuntimeTargetSchema>
export type ClaudeAccountSummary = z.infer<typeof ClaudeAccountSummarySchema>
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const uuid = vi.hoisted(() => ({ next: vi.fn<() => string>() }))
vi.mock('../platform/random-v4-uuid', () => ({ randomV4Uuid: uuid.next }))
import type { AccountsSnapshot, ProviderRateLimits } from './accounts-snapshot'
import type { AccountProviderRateLimits, AccountsSnapshot } from './accounts-snapshot'
import {
getActiveCodexAccountIdForRateLimitTarget,
getCodexResetCreditOutcomeCopy,
@@ -14,7 +14,10 @@ import {
const UUID = '11111111-1111-4111-8111-111111111111'
function makeLimits(availableCount: number, nextExpiresAt: number | null): ProviderRateLimits {
function makeLimits(
availableCount: number,
nextExpiresAt: number | null
): AccountProviderRateLimits {
return {
provider: 'codex',
session: null,
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Verbindung zum Host wird hergestellt…",
"connectingTo": "Verbindung zu {{name}} wird hergestellt…",
"default": "Systemstandard",
"fableWindow": "Fable",
"hostNotFound": "Host nicht gefunden",
"invalidSnapshot": "Ungültige Momentaufnahme der Konten vom Host",
"loadingAccounts": "Konten werden geladen…",
"manageAccountsHint": "Konten auf dem Desktop unter Einstellungen → Konten hinzufügen oder erneut authentifizieren.",
"mcpWindow": "MCP",
"otherAgents": "Weitere Agenten",
"refreshAccounts": "Konten aktualisieren",
"sessionWindow": "5 h",
"switchFailed": "Konto konnte nicht gewechselt werden",
+4 -1
View File
@@ -290,7 +290,10 @@
"loadingAccounts": "Loading accounts…",
"manageAccountsHint": "Add or re-authenticate accounts from desktop Settings → Accounts.",
"useReset": "Use reset",
"pairingInvalidMessage": "Re-pair this phone with your desktop to see its accounts."
"pairingInvalidMessage": "Re-pair this phone with your desktop to see its accounts.",
"otherAgents": "Other agents",
"fableWindow": "Fable",
"mcpWindow": "MCP"
}
},
"agentHistory": {
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Conectando al host…",
"connectingTo": "Conectando a {{name}}…",
"default": "Predeterminado del sistema",
"fableWindow": "Fable",
"hostNotFound": "Host no encontrado",
"invalidSnapshot": "Instantánea de cuentas del host no válida",
"loadingAccounts": "Cargando cuentas…",
"manageAccountsHint": "Añade cuentas o vuelve a autenticarlas desde Ajustes → Cuentas en el escritorio.",
"mcpWindow": "MCP",
"otherAgents": "Otros agentes",
"refreshAccounts": "Actualizar cuentas",
"sessionWindow": "5h",
"switchFailed": "No se pudo cambiar de cuenta",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Connexion à l'hôte…",
"connectingTo": "Connexion à {{name}}…",
"default": "Valeur par défaut du système",
"fableWindow": "Fable",
"hostNotFound": "Hôte introuvable",
"invalidSnapshot": "État des comptes invalide reçu de l'hôte",
"loadingAccounts": "Chargement des comptes…",
"manageAccountsHint": "Ajoutez ou réauthentifiez des comptes dans Paramètres → Comptes sur l'ordinateur.",
"mcpWindow": "MCP",
"otherAgents": "Autres agents",
"refreshAccounts": "Actualiser les comptes",
"sessionWindow": "5 h",
"switchFailed": "Impossible de changer de compte",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "ホストに接続中…",
"connectingTo": "「{{name}}」に接続中…",
"default": "システムのデフォルト",
"fableWindow": "Fable",
"hostNotFound": "ホストが見つかりません",
"invalidSnapshot": "ホストからのアカウントスナップショットが無効です",
"loadingAccounts": "アカウントを読み込み中…",
"manageAccountsHint": "デスクトップの「設定」→「アカウント」から、アカウントの追加や再認証を行ってください。",
"mcpWindow": "MCP",
"otherAgents": "その他の Agent",
"refreshAccounts": "アカウントを更新",
"sessionWindow": "5時間",
"switchFailed": "アカウントを切り替えられませんでした",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "호스트에 연결 중…",
"connectingTo": "{{name}}에 연결 중…",
"default": "시스템 기본값",
"fableWindow": "Fable",
"hostNotFound": "호스트를 찾을 수 없습니다.",
"invalidSnapshot": "호스트에서 받은 계정 스냅샷이 유효하지 않습니다.",
"loadingAccounts": "계정 로드 중…",
"manageAccountsHint": "데스크톱의 설정 → 계정에서 계정을 추가하거나 다시 인증하세요.",
"mcpWindow": "MCP",
"otherAgents": "기타 에이전트",
"refreshAccounts": "계정 새로고침",
"sessionWindow": "5시간",
"switchFailed": "계정을 전환하지 못했습니다.",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Conectando ao host…",
"connectingTo": "Conectando a {{name}}…",
"default": "Padrão do sistema",
"fableWindow": "Fable",
"hostNotFound": "Host não encontrado",
"invalidSnapshot": "Snapshot de contas inválido recebido do host",
"loadingAccounts": "Carregando contas…",
"manageAccountsHint": "Adicione contas ou autentique-as novamente em Configurações → Contas no desktop.",
"mcpWindow": "MCP",
"otherAgents": "Outros agentes",
"refreshAccounts": "Atualizar contas",
"sessionWindow": "5h",
"switchFailed": "Não foi possível trocar de conta",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Подключение к серверу…",
"connectingTo": "Подключение к {{name}}…",
"default": "Как в системе",
"fableWindow": "Fable",
"hostNotFound": "Сервер не найден",
"invalidSnapshot": "Некорректные данные учётных записей от сервера",
"loadingAccounts": "Загрузка учётных записей…",
"manageAccountsHint": "Для добавления учётных записей или повторного входа откройте «Настройки → Учётные записи» в приложении на компьютере.",
"mcpWindow": "MCP",
"otherAgents": "Другие агенты",
"refreshAccounts": "Обновить учётные записи",
"sessionWindow": "5 ч",
"switchFailed": "Не удалось переключить учётную запись",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "Sunucuya bağlanılıyor…",
"connectingTo": "{{name}} ile bağlantı kuruluyor…",
"default": "Sistem varsayılanı",
"fableWindow": "Fable",
"hostNotFound": "Sunucu bulunamadı",
"invalidSnapshot": "Sunucudan gelen hesap anlık görüntüsü geçersiz",
"loadingAccounts": "Hesaplar yükleniyor…",
"manageAccountsHint": "Masaüstünde Ayarlar → Hesaplar bölümünden hesap ekleyin veya hesaplarınızda yeniden oturum açın.",
"mcpWindow": "MCP",
"otherAgents": "Diğer ajanlar",
"refreshAccounts": "Hesapları yenile",
"sessionWindow": "5 sa",
"switchFailed": "Hesap değiştirilemedi",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "正在連線至主機…",
"connectingTo": "正在連線至 {{name}}…",
"default": "系統預設",
"fableWindow": "Fable",
"hostNotFound": "找不到主機",
"invalidSnapshot": "主機傳回的帳戶快照無效",
"loadingAccounts": "正在載入帳戶…",
"manageAccountsHint": "請至桌面版的「設定」→「帳戶」,新增帳戶或重新驗證。",
"mcpWindow": "MCP",
"otherAgents": "其他代理程式",
"refreshAccounts": "重新整理帳戶",
"sessionWindow": "5 小時",
"switchFailed": "無法切換帳戶",
+3
View File
@@ -5,10 +5,13 @@
"connecting": "正在连接到主机…",
"connectingTo": "正在连接到 {{name}}…",
"default": "系统默认",
"fableWindow": "Fable",
"hostNotFound": "未找到主机",
"invalidSnapshot": "主机返回的账户快照无效",
"loadingAccounts": "正在加载账户…",
"manageAccountsHint": "请在桌面的“设置 → 账户”中添加账户或重新进行账户身份验证。",
"mcpWindow": "MCP",
"otherAgents": "其他智能体",
"refreshAccounts": "刷新账户",
"sessionWindow": "5小时",
"switchFailed": "无法切换账户",
@@ -10,7 +10,7 @@ import {
} from '../../../../shared/usage-percentage-display'
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
import { barColor, clampUsedPercent } from './tooltip'
import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
import { formatRateLimitWindowChipLabel } from '../../../../shared/rate-limit-window-label'
import { formatUsagePercentageLabel } from './usage-percentage-label'
import { getUsageRosterRowState } from './usage-roster-row-state'
import { translate } from '@/i18n/i18n'
@@ -3,7 +3,8 @@ import { Card } from '@/components/ui/card'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import { translate } from '@/i18n/i18n'
import { formatTimeAgo, getProviderDisplayName, ProviderIcon, ProviderPanel } from './tooltip'
import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { formatTimeAgo, ProviderIcon, ProviderPanel } from './tooltip'
export function ProviderIdentityCard({
p,
@@ -17,10 +17,10 @@ import {
USAGE_URGENT_PERCENT,
USAGE_WARNING_PERCENT,
clampUsedPercent,
getProviderDisplayName,
getProviderUsageErrorMessage,
getProviderUsageStatusLabel
} from './tooltip'
import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { getOwnerNotSetUpLabelParts } from './owner-not-set-up-copy'
import { isClaudeUsageWaitingForClaude } from './usage-error-copy'
import { getTightestUsageSection, getUsageHeadlineSection } from './UsageRosterPanel'
@@ -4,7 +4,10 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { SettingsSegmentedControl } from '@/components/settings/form-controls/SettingsFormControls'
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
import { translate } from '@/i18n/i18n'
import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
import {
formatRateLimitWindowChipLabel,
formatWindowLabel
} from '../../../../shared/rate-limit-window-label'
import { CURSOR_MODELS_BUCKET_NAME } from '../../../../shared/cursor-usage-buckets'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
import {
@@ -13,7 +16,7 @@ import {
type UsagePercentageDisplay
} from '../../../../shared/usage-percentage-display'
import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip'
import { getProviderDisplayName } from './usage-error-copy'
import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting'
import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state'
import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
@@ -9,10 +9,10 @@ import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-f
import { translate } from '@/i18n/i18n'
import {
createPendingProviderSnapshot,
hasUsageData,
hasUsageProviderSettingsForProvider,
isProviderConfigured
} from './status-bar-provider-visibility'
import { hasUsageData } from '../../../../shared/usage-provider-windows'
import { describeOwnerNotSetUp } from './owner-not-set-up-copy'
/** Whose usage the status bar shows: this device, or the remote host that owns the accounts. */
@@ -1,5 +1,6 @@
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { hasUsageData } from '../../../../shared/usage-provider-windows'
export type UsageProviderSettings = Pick<
GlobalSettings,
@@ -36,16 +37,6 @@ type UsageProviderSnapshots = {
type UsageProviderId = ProviderRateLimits['provider']
export function hasUsageData(provider: ProviderRateLimits): boolean {
return Boolean(
provider.session ||
provider.weekly ||
provider.fableWeekly ||
provider.monthly ||
(provider.buckets && provider.buckets.length > 0)
)
}
function isProviderSnapshotPending(provider: ProviderRateLimits | null | undefined): boolean {
return provider == null || (provider.status === 'fetching' && !hasUsageData(provider))
}
@@ -2,7 +2,7 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rat
import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
import { isCursorUsageBucket } from '../../../../shared/cursor-usage-buckets'
import { translate } from '@/i18n/i18n'
import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
import { formatRateLimitWindowChipLabel } from '../../../../shared/rate-limit-window-label'
import { getUsageHeadlineSection } from './UsageRosterPanel'
export type StatusBarUsageSection = {
@@ -8,9 +8,10 @@ import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from
import { translate } from '@/i18n/i18n'
import {
getProviderDisplayName,
getProviderUsageErrorMessage,
getProviderUsageStatusLabel
} from './usage-error-copy'
getUsageWindowSections,
type UsageWindowSection
} from '../../../../shared/usage-provider-windows'
import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy'
import {
clampUsedPercent,
getDisplayedUsagePercentage,
@@ -29,11 +30,7 @@ export {
export { clampUsedPercent }
export {
getProviderDisplayName,
getProviderUsageErrorMessage,
getProviderUsageStatusLabel
} from './usage-error-copy'
export { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy'
export function formatTimeAgo(ts: number): string {
const diff = Date.now() - ts
@@ -144,54 +141,29 @@ function ErrorMessage({
export function getWindowSections(
p: ProviderRateLimits
): { label: string; window: RateLimitWindow | null }[] {
if (p.buckets?.length) {
const bucketSections = p.buckets.map((b) => ({ label: b.name, window: b as RateLimitWindow }))
return [
...bucketSections,
// Include Cursor’s monthly plan total because pools can understate proximity to its cap.
...(p.monthly
? [
{
label:
p.provider === 'cursor'
? translate('auto.components.status.bar.tooltip.cursor.plan', 'Plan')
: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'),
window: p.monthly
}
]
: []),
{
label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'),
window: p.weekly
}
]
return getUsageWindowSections(p).map((section) => ({
label: getWindowSectionLabel(section),
window: section.window
}))
}
function getWindowSectionLabel(section: UsageWindowSection): string {
switch (section.kind) {
case 'bucket':
return section.name
case 'session':
return translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session')
case 'weekly':
return translate('auto.components.status.bar.tooltip.252c096536', 'Weekly')
case 'fableWeekly':
return translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable')
case 'monthly':
return translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly')
case 'plan':
return translate('auto.components.status.bar.tooltip.cursor.plan', 'Plan')
case 'mcp':
return translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP')
}
const sections: { label: string; window: RateLimitWindow | null }[] = [
{
label: translate('auto.components.status.bar.tooltip.94038ad2fa', 'Session'),
window: p.session
},
{
label: translate('auto.components.status.bar.tooltip.252c096536', 'Weekly'),
window: p.weekly
}
]
if (p.fableWeekly !== undefined && p.fableWeekly !== null) {
sections.push({
label: translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable'),
window: p.fableWeekly
})
}
if (p.monthly !== undefined && p.monthly !== null) {
sections.push({
label:
p.provider === 'zcode'
? translate('auto.components.status.bar.tooltip.zcode.mcp', 'MCP')
: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'),
window: p.monthly
})
}
return sections
}
// Tooltips invert the theme; use background text tokens for legible primary and secondary content.
@@ -1,41 +1,8 @@
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import { translate } from '@/i18n/i18n'
import { getProviderDisplayName } from '../../../../shared/usage-provider-windows'
import { describeOwnerNotSetUp, getOwnerNotSetUpLabel } from './owner-not-set-up-copy'
export function getProviderDisplayName(provider: ProviderRateLimits['provider']): string {
if (provider === 'claude') {
return 'Claude'
}
if (provider === 'codex') {
return 'Codex'
}
if (provider === 'gemini') {
return 'Gemini'
}
if (provider === 'opencode-go') {
return 'OpenCode Go'
}
if (provider === 'kimi') {
return 'Kimi'
}
if (provider === 'antigravity') {
return 'Antigravity'
}
if (provider === 'minimax') {
return 'MiniMax'
}
if (provider === 'grok') {
return 'Grok'
}
if (provider === 'zcode') {
return 'ZCode'
}
if (provider === 'cursor') {
return 'Cursor'
}
return provider
}
function isUsageRateLimitError(p: ProviderRateLimits): boolean {
const failureKind = p.usageMetadata?.failureKind
// Structured failure kinds outrank wording that may only name the unread rate limits.
@@ -1,5 +1,8 @@
import { translate } from '@/i18n/i18n'
import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
import {
formatRateLimitWindowChipLabel,
formatWindowLabel
} from '../../../../shared/rate-limit-window-label'
import type {
ProviderRateLimits,
RateLimitBucket,
@@ -1,4 +1,4 @@
import { formatResetDuration } from '../../../shared/rate-limit-reset-format'
import { formatResetDuration } from './rate-limit-reset-format'
/** Format usage-window duration; retain 10080 minutes → wk compatibility with the original status bar. */
export function formatWindowLabel(windowMinutes: number): string {
+83
View File
@@ -0,0 +1,83 @@
import type { ProviderRateLimits, RateLimitWindow } from './rate-limit-types'
// Structural so mobile's decoded snapshot, whose provider id is any string, fits too.
type ProviderUsageWindows = {
provider: string
session: RateLimitWindow | null
weekly: RateLimitWindow | null
fableWeekly?: RateLimitWindow | null
monthly?: RateLimitWindow | null
buckets?: readonly (RateLimitWindow & { name: string })[]
}
export type UsageWindowSection =
| { kind: 'bucket'; name: string; window: RateLimitWindow }
| {
kind: 'session' | 'weekly' | 'fableWeekly' | 'monthly' | 'plan' | 'mcp'
window: RateLimitWindow | null
}
// `satisfies` makes a provider added to ProviderRateLimits fail the typecheck until it has a name.
const PROVIDER_DISPLAY_NAMES: Readonly<Record<string, string>> = {
claude: 'Claude',
codex: 'Codex',
gemini: 'Gemini',
'opencode-go': 'OpenCode Go',
kimi: 'Kimi',
antigravity: 'Antigravity',
minimax: 'MiniMax',
grok: 'Grok',
zcode: 'ZCode',
cursor: 'Cursor'
} satisfies Record<ProviderRateLimits['provider'], string>
// A provider id from a newer host falls back to the id itself.
export function getProviderDisplayName(provider: string): string {
return Object.hasOwn(PROVIDER_DISPLAY_NAMES, provider)
? PROVIDER_DISPLAY_NAMES[provider]
: provider
}
export function hasUsageData(provider: ProviderUsageWindows): boolean {
return Boolean(
provider.session ||
provider.weekly ||
provider.fableWeekly ||
provider.monthly ||
(provider.buckets && provider.buckets.length > 0)
)
}
/** The usage windows a provider shows, in order; session and weekly stay as null placeholders. */
export function getUsageWindowSections(p: ProviderUsageWindows): UsageWindowSection[] {
if (p.buckets?.length) {
return [
...p.buckets.map((bucket) => ({
kind: 'bucket' as const,
name: bucket.name,
window: bucket
})),
// Include Cursor’s monthly plan total because pools can understate proximity to its cap.
...(p.monthly
? [
{
kind: p.provider === 'cursor' ? ('plan' as const) : ('monthly' as const),
window: p.monthly
}
]
: []),
{ kind: 'weekly', window: p.weekly }
]
}
const sections: UsageWindowSection[] = [
{ kind: 'session', window: p.session },
{ kind: 'weekly', window: p.weekly }
]
if (p.fableWeekly) {
sections.push({ kind: 'fableWeekly', window: p.fableWeekly })
}
if (p.monthly) {
sections.push({ kind: p.provider === 'zcode' ? 'mcp' : 'monthly', window: p.monthly })
}
return sections
}