mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
"connecting": "ホストに接続中…",
|
||||
"connectingTo": "「{{name}}」に接続中…",
|
||||
"default": "システムのデフォルト",
|
||||
"fableWindow": "Fable",
|
||||
"hostNotFound": "ホストが見つかりません",
|
||||
"invalidSnapshot": "ホストからのアカウントスナップショットが無効です",
|
||||
"loadingAccounts": "アカウントを読み込み中…",
|
||||
"manageAccountsHint": "デスクトップの「設定」→「アカウント」から、アカウントの追加や再認証を行ってください。",
|
||||
"mcpWindow": "MCP",
|
||||
"otherAgents": "その他の Agent",
|
||||
"refreshAccounts": "アカウントを更新",
|
||||
"sessionWindow": "5時間",
|
||||
"switchFailed": "アカウントを切り替えられませんでした",
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
"connecting": "호스트에 연결 중…",
|
||||
"connectingTo": "{{name}}에 연결 중…",
|
||||
"default": "시스템 기본값",
|
||||
"fableWindow": "Fable",
|
||||
"hostNotFound": "호스트를 찾을 수 없습니다.",
|
||||
"invalidSnapshot": "호스트에서 받은 계정 스냅샷이 유효하지 않습니다.",
|
||||
"loadingAccounts": "계정 로드 중…",
|
||||
"manageAccountsHint": "데스크톱의 설정 → 계정에서 계정을 추가하거나 다시 인증하세요.",
|
||||
"mcpWindow": "MCP",
|
||||
"otherAgents": "기타 에이전트",
|
||||
"refreshAccounts": "계정 새로고침",
|
||||
"sessionWindow": "5시간",
|
||||
"switchFailed": "계정을 전환하지 못했습니다.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
"connecting": "Подключение к серверу…",
|
||||
"connectingTo": "Подключение к {{name}}…",
|
||||
"default": "Как в системе",
|
||||
"fableWindow": "Fable",
|
||||
"hostNotFound": "Сервер не найден",
|
||||
"invalidSnapshot": "Некорректные данные учётных записей от сервера",
|
||||
"loadingAccounts": "Загрузка учётных записей…",
|
||||
"manageAccountsHint": "Для добавления учётных записей или повторного входа откройте «Настройки → Учётные записи» в приложении на компьютере.",
|
||||
"mcpWindow": "MCP",
|
||||
"otherAgents": "Другие агенты",
|
||||
"refreshAccounts": "Обновить учётные записи",
|
||||
"sessionWindow": "5 ч",
|
||||
"switchFailed": "Не удалось переключить учётную запись",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
"connecting": "正在連線至主機…",
|
||||
"connectingTo": "正在連線至 {{name}}…",
|
||||
"default": "系統預設",
|
||||
"fableWindow": "Fable",
|
||||
"hostNotFound": "找不到主機",
|
||||
"invalidSnapshot": "主機傳回的帳戶快照無效",
|
||||
"loadingAccounts": "正在載入帳戶…",
|
||||
"manageAccountsHint": "請至桌面版的「設定」→「帳戶」,新增帳戶或重新驗證。",
|
||||
"mcpWindow": "MCP",
|
||||
"otherAgents": "其他代理程式",
|
||||
"refreshAccounts": "重新整理帳戶",
|
||||
"sessionWindow": "5 小時",
|
||||
"switchFailed": "無法切換帳戶",
|
||||
|
||||
@@ -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
-1
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user