diff --git a/src/main/rate-limits/claude-active-usage-fetch.ts b/src/main/rate-limits/claude-active-usage-fetch.ts index 4b16814f29b..dcd04f6b8ba 100644 --- a/src/main/rate-limits/claude-active-usage-fetch.ts +++ b/src/main/rate-limits/claude-active-usage-fetch.ts @@ -1,6 +1,7 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { readClaudeOAuthCredentials, + type ClaudeOAuthCredentialReadResult, resolveClaudeOAuthCredentialReadOptions } from './claude-oauth-credentials' import { fetchClaudeOAuthUsage } from './claude-oauth-usage-request' @@ -62,15 +63,20 @@ export async function fetchActiveClaudeRateLimits( return await fetchClaudeOAuthUsage(credentials.token, options?.signal) } catch (error) { const { failureKind } = classifyClaudeOAuthUsageError(error) + if (failureKind === 'stale-token' && !canClaudeRenewLogin(credentials, error)) { + return makeClaudeUsageResult('error', 'Sign in again to use this account.', { + ...metadata, + failureKind: 'missing-credentials', + attemptedSources: ['oauth'] + }) + } // Retain both the 429 polling delay and its user-visible cause. const retryAfterMs = error instanceof OAuthUsageError ? error.retryAfterMs : null return makeClaudeUsageResult( 'error', failureKind === 'stale-token' - ? // Claude refreshes on next use; System Default has no managed account to reauthenticate. - metadata.authProvenance === 'system' - ? 'Claude usage updates the next time Claude runs.' - : 'Claude usage has expired. Start Claude in this account to refresh it.' + ? // Idle login's access token lapsed, not the login; Claude refreshes it when it next runs. + 'Claude usage updates the next time Claude runs.' : (failureKind === 'rate-limited' || failureKind === 'missing-scope') && error instanceof OAuthUsageError ? error.message @@ -84,3 +90,16 @@ export async function fetchActiveClaudeRateLimits( ) } } + +// Mirrors Claude Code's own refreshability check; anything else needs a new sign-in. +function canClaudeRenewLogin( + credentials: ClaudeOAuthCredentialReadResult, + error: unknown +): boolean { + const refreshTokenExpiresAt = credentials.refreshTokenExpiresAt + return ( + credentials.hasRefreshableCredentials && + (refreshTokenExpiresAt === undefined || refreshTokenExpiresAt > Date.now()) && + !(error instanceof OAuthUsageError && /revoked/i.test(error.message)) + ) +} diff --git a/src/main/rate-limits/claude-oauth-credentials.ts b/src/main/rate-limits/claude-oauth-credentials.ts index 2a95be7be23..c1fb98c7c9a 100644 --- a/src/main/rate-limits/claude-oauth-credentials.ts +++ b/src/main/rate-limits/claude-oauth-credentials.ts @@ -16,6 +16,8 @@ export type ClaudeOAuthCredentialSource = export type ClaudeOAuthCredentialReadResult = { token: string | null hasRefreshableCredentials: boolean + /** Epoch ms; Claude Code 2.1.x records it beside the refresh token. */ + refreshTokenExpiresAt?: number source: ClaudeOAuthCredentialSource keychainUnavailable?: boolean unavailable?: boolean @@ -30,7 +32,8 @@ const schema = z.object({ claudeAiOauth: z .object({ accessToken: z.string().optional(), - refreshToken: z.string().optional() + refreshToken: z.string().optional(), + refreshTokenExpiresAt: z.unknown().optional() }) .optional() }) @@ -44,6 +47,9 @@ export function parseClaudeOAuthCredentialsJson( return { token: oauth?.accessToken || null, hasRefreshableCredentials: Boolean(oauth?.refreshToken), + ...(typeof oauth?.refreshTokenExpiresAt === 'number' + ? { refreshTokenExpiresAt: oauth.refreshTokenExpiresAt } + : {}), source } } catch { diff --git a/src/main/rate-limits/claude-profile-usage.test.ts b/src/main/rate-limits/claude-profile-usage.test.ts index e718eb5a453..74e8d379ad5 100644 --- a/src/main/rate-limits/claude-profile-usage.test.ts +++ b/src/main/rate-limits/claude-profile-usage.test.ts @@ -75,7 +75,12 @@ it('lets the server decide a locally expired token and never refreshes it', asyn writeFileSync( file, JSON.stringify({ - claudeAiOauth: { accessToken: 'expired-fake', refreshToken: 'must-not-be-used', expiresAt: 1 } + claudeAiOauth: { + accessToken: 'expired-fake', + refreshToken: 'must-not-be-used', + expiresAt: 1, + refreshTokenExpiresAt: 4_102_444_800_000 + } }) ) vi.stubGlobal('fetch', () => { @@ -84,14 +89,16 @@ it('lets the server decide a locally expired token and never refreshes it', asyn calls.usage.mockRejectedValue(new OAuthUsageError('Invalid OAuth token.', 401, true, null)) expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ status: 'error', - error: 'Claude usage has expired. Start Claude in this account to refresh it.', + error: 'Claude usage updates the next time Claude runs.', usageMetadata: { failureKind: 'stale-token' } }) expect(calls.usage.mock.calls.map(([token]) => token)).toEqual(['expired-fake']) const system = systemDefault() writeFileSync( join(system.home, '.credentials.json'), - JSON.stringify({ claudeAiOauth: { accessToken: 'expired-default', expiresAt: 1 } }) + JSON.stringify({ + claudeAiOauth: { accessToken: 'expired-default', refreshToken: 'unused', expiresAt: 1 } + }) ) calls.usage.mockResolvedValueOnce({ provider: 'claude', status: 'ok' }) expect(await fetchActiveClaudeRateLimits(system.options)).toMatchObject({ status: 'ok' }) @@ -100,6 +107,23 @@ it('lets the server decide a locally expired token and never refreshes it', asyn usageMetadata: { failureKind: 'stale-token' } }) }) +it.each([ + ['no refresh token', {}, 'Invalid OAuth token.'], + ['an expired refresh token', { refreshToken: 'fake', refreshTokenExpiresAt: 1 }, 'Invalid'], + ['a revoked refresh token', { refreshToken: 'fake' }, 'OAuth token has been revoked.'] +])('asks a login with %s to sign in again instead of waiting for Claude', async (_, oauth, msg) => { + const f = profile() + writeFileSync( + join(f.home, '.credentials.json'), + JSON.stringify({ claudeAiOauth: { accessToken: 'expired-fake', expiresAt: 1, ...oauth } }) + ) + calls.usage.mockRejectedValue(new OAuthUsageError(msg, 401, true, null)) + expect(await fetchActiveClaudeRateLimits(f.options)).toMatchObject({ + status: 'error', + error: 'Sign in again to use this account.', + usageMetadata: { failureKind: 'missing-credentials' } + }) +}) it('reads only the requested scoped Keychain, with no unsuffixed fallback', async () => { const f = profile() await readClaudeOAuthCredentials({ credentialsFileConfigDir: f.home, keychainConfigDir: f.home }) diff --git a/src/main/rate-limits/service-inactive-claude-cooldown.test.ts b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts index 0ebb7513561..2d6ecfb618b 100644 --- a/src/main/rate-limits/service-inactive-claude-cooldown.test.ts +++ b/src/main/rate-limits/service-inactive-claude-cooldown.test.ts @@ -248,7 +248,11 @@ beforeEach(async () => { writeFileSync( join(path, '.credentials.json'), JSON.stringify({ - claudeAiOauth: { accessToken: `synthetic-${id}`, expiresAt: 1900000000000 } + claudeAiOauth: { + accessToken: `synthetic-${id}`, + refreshToken: `synthetic-refresh-${id}`, + expiresAt: 1900000000000 + } }) ) } @@ -402,6 +406,22 @@ it.each([401, 503])( } ) +it('keeps an idle account last-good usage when its access token expires overnight', async () => { + await service.fetchInactiveClaudeAccountsOnOpen() + const originalAt = cached('A')?.updatedAt + // Why: Orca never refreshes a Claude login, so an idle account's usage 401s until Claude runs in it. + fixture.now += 2 * 60 * 60 * 1000 + fixture.status = 401 + await service.fetchInactiveClaudeAccountsOnOpen() + expect(cached('A')).toMatchObject({ + status: 'error', + updatedAt: originalAt, + session: { usedPercent: 21 }, + weekly: { usedPercent: 11 }, + usageMetadata: { failureKind: 'stale-token', authProvenance: 'profile:A' } + }) +}) + it.each([null, '0', '-5', 'soon'])( 'does not invent a wait for Retry-After %s', async (retryAfter) => { diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 51d20af9b7f..63ada73d33b 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -284,6 +284,49 @@ describe('RateLimitService', () => { } }) + it('does not revive a kept window past its reset when a live post carries only the other one', async () => { + vi.useFakeTimers() + try { + const startedAt = Date.now() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...okProvider('claude', 18), + session: { + usedPercent: 18, + windowMinutes: 300, + resetsAt: startedAt + 3 * 60 * 60 * 1000, + resetDescription: null + } + })) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage updates the next time Claude runs.'), + usageMetadata: { failureKind: 'stale-token' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + await service.refresh() + expect(service.getState().claude?.session?.usedPercent).toBe(18) + + // The kept 5h window resets before the next live post, which carries only the weekly one. + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + service.ingestLiveClaudeRateLimits({ + configDir: null, + fiveHour: null, + sevenDay: { used_percentage: 41 } + }) + + const claude = service.getState().claude + expect(claude?.status).toBe('ok') + expect(claude?.session).toBeNull() + expect(claude?.weekly?.usedPercent).toBe(41) + } finally { + vi.useRealTimers() + } + }) + it('attributes statusline posts to System Default when it inherits CLAUDE_CONFIG_DIR', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 9a1d5d1b329..ecc1729e831 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -7,6 +7,7 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchCursorRateLimits } from './cursor-fetcher' import { fetchOpenCodeGoUsage } from './opencode-go-usage-source-selection' import { asRateLimitWindow, @@ -457,6 +458,183 @@ describe('RateLimitService', () => { } }) + it('keeps last-known usage through an expired access token past the generic stale threshold', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => okProvider('claude', 18)) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage updates the next time Claude runs.'), + usageMetadata: { failureKind: 'stale-token' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + + await service.refresh() + expect(service.getState().claude?.status).toBe('ok') + + // Why: Orca never refreshes a Claude login, so an idle account's usage 401s until Claude runs again. + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + await service.refresh() + + const claude = service.getState().claude + expect(claude?.status).toBe('error') + expect(claude?.usageMetadata?.failureKind).toBe('stale-token') + expect(claude?.session?.usedPercent).toBe(18) + } finally { + vi.useRealTimers() + } + }) + + it('lets a non-Claude expired sign-in age out at the generic stale threshold', async () => { + vi.useFakeTimers() + try { + mockFreshBackgroundProviderFetches() + vi.mocked(fetchClaudeRateLimits).mockImplementation(async () => okProvider('claude', 18)) + vi.mocked(fetchCursorRateLimits) + .mockImplementationOnce(async () => okProvider('cursor', 42)) + .mockImplementation(async () => ({ + ...errorProvider('cursor', 'Cursor sign-in expired — run `cursor-agent login` again'), + usageMetadata: { failureKind: 'stale-token' } + })) + + const service = new RateLimitService() + await service.refresh() + expect(service.getState().cursor?.session?.usedPercent).toBe(42) + + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + await service.refresh() + + const cursor = service.getState().cursor + expect(cursor?.status).toBe('error') + expect(cursor?.usageMetadata?.failureKind).toBe('stale-token') + expect(cursor?.session).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('drops a kept window once its reset passes and keeps the rest', async () => { + vi.useFakeTimers() + try { + const startedAt = Date.now() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...okProvider('claude', 18), + session: { + usedPercent: 18, + windowMinutes: 300, + resetsAt: startedAt + 60 * 60 * 1000, + resetDescription: null + }, + weekly: { + usedPercent: 40, + windowMinutes: 10080, + resetsAt: startedAt + 3 * 24 * 60 * 60 * 1000, + resetDescription: null + } + })) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage updates the next time Claude runs.'), + usageMetadata: { failureKind: 'stale-token' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + await service.refresh() + + const claude = service.getState().claude + expect(claude?.status).toBe('error') + expect(claude?.session).toBeNull() + expect(claude?.weekly?.usedPercent).toBe(40) + expect(claude?.updatedAt).toBe(startedAt) + } finally { + vi.useRealTimers() + } + }) + + it('keeps an outgoing account whose kept snapshot lost only its reset 5h window', async () => { + vi.useFakeTimers() + try { + const startedAt = Date.now() + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...okProvider('claude', 18), + session: { + usedPercent: 18, + windowMinutes: 300, + resetsAt: startedAt + 60 * 60 * 1000, + resetDescription: null + }, + weekly: { + usedPercent: 40, + windowMinutes: 10080, + resetsAt: startedAt + 3 * 24 * 60 * 60 * 1000, + resetDescription: null + } + })) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage updates the next time Claude runs.'), + usageMetadata: { failureKind: 'stale-token' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + service.setInactiveClaudeAccountsResolver(() => [{ id: 'outgoing' }]) + await service.refresh() + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + await service.refresh() + await service.refreshForClaudeAccountChange('outgoing') + + const outgoing = service + .getState() + .inactiveClaudeAccounts.find((row) => row.accountId === 'outgoing') + expect(outgoing?.rateLimits?.session).toBeNull() + expect(outgoing?.rateLimits?.weekly?.usedPercent).toBe(40) + } finally { + vi.useRealTimers() + } + }) + + it('shows the failure instead of a kept snapshot once every window has reset', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockImplementationOnce(async () => ({ + ...okProvider('claude', 18), + session: { + usedPercent: 18, + windowMinutes: 300, + resetsAt: Date.now() + 60 * 60 * 1000, + resetDescription: null + } + })) + .mockImplementation(async () => ({ + ...errorProvider('claude', 'Claude usage updates the next time Claude runs.'), + usageMetadata: { failureKind: 'stale-token' } + })) + mockFreshBackgroundProviderFetches() + + const service = new RateLimitService() + await service.refresh() + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000) + const failedAt = Date.now() + await service.refresh() + + const claude = service.getState().claude + expect(claude?.status).toBe('error') + expect(claude?.session).toBeNull() + expect(claude?.weekly).toBeNull() + expect(claude?.updatedAt).toBe(failedAt) + expect(claude?.error).toBe('Claude usage updates the next time Claude runs.') + } finally { + vi.useRealTimers() + } + }) + it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index aab308478d8..286da7eabee 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -1,5 +1,6 @@ import { consumeCodexRateLimitResetCredit } from '../codex-fetcher' import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts' +import { hasUsageWindows } from './service-result-policy' import { normalizeCodexAccountSelectionTarget, normalizeClaudeAccountSelectionTarget, @@ -144,10 +145,11 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna target?: ClaudeAccountSelectionTarget ): Promise { const nextTarget = normalizeClaudeAccountSelectionTarget(target) - // Seed outgoing switcher data before clearing the active snapshot. + // Seed outgoing switcher data before clearing; a weekly-only kept snapshot still counts. if ( outgoingAccountId && - this.state.claude?.session && + this.state.claude && + hasUsageWindows(this.state.claude) && this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) ) { this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index b795fad5474..c5c837a762b 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -1,4 +1,5 @@ import { RateLimitServiceFetchTargets } from './service-fetch-targets' +import { withoutResetWindows } from './service-result-policy' import { LIVE_CLAUDE_INGEST_DEDUPE_MS, MIN_REFETCH_MS, @@ -141,9 +142,11 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT if (!freshSession && !freshWeekly) { return } + // Kept snapshots can outlive a reset; never relabel a past-reset window as live. + const inherited = previous ? withoutResetWindows(previous, now) : null // Partial statusline payloads leave unreported windows unchanged. - const session = freshSession ?? previous?.session ?? null - const weekly = freshWeekly ?? previous?.weekly ?? null + const session = freshSession ?? inherited?.session ?? null + const weekly = freshWeekly ?? inherited?.weekly ?? null // Stale posts that repeat the surviving window must not refresh live-feed age. const droppedStale = (incomingSession && !freshSession) || (incomingWeekly && !freshWeekly) if ( @@ -170,7 +173,7 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT session, weekly, // Statusline lacks Fable scope; retain the latest OAuth value. - fableWeekly: previous?.fableWeekly ?? null, + fableWeekly: inherited?.fableWeekly ?? null, updatedAt: Date.now(), error: null, status: 'ok', diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts index f18b87c5107..8c746dd11ab 100644 --- a/src/main/rate-limits/service/service-result-policy.ts +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -1,12 +1,39 @@ +import type { RateLimitWindow } from '../../../shared/rate-limit-types' import { RateLimitServiceFetchControl } from './service-fetch-control' import { + LONG_STALE_THRESHOLD_MS, MAX_ACTIVE_FAILURE_STREAK, - RATE_LIMITED_STALE_THRESHOLD_MS, STALE_THRESHOLD_MS, type ActiveRateLimitProvider, type ProviderRateLimits } from './service-types' +export function hasUsageWindows(limits: ProviderRateLimits): boolean { + return Boolean( + limits.session || + limits.weekly || + limits.fableWeekly || + limits.monthly || + (limits.buckets && limits.buckets.length > 0) + ) +} + +// Why: a window past its reset no longer describes the account, and 0% would hide use on another device. +export function withoutResetWindows(limits: ProviderRateLimits, now: number): ProviderRateLimits { + const isCurrent = (window: RateLimitWindow): boolean => + typeof window.resetsAt !== 'number' || window.resetsAt > now + const current = (window: RateLimitWindow | null | undefined): RateLimitWindow | null => + window && isCurrent(window) ? window : null + return { + ...limits, + session: current(limits.session), + weekly: current(limits.weekly), + ...(limits.fableWeekly ? { fableWeekly: current(limits.fableWeekly) } : {}), + ...(limits.monthly ? { monthly: current(limits.monthly) } : {}), + ...(limits.buckets ? { buckets: limits.buckets.filter(isCurrent) } : {}) + } +} + export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl { protected applyStalePolicy( fresh: ProviderRateLimits, @@ -28,29 +55,30 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch return fresh } - const previousHasData = Boolean( - previous?.session || - previous?.weekly || - previous?.fableWeekly || - previous?.monthly || - (previous?.buckets && previous.buckets.length > 0) - ) - - if (!previous || !previousHasData) { + if (!previous) { return fresh } + const failureKind = fresh.usageMetadata?.failureKind + // Only Claude's lapsed token renews itself when its CLI next runs; others may be a real expiry. const staleThresholdMs = - fresh.usageMetadata?.failureKind === 'rate-limited' - ? RATE_LIMITED_STALE_THRESHOLD_MS + failureKind === 'rate-limited' || + (failureKind === 'stale-token' && fresh.provider === 'claude') + ? LONG_STALE_THRESHOLD_MS : STALE_THRESHOLD_MS - if (Date.now() - previous.updatedAt > staleThresholdMs) { + const now = Date.now() + if (now - previous.updatedAt > staleThresholdMs) { + return fresh + } + + const current = withoutResetWindows(previous, now) + if (!hasUsageWindows(current)) { return fresh } // Preserve recent display through transient errors until stale expiry, avoiding empty-bar flashes. return { - ...previous, + ...current, error: fresh.error, status: 'error', usageMetadata: { diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 58782cac2d3..56cb24d84f5 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -108,8 +108,8 @@ export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet window !== null) + // Why: a lapsed token or failed refresh is not a sign-out; ask for a sign-in only when one is confirmed. + const emptyState = + usageWindows.length === 0 && limits.status === 'error' + ? getUsageRosterRowState(limits, false) + : null return (
))} - {usageWindows.length === 0 && limits.status === 'error' ? ( + {emptyState ? ( - {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} + {emptyState.kind === 'sign-in' + ? translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage') + : emptyState.statusLabel} ) : null} diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 6a92efdc7fb..09e084ff78f 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -30,6 +30,9 @@ import { getStatusBarUsageSections } from './status-bar-usage-sections' import { GroupedPoolUsage } from './usage-grouped-pools' import { formatStatusBarSectionLabel, isGroupedUsagePool } from './usage-pool-label' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' +import { useNow } from '@/hooks/use-now' + +const CALM_STALE_USAGE_MAX_AGE_MS = 30 * 60_000 function MiniBar({ usedPct, @@ -243,6 +246,7 @@ export function ProviderSegment({ const statusLabel = p ? getProviderUsageStatusLabel(p) : '' // Why: not a problem; Claude updates its own login the next time it runs. const calm = p ? isClaudeUsageWaitingForClaude(p) : false + const calmClock = useNow(60_000, calm && p?.status === 'error') const tightest = p ? mode === 'compact' ? getUsageHeadlineSection(p) @@ -308,7 +312,9 @@ export function ProviderSegment({ ) } - const isStale = p.status === 'error' && !calm + // Why: calm is honest only while the kept bars are recent; old ones still deserve the warning. + const isStale = + p.status === 'error' && (!calm || calmClock - p.updatedAt > CALM_STALE_USAGE_MAX_AGE_MS) const showBalance = isExtraUsageActive(p) const showGroupedPools = mode === 'verbose' && groupedBuckets.length > 0 && groupedBuckets.length === sections.length diff --git a/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx index 3075f830402..a7ac003d48c 100644 --- a/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx +++ b/src/renderer/src/components/status-bar/claude-status-sign-in.test.tsx @@ -178,6 +178,29 @@ describe('status bar Claude account that needs a sign-in', () => { expect(newRow?.textContent).toContain('Sign in to see usage') }) + it('does not ask an idle account whose usage token expired overnight to sign in', async () => { + inactiveClaudeAccounts = [ + { + accountId: 'new', + isFetching: false, + rateLimits: { + ...claudeProvider, + status: 'error', + error: 'Claude usage updates the next time Claude runs.', + usageMetadata: { + failureKind: 'stale-token', + attemptedSources: ['oauth'], + authProvenance: 'profile:new' + } + } + } + ] + await openAccounts() + const newRow = screen.getAllByText('new@example.com').at(-1)!.closest('[role="menuitem"]') + expect(newRow?.textContent).not.toContain('Sign in to see usage') + expect(newRow?.textContent).toContain('Updates when Claude runs') + }) + it('says why a failed sign-in failed', async () => { reauthenticate.mockRejectedValueOnce(new Error('Claude login exited with code 1.')) await openAccounts() diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index 5829ce53b46..b04831d04cb 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -86,6 +86,53 @@ describe('InlineUsageBars', () => { expect(markup).toContain('Sign in to see usage') }) + // Why: an idle account's access token lapses overnight while its login stays valid; Claude refreshes it on its next run. + it.each(['profile:idle', 'system', 'wsl:Ubuntu:system'])( + 'does not ask a Claude account with an expired usage token (%s) to sign in', + async (authProvenance) => { + const { InlineUsageBars } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + // An older host's wording; the row speaks from the failure kind. + error: 'Claude usage has expired. Start Claude in this account to refresh it.', + status: 'error', + usageMetadata: { + failureKind: 'stale-token', + attemptedSources: ['oauth'], + authProvenance + } + } + + const markup = renderToStaticMarkup() + + expect(markup).not.toContain('Sign in to see usage') + expect(markup).toContain('Updates when Claude runs') + } + ) + + it.each([ + { failureKind: 'missing-credentials' as const, label: 'Sign in to see usage' }, + { failureKind: 'network' as const, label: 'Network issue' } + ])('labels a Claude row with no usage by its $failureKind failure', async (row) => { + const { InlineUsageBars } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: 'Claude usage is unavailable.', + status: 'error', + usageMetadata: { failureKind: row.failureKind } + } + + const markup = renderToStaticMarkup() + + expect(markup).toContain(row.label) + }) + it('derives the collapsed session label from resetsAt (#5399)', async () => { const { InlineUsageBars } = await import('./StatusBar') diff --git a/src/renderer/src/components/status-bar/provider-segment-stale-warning.test.tsx b/src/renderer/src/components/status-bar/provider-segment-stale-warning.test.tsx new file mode 100644 index 00000000000..2135492f46a --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-segment-stale-warning.test.tsx @@ -0,0 +1,47 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce((text, [k, v]) => text.replace(`{{${k}}}`, v), fallback) +})) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +const { ProviderSegment } = await import('./StatusBarProviderSegment') +const NOW = 1_800_000_000_000 + +beforeEach(() => { + vi.useFakeTimers({ now: NOW }) +}) +afterEach(() => { + vi.useRealTimers() +}) + +function lapsedClaude(ageMs: number): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: NOW - ageMs, + error: 'Claude usage updates the next time Claude runs.', + status: 'error', + usageMetadata: { failureKind: 'stale-token' } + } +} + +it.each([ + ['stays calm over recent kept bars', 5 * 60_000, false], + ['warns once kept bars are older than 30 minutes', 2 * 60 * 60_000, true] +])('Claude lapsed-token chip %s', (_, ageMs, warns) => { + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain('40%') + expect(/triangle/i.test(markup)).toBe(warns) +}) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index e7d33e02152..58d002abe98 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -34,6 +34,7 @@ import { ProviderIcon, ProviderPanel } from './tooltip' +import { isClaudeUsageWaitingForClaude } from './usage-error-copy' function provider(overrides: Partial = {}): ProviderRateLimits { return { @@ -245,34 +246,26 @@ describe('provider usage error copy', () => { ) }) - it('uses structured Claude failure kinds before raw auth regexes', () => { - const p = provider({ - error: 'Invalid OAuth token.', - usageMetadata: { - failureKind: 'stale-token', - attemptedSources: ['oauth', 'cli'] - } - }) + // Why: an expired access token is not a signed-out login; Claude refreshes it the next time it runs. + it.each([undefined, 'system', 'wsl:Ubuntu:system', 'profile:idle'])( + 'tells every Claude login (%s) calmly that usage updates when Claude next runs', + (authProvenance) => { + const p = provider({ + error: 'Invalid OAuth token.', + usageMetadata: { + failureKind: 'stale-token', + attemptedSources: ['oauth'], + authProvenance + } + }) - expect(getProviderUsageStatusLabel(p)).toBe('Sign-in expired') - expect(getProviderUsageErrorMessage(p)).toBe( - 'Claude usage has expired. Start Claude in this account to refresh it.' - ) - }) - - it('tells a System Default user calmly that usage updates when Claude next runs', () => { - const p = provider({ - error: 'Invalid OAuth token.', - usageMetadata: { - failureKind: 'stale-token', - attemptedSources: ['oauth'], - authProvenance: 'system' - } - }) - - expect(getProviderUsageStatusLabel(p)).toBe('Updates when Claude runs') - expect(getProviderUsageErrorMessage(p)).toBe('Claude usage updates the next time Claude runs.') - }) + expect(getProviderUsageStatusLabel(p)).toBe('Updates when Claude runs') + expect(getProviderUsageErrorMessage(p)).toBe( + 'Claude usage updates the next time Claude runs.' + ) + expect(isClaudeUsageWaitingForClaude(p)).toBe(true) + } + ) it('keeps refresh copy for failure kinds only a refreshing older host reports', () => { const p = provider({ diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 6f52bd032c6..53a92848c44 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -84,13 +84,9 @@ function getDelegatedCliRefreshProvider( return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null } -/** System Default's login expired on the server: Claude refreshes it the next time it runs. */ +/** An idle login's access token lapsed, not the login: Claude refreshes it the next time it runs. */ export function isClaudeUsageWaitingForClaude(p: ProviderRateLimits): boolean { - return ( - p.provider === 'claude' && - p.usageMetadata?.failureKind === 'stale-token' && - p.usageMetadata.authProvenance === 'system' - ) + return p.provider === 'claude' && p.usageMetadata?.failureKind === 'stale-token' } export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { @@ -116,14 +112,12 @@ export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { 'auto.components.status.bar.tooltip.0d8d7cfe15', 'Waiting for Claude session' ) - // Why: Orca never refreshes a Claude login; only an older host that still does reports the other kinds. case 'stale-token': - return isClaudeUsageWaitingForClaude(p) - ? translate( - 'auto.components.status.bar.tooltip.claude.waiting.label', - 'Updates when Claude runs' - ) - : translate('auto.components.status.bar.tooltip.claude.expired.label', 'Sign-in expired') + return translate( + 'auto.components.status.bar.tooltip.claude.waiting.label', + 'Updates when Claude runs' + ) + // Why: Orca never refreshes a Claude login; only an older host that still does reports these. case 'refreshable-credentials-without-token': case 'delegated-refresh-required': return translate('auto.components.status.bar.tooltip.1804cd8c3f', 'Refreshing sign-in') @@ -213,15 +207,10 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { 'Claude usage will refresh after the live Claude terminal rotates its credentials.' ) case 'stale-token': - return isClaudeUsageWaitingForClaude(p) - ? translate( - 'auto.components.status.bar.tooltip.claude.waiting.message', - 'Claude usage updates the next time Claude runs.' - ) - : translate( - 'auto.components.status.bar.tooltip.claude.expired.message', - 'Claude usage has expired. Start Claude in this account to refresh it.' - ) + return translate( + 'auto.components.status.bar.tooltip.claude.waiting.message', + 'Claude usage updates the next time Claude runs.' + ) case 'refreshable-credentials-without-token': case 'delegated-refresh-required': return translate( diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts index 0720272a6d7..0b943525c2d 100644 --- a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts @@ -41,7 +41,7 @@ describe('getUsageRosterRowState', () => { }), false ) - ).toEqual({ kind: 'error', statusLabel: 'Sign-in expired' }) + ).toEqual({ kind: 'error', statusLabel: 'Updates when Claude runs' }) expect( getUsageRosterRowState( provider({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ae5a04424db..1651df4c12e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4559,10 +4559,6 @@ "waiting": { "label": "Updates when Claude runs", "message": "Claude usage updates the next time Claude runs." - }, - "expired": { - "label": "Sign-in expired", - "message": "Claude usage has expired. Start Claude in this account to refresh it." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ca0e0948992..d673a51865f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4236,10 +4236,6 @@ "waiting": { "label": "Se actualiza cuando Claude se ejecuta", "message": "El uso de Claude se actualizará la próxima vez que ejecutes Claude." - }, - "expired": { - "label": "Inicio de sesión caducado", - "message": "El uso de Claude ha caducado. Inicia Claude en esta cuenta para actualizarlo." } }, "remoteOwnerNotConfigured": "No configurado en {{server}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d3a244b5f69..0f2a248f330 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4385,10 +4385,6 @@ "waiting": { "label": "Se met à jour quand Claude s'exécute", "message": "L'utilisation de Claude se met à jour la prochaine fois que Claude s'exécute." - }, - "expired": { - "label": "Connexion expirée", - "message": "L'utilisation de Claude a expiré. Démarrez Claude dans ce compte pour l'actualiser." } }, "remoteOwnerNotConfigured": "Non configuré sur {{server}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index afc1c89c075..f0724a0e102 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4221,10 +4221,6 @@ "waiting": { "label": "Claude の実行時に更新", "message": "Claude の使用状況は、次に Claude を実行したときに更新されます。" - }, - "expired": { - "label": "サインインの有効期限切れ", - "message": "Claude の使用状況が期限切れです。このアカウントで Claude を起動して更新してください。" } }, "remoteOwnerNotConfigured": "{{server}} では未構成", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb2e31ff95a..1c8f84b97fb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4221,10 +4221,6 @@ "waiting": { "label": "Claude 실행 시 업데이트됨", "message": "Claude 사용량은 다음에 Claude를 실행할 때 업데이트됩니다." - }, - "expired": { - "label": "로그인 만료", - "message": "Claude 사용량이 만료되었습니다. 이 계정에서 Claude를 시작하면 사용량이 갱신됩니다." } }, "remoteOwnerNotConfigured": "{{server}}에 구성되지 않음", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 8d8f43be0b5..9ede54f51f5 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -2519,10 +2519,6 @@ "waiting": { "label": "Atualiza quando o Claude é executado", "message": "O uso do Claude será atualizado na próxima vez que ele for executado." - }, - "expired": { - "label": "Login expirado", - "message": "Os dados de uso do Claude expiraram. Inicie o Claude nesta conta para atualizá-los." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d2399bff353..04caaafd50c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4221,10 +4221,6 @@ "waiting": { "label": "运行 Claude 时更新", "message": "Claude 用量将在下次运行 Claude 时更新。" - }, - "expired": { - "label": "登录已过期", - "message": "Claude 用量已过期。请在此账户中启动 Claude 以刷新。" } }, "remoteOwnerNotConfigured": "未在 {{server}} 上配置",