diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index b6ca7b795c0..3929d01e000 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -35,7 +35,7 @@ import type { RateLimitRuntimeTarget, RateLimitWindow } from '../../../../shared/rate-limit-types' -import { ProviderIcon, ProviderPanel, barColor } from './tooltip' +import { ProviderIcon, ProviderPanel, barColor, getProviderUsageStatusLabel } from './tooltip' import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { formatWindowLabel } from '@/lib/window-label-formatter' @@ -1024,7 +1024,7 @@ function ProviderSegment({ compact: boolean }): React.JSX.Element { const provider = p?.provider ?? 'claude' - const statusLabel = p?.error && /rate limit/i.test(p.error) ? 'Limited' : 'Unavailable' + const statusLabel = p ? getProviderUsageStatusLabel(p) : '' // Idle / initial load if (!p || p.status === 'idle') { diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index d28c4fae2fd..8378f68449b 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -1,6 +1,23 @@ import { describe, expect, it } from 'vitest' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -import { formatResetCountdown, getWindowSections } from './tooltip' +import { + formatResetCountdown, + getProviderUsageErrorMessage, + getProviderUsageStatusLabel, + getWindowSections +} from './tooltip' + +function provider(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'error', + ...overrides + } +} describe('formatResetCountdown', () => { it('uses natural copy when the reset time has arrived', () => { @@ -13,6 +30,101 @@ describe('formatResetCountdown', () => { }) }) +describe('provider usage error copy', () => { + it('frames Claude auth-shaped usage failures as usage refresh failures', () => { + const p = provider({ error: 'Invalid authentication credentials' }) + + expect(getProviderUsageStatusLabel(p)).toBe('Refresh failed') + expect(getProviderUsageErrorMessage(p)).toBe( + 'Claude usage could not be refreshed. Agent sessions may still be signed in.' + ) + }) + + it('frames provider credential and session failures without showing raw auth details', () => { + const codex = provider({ + provider: 'codex', + error: + 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.' + }) + const gemini = provider({ + provider: 'gemini', + error: 'Gemini CLI credentials not found' + }) + + expect(getProviderUsageStatusLabel(codex)).toBe('Refresh failed') + expect(getProviderUsageErrorMessage(codex)).toBe( + 'Codex usage could not be refreshed. Agent sessions may still be signed in.' + ) + expect(getProviderUsageErrorMessage(gemini)).toBe( + 'Gemini usage could not be refreshed. Agent sessions may still be signed in.' + ) + }) + + it('frames credential-file and login failures as auth-shaped usage failures', () => { + const kimi = provider({ + provider: 'kimi', + error: 'Kimi credentials-file is invalid' + }) + const opencodeGo = provider({ + provider: 'opencode-go', + error: 'Please log in before refreshing usage.' + }) + + expect(getProviderUsageErrorMessage(kimi)).toBe( + 'Kimi usage could not be refreshed. Agent sessions may still be signed in.' + ) + expect(getProviderUsageErrorMessage(opencodeGo)).toBe( + 'OpenCode Go usage could not be refreshed. Agent sessions may still be signed in.' + ) + }) + + it('frames known Codex auth refresh failures as auth-shaped usage failures', () => { + const cases = [ + 'Please reauthenticate before checking usage.', + 'Not logged in.', + 'Token data is not available.', + 'Auth is missing.', + 'Auth tokens are missing.', + 'Auth does not expose access tokens.' + ] + + for (const error of cases) { + expect(getProviderUsageErrorMessage(provider({ provider: 'codex', error }))).toBe( + 'Codex usage could not be refreshed. Agent sessions may still be signed in.' + ) + } + }) + + it('keeps rate-limit failures distinct from refresh failures', () => { + const p = provider({ error: 'Claude usage is rate limited right now.' }) + + expect(getProviderUsageStatusLabel(p)).toBe('Limited') + expect(getProviderUsageErrorMessage(p)).toBe('Claude usage is rate limited right now.') + }) + + it('lets rate-limit copy win when the detail also mentions auth', () => { + const p = provider({ + error: 'Rate limit reached while refreshing OAuth access token.' + }) + + expect(getProviderUsageStatusLabel(p)).toBe('Limited') + expect(getProviderUsageErrorMessage(p)).toBe( + 'Rate limit reached while refreshing OAuth access token.' + ) + }) + + it('keeps generic OAuth and network failures as raw refresh details', () => { + const oauth = provider({ error: 'OAuth API returned 500' }) + const network = provider({ error: 'Network error while refreshing OAuth usage: ECONNRESET' }) + + expect(getProviderUsageStatusLabel(oauth)).toBe('Refresh failed') + expect(getProviderUsageErrorMessage(oauth)).toBe('OAuth API returned 500') + expect(getProviderUsageErrorMessage(network)).toBe( + 'Network error while refreshing OAuth usage: ECONNRESET' + ) + }) +}) + describe('getWindowSections', () => { it('returns buckets as sections when present', () => { const p: ProviderRateLimits = { diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 6e720f88ce4..2078d30bda3 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -63,12 +63,90 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem return } +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' + } + return provider +} + +function isUsageRateLimitError(message: string | null): boolean { + return Boolean(message && /\brate[- ]?limits?\b|\brate[- ]?limited\b/i.test(message)) +} + +const USAGE_AUTH_ERROR_PATTERNS = [ + // Why: "OAuth" can be an upstream route label; only credential/session wording + // should hide raw details behind the softer usage-refresh copy. + /\binvalid (?:authentication )?credentials?\b/i, + /\b(?:no|missing|invalid|expired|stale|unavailable) (?:oauth )?(?:access token|refresh token|token|credentials?|auth(?:entication)? session|auth cookie)\b/i, + /\b(?:access token|refresh token|token|credentials?|auth(?:entication)? session|auth cookie) (?:is |are |was |were |could not be |cannot be |can't be )?(?:missing|unavailable|invalid|expired|stale|used|refreshed|loaded|found)\b/i, + /\bcredentials?[ -]file (?:is |was )?(?:missing|unavailable|invalid|expired|stale)\b/i, + /\b(?:access token|refresh token|token|credentials?|auth(?:entication)? session|auth cookie) not (?:found|available)\b/i, + /\b(?:token data|tokens?) (?:is |are )?not available\b/i, + /\bauth (?:is missing|tokens are missing|does not expose)\b/i, + /\bunauthori[sz]ed\b/i, + /\bunauthenticated\b/i, + /\bplease reauthenticate\b/i, + /\bsign in\b/i, + /\blogged in to another account\b/i, + /\bnot logged in\b/i, + /\blog[ -]?in\b/i, + /\blog(?:ged)? out\b/i +] + +function isUsageAuthError(message: string | null): boolean { + return Boolean(message && USAGE_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message))) +} + +export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + if (isUsageRateLimitError(p.error)) { + return translate('auto.components.status.bar.tooltip.7ad719c4bf', 'Limited') + } + return translate('auto.components.status.bar.tooltip.e740f92596', 'Refresh failed') +} + +export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { + const fallback = translate( + 'auto.components.status.bar.tooltip.2c35eca8d4', + 'Unable to fetch usage' + ) + if (!p.error) { + return fallback + } + if (isUsageRateLimitError(p.error)) { + return p.error + } + if (isUsageAuthError(p.error)) { + const name = getProviderDisplayName(p.provider) + return translate( + 'auto.components.status.bar.tooltip.8418ec448d', + '{{value0}} usage could not be refreshed. Agent sessions may still be signed in.', + { value0: name } + ) + } + return p.error +} + function ErrorMessage({ message, + label, stale = false, inverted = false }: { message: string + label?: string /** When true, prior data is still visible — show a softer "refresh failed" label. */ stale?: boolean inverted?: boolean @@ -84,7 +162,7 @@ function ErrorMessage({ 'auto.components.status.bar.tooltip.a9a318b7a3', 'Refresh failed — showing cached data' ) - : translate('auto.components.status.bar.tooltip.7567cd1c6b', 'Usage unavailable')} + : (label ?? translate('auto.components.status.bar.tooltip.e740f92596', 'Refresh failed'))}
{message}
@@ -171,18 +249,7 @@ export function ProviderPanel({ ) } - const name = - p.provider === 'claude' - ? 'Claude' - : p.provider === 'codex' - ? 'Codex' - : p.provider === 'gemini' - ? 'Gemini' - : p.provider === 'opencode-go' - ? 'OpenCode Go' - : p.provider === 'kimi' - ? 'Kimi' - : p.provider + const name = getProviderDisplayName(p.provider) if (p.status === 'unavailable') { return ( @@ -207,10 +274,8 @@ export function ProviderPanel({
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2ca83e675fa..3afdc97ec78 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2692,7 +2692,10 @@ "2c35eca8d4": "Unable to fetch usage", "1292d4f2ee": "Unavailable", "7567cd1c6b": "Usage unavailable", - "a9a318b7a3": "Refresh failed — showing cached data" + "a9a318b7a3": "Refresh failed — showing cached data", + "7ad719c4bf": "Limited", + "e740f92596": "Refresh failed", + "8418ec448d": "{{value0}} usage could not be refreshed. Agent sessions may still be signed in." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5f5b9b8324e..0447a08236c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2692,7 +2692,10 @@ "2c35eca8d4": "No se puede recuperar el uso", "1292d4f2ee": "Indisponible", "7567cd1c6b": "Uso no disponible", - "a9a318b7a3": "Error al actualizar: se muestran datos almacenados en caché" + "a9a318b7a3": "Error al actualizar: se muestran datos almacenados en caché", + "7ad719c4bf": "Limitado", + "e740f92596": "Error al actualizar", + "8418ec448d": "No se pudo actualizar el uso de {{value0}}. Es posible que las sesiones de agente sigan iniciadas." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0262ef0db28..8f6d659b6a0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2692,7 +2692,10 @@ "2c35eca8d4": "使用状況を取得できません", "1292d4f2ee": "利用不可", "7567cd1c6b": "使用不可", - "a9a318b7a3": "更新に失敗しました - キャッシュされたデータが表示されています" + "a9a318b7a3": "更新に失敗しました - キャッシュされたデータが表示されています", + "7ad719c4bf": "制限中", + "e740f92596": "更新に失敗しました", + "8418ec448d": "{{value0}} の使用状況を更新できませんでした。エージェントセッションは引き続きサインイン済みの場合があります。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce54481c0f1..c96a5033128 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2692,7 +2692,10 @@ "2c35eca8d4": "사용량을 가져올 수 없습니다.", "1292d4f2ee": "없는", "7567cd1c6b": "이용불가", - "a9a318b7a3": "새로 고침 실패 - 캐시된 데이터 표시" + "a9a318b7a3": "새로 고침 실패 - 캐시된 데이터 표시", + "7ad719c4bf": "제한됨", + "e740f92596": "새로 고침 실패", + "8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 78bac899cf7..a2e7de65181 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2692,7 +2692,10 @@ "2c35eca8d4": "无法获取使用情况", "1292d4f2ee": "不可用", "7567cd1c6b": "无法使用", - "a9a318b7a3": "刷新失败——显示缓存数据" + "a9a318b7a3": "刷新失败——显示缓存数据", + "7ad719c4bf": "受限", + "e740f92596": "刷新失败", + "8418ec448d": "无法刷新 {{value0}} 使用情况。代理会话可能仍已登录。" } } },