mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user