Fix Claude usage refresh error copy (#5155)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-10 21:40:24 -07:00
committed by GitHub
co-authored by Orca
parent 9ceabb43b6
commit 3d7d9a15cf
8 changed files with 217 additions and 25 deletions
@@ -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') {
@@ -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> = {}): 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 = {
@@ -63,12 +63,90 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem
return <ClaudeIcon size={13} />
}
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'))}
</div>
<div className={detailClass}>{message}</div>
</div>
@@ -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({
</div>
<div className="mt-2">
<ErrorMessage
message={
p.error ??
translate('auto.components.status.bar.tooltip.2c35eca8d4', 'Unable to fetch usage')
}
label={getProviderUsageStatusLabel(p)}
message={getProviderUsageErrorMessage(p)}
inverted={inverted}
/>
</div>
+4 -1
View File
@@ -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."
}
}
},
+4 -1
View File
@@ -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."
}
}
},
+4 -1
View File
@@ -2692,7 +2692,10 @@
"2c35eca8d4": "使用状況を取得できません",
"1292d4f2ee": "利用不可",
"7567cd1c6b": "使用不可",
"a9a318b7a3": "更新に失敗しました - キャッシュされたデータが表示されています"
"a9a318b7a3": "更新に失敗しました - キャッシュされたデータが表示されています",
"7ad719c4bf": "制限中",
"e740f92596": "更新に失敗しました",
"8418ec448d": "{{value0}} の使用状況を更新できませんでした。エージェントセッションは引き続きサインイン済みの場合があります。"
}
}
},
+4 -1
View File
@@ -2692,7 +2692,10 @@
"2c35eca8d4": "사용량을 가져올 수 없습니다.",
"1292d4f2ee": "없는",
"7567cd1c6b": "이용불가",
"a9a318b7a3": "새로 고침 실패 - 캐시된 데이터 표시"
"a9a318b7a3": "새로 고침 실패 - 캐시된 데이터 표시",
"7ad719c4bf": "제한됨",
"e740f92596": "새로 고침 실패",
"8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다."
}
}
},
+4 -1
View File
@@ -2692,7 +2692,10 @@
"2c35eca8d4": "无法获取使用情况",
"1292d4f2ee": "不可用",
"7567cd1c6b": "无法使用",
"a9a318b7a3": "刷新失败——显示缓存数据"
"a9a318b7a3": "刷新失败——显示缓存数据",
"7ad719c4bf": "受限",
"e740f92596": "刷新失败",
"8418ec448d": "无法刷新 {{value0}} 使用情况。代理会话可能仍已登录。"
}
}
},