fix(mobile): show usage bars for system-default login (#5499)

Show mobile account usage for Claude/Codex system-default logins when rate-limit data exists without Orca-managed accounts.\n\nReview fixes:\n- match inactive account usage to the runtime rateLimits payload shape\n- share usage-bar state so stale window data remains visible during transient fetch errors\n- add regression coverage for both cases
This commit is contained in:
gsxdsm
2026-06-16 13:29:45 -07:00
committed by GitHub
parent 53873f23fe
commit f47832cce0
6 changed files with 481 additions and 233 deletions
@@ -0,0 +1,137 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
export const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+34 -146
View File
@@ -2,7 +2,6 @@ import { useEffect, useState, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
@@ -15,13 +14,16 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
@@ -132,6 +134,8 @@ export default function AccountsScreen() {
}
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const activeSessionBar = getUsageBarState(activeUsage, 'session')
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
<View style={styles.section}>
@@ -149,6 +153,25 @@ export default function AccountsScreen() {
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
{/* Why: when system default is the active selection, activeUsage
holds the system-default login's rate limits surface them
here so non-managed users still see their usage. */}
{state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={activeSessionBar.usedPercent}
unavailable={activeSessionBar.unavailable}
loading={activeSessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
/>
</View>
) : null}
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
@@ -164,12 +187,12 @@ export default function AccountsScreen() {
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
const usage = isActive ? activeUsage : (inactiveEntry?.rateLimits ?? null)
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
const session = usage?.session
const weekly = usage?.weekly
const sessionBar = getUsageBarState(usage, 'session', isFetching)
const weeklyBar = getUsageBarState(usage, 'weekly', isFetching)
return (
<View key={account.id}>
<View style={styles.separator} />
@@ -185,15 +208,15 @@ export default function AccountsScreen() {
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={session?.usedPercent ?? null}
unavailable={!session && !isFetching}
loading={isFetching && !session}
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={weekly?.usedPercent ?? null}
unavailable={!weekly && !isFetching}
loading={isFetching && !weekly}
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
{usage?.error ? (
@@ -285,138 +308,3 @@ export default function AccountsScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+21 -17
View File
@@ -19,6 +19,9 @@ import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage,
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
@@ -609,9 +612,10 @@ export default function HomeScreen() {
if (!snap) {
continue
}
const hasClaude = snap.claude.accounts.length > 0
const hasCodex = snap.codex.accounts.length > 0
if (hasClaude || hasCodex) {
// Why: also show hosts whose only usage is the system-default login
// (no Orca-managed accounts but live rate-limit data for the active
// target), otherwise system-default users see no usage section at all.
if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
items.push({ host, snapshot: snap })
}
}
@@ -979,16 +983,16 @@ export default function HomeScreen() {
provider === 'claude'
? snapshot.claude.accounts
: snapshot.codex.accounts
if (accounts.length === 0) {
const limits = getActiveProviderRateLimits(snapshot, provider)
// Why: with no managed accounts, still render a
// "System default" row when the active target has
// live usage data; the row label already falls back
// to "System default" below.
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
const limits = getActiveProviderRateLimits(snapshot, provider)
const isFetching =
limits?.status === 'fetching' || limits?.status === 'idle'
const unavailable =
limits == null ||
limits.status === 'unavailable' ||
limits.status === 'error'
const sessionBar = getUsageBarState(limits, 'session')
const weeklyBar = getUsageBarState(limits, 'weekly')
return (
<View key={provider} style={styles.accountsRow}>
<View style={styles.accountsIcon}>
@@ -1005,15 +1009,15 @@ export default function HomeScreen() {
<View style={styles.accountsBars}>
<UsageBar
label="5h"
usedPercent={limits?.session?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.session == null}
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={limits?.weekly?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.weekly == null}
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
</View>
+19 -70
View File
@@ -1,76 +1,25 @@
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
claude: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Pure types and selectors live in account-usage-state.ts (no RN imports) so
// they are unit-testable; re-exported here so existing import sites are stable.
export type {
RateLimitWindow,
ProviderRateLimits,
InactiveAccountUsage,
ClaudeAccountSummary,
CodexAccountSummary,
AccountsSnapshot,
ProviderKey,
UsageBarState
} from './account-usage-state'
export {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage
} from './account-usage-state'
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage,
type AccountsSnapshot,
type InactiveAccountUsage,
type ProviderRateLimits
} from './account-usage-state'
function makeLimits(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
monthly: null,
updatedAt: 0,
error: null,
status: 'idle',
...overrides
}
}
function makeSnapshot(
overrides: {
claudeLimits?: ProviderRateLimits | null
codexLimits?: ProviderRateLimits | null
claudeAccounts?: AccountsSnapshot['claude']['accounts']
codexAccounts?: AccountsSnapshot['codex']['accounts']
inactiveClaudeAccounts?: InactiveAccountUsage[]
inactiveCodexAccounts?: InactiveAccountUsage[]
} = {}
): AccountsSnapshot {
return {
claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null },
codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null },
rateLimits: {
claude: overrides.claudeLimits ?? null,
codex: overrides.codexLimits ?? null,
inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [],
inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? []
}
}
}
describe('hasActiveProviderUsage', () => {
it('is false when there are no rate limits at all', () => {
expect(hasActiveProviderUsage(null)).toBe(false)
})
it('is true when a session window has data', () => {
expect(
hasActiveProviderUsage(
makeLimits({
status: 'ok',
session: { usedPercent: 12, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
)
).toBe(true)
})
it('is true when a successful fetch returned ok even with empty windows', () => {
expect(hasActiveProviderUsage(makeLimits({ status: 'ok' }))).toBe(true)
})
it('is false for an unavailable/error provider with no window data (no creds)', () => {
expect(hasActiveProviderUsage(makeLimits({ status: 'unavailable' }))).toBe(false)
expect(hasActiveProviderUsage(makeLimits({ status: 'error', error: 'nope' }))).toBe(false)
})
})
describe('hasRenderableUsage', () => {
it('is true when the provider has at least one managed account', () => {
const snapshot = makeSnapshot({
claudeAccounts: [{ id: 'a', email: 'x@y.z' }]
})
expect(hasRenderableUsage(snapshot, 'claude')).toBe(true)
})
// The bug: system-default auth has zero managed accounts but real usage data,
// and the home screen used to hide it entirely.
it('is true with zero managed accounts when active rate-limit data exists (system default)', () => {
const snapshot = makeSnapshot({
codexLimits: makeLimits({
provider: 'codex',
status: 'ok',
session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
})
expect(hasRenderableUsage(snapshot, 'codex')).toBe(true)
})
it('is false with zero accounts and no usable rate-limit data', () => {
const snapshot = makeSnapshot({
claudeLimits: makeLimits({ status: 'unavailable' })
})
expect(hasRenderableUsage(snapshot, 'claude')).toBe(false)
expect(hasRenderableUsage(makeSnapshot(), 'claude')).toBe(false)
})
})
describe('getInactiveProviderUsage', () => {
it('returns inactive usage using the runtime rateLimits payload shape', () => {
const limits = makeLimits({
status: 'ok',
session: { usedPercent: 52, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
const snapshot = makeSnapshot({
inactiveClaudeAccounts: [
{ accountId: 'account-1', rateLimits: limits, updatedAt: 123, isFetching: false }
]
})
expect(getInactiveProviderUsage(snapshot, 'claude', 'account-1')?.rateLimits).toBe(limits)
})
})
describe('getUsageBarState', () => {
it('keeps stale window data visible during a transient error', () => {
const bar = getUsageBarState(
makeLimits({
status: 'error',
error: 'temporarily unavailable',
session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
}),
'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({
usedPercent: null,
unavailable: false,
loading: true
})
})
})
@@ -0,0 +1,129 @@
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
//
// 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.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
buckets?: Array<RateLimitWindow & { name: string }>
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
rateLimits: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export type UsageBarState = {
usedPercent: number | null
unavailable: boolean
loading: boolean
}
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Why: rate limits are fetched for the active target even when no Orca-managed
// account exists (the default target is the agent's own system-default login).
// Treat a provider as having usage worth showing when a fetch succeeded or any
// window has data; an unavailable/error provider with no windows means the
// system-default login has no credentials for it, so there is nothing to show.
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'
}
// Why: transient errors keep the last successful window data, so availability
// is per window rather than per provider status.
export function getUsageBarState(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
isFetchingOverride?: boolean
): UsageBarState {
const window = limits?.[windowKey] ?? null
const fetching =
isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle')
return {
usedPercent: window?.usedPercent ?? null,
unavailable: window == null && !fetching,
loading: fetching && window == null
}
}
// 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.
export function hasRenderableUsage(snapshot: AccountsSnapshot, provider: ProviderKey): boolean {
const accounts = provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
if (accounts.length > 0) {
return true
}
return hasActiveProviderUsage(getActiveProviderRateLimits(snapshot, provider))
}