fix: show Claude Fable usage in account switcher
Show Fable usage in inactive Claude account switcher previews by supplementing OAuth usage with scoped Claude CLI /usage data, with macOS scoped Keychain safety checks and compact preview layout coverage. Validation: - pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx src/renderer/src/components/status-bar/tooltip.test.ts - pnpm run typecheck - pnpm exec oxlint src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/StatusBar.tsx src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - git diff --check HEAD~1..HEAD
This commit is contained in:
parent
4caea00f16
commit
b523c5311f
|
|
@ -1,14 +1,17 @@
|
|||
/* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchViaPty } from './claude-pty'
|
||||
import {
|
||||
deleteActiveClaudeKeychainCredentialsStrict,
|
||||
readActiveClaudeKeychainCredentials,
|
||||
readActiveClaudeKeychainCredentialsStrict,
|
||||
readManagedClaudeKeychainCredentials
|
||||
readManagedClaudeKeychainCredentials,
|
||||
writeActiveClaudeKeychainCredentials,
|
||||
writeManagedClaudeKeychainCredentials
|
||||
} from '../claude-accounts/keychain'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
|
||||
|
|
@ -46,9 +49,12 @@ vi.mock('./claude-pty', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../claude-accounts/keychain', () => ({
|
||||
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readActiveClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readManagedClaudeKeychainCredentials: vi.fn()
|
||||
readManagedClaudeKeychainCredentials: vi.fn(),
|
||||
writeActiveClaudeKeychainCredentials: vi.fn(),
|
||||
writeManagedClaudeKeychainCredentials: vi.fn()
|
||||
}))
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
|
@ -71,6 +77,9 @@ describe('fetchClaudeRateLimits', () => {
|
|||
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null)
|
||||
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null)
|
||||
vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue()
|
||||
vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue()
|
||||
vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue()
|
||||
appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test')
|
||||
resolveProxyMock.mockResolvedValue('DIRECT')
|
||||
netFetchMock.mockResolvedValue(
|
||||
|
|
@ -948,6 +957,239 @@ describe('fetchClaudeRateLimits', () => {
|
|||
expect(readFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => {
|
||||
setPlatform('linux')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
appGetPathMock.mockReturnValue(tempDir)
|
||||
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
|
||||
mkdirSync(ownedAuthPath, { recursive: true })
|
||||
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
|
||||
const canonicalAuthPath = realpathSync(ownedAuthPath)
|
||||
writeFileSync(
|
||||
join(ownedAuthPath, '.credentials.json'),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'inactive-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
vi.mocked(fetchViaPty).mockResolvedValueOnce({
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
fableWeekly: {
|
||||
usedPercent: 42,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: '2d'
|
||||
},
|
||||
updatedAt: 1,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
await expect(
|
||||
fetchManagedAccountUsage(
|
||||
{ id: 'account-1', managedAuthPath: ownedAuthPath },
|
||||
{ allowUsagePanelSupplement: true }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 },
|
||||
weekly: { usedPercent: 34 },
|
||||
fableWeekly: { usedPercent: 42, resetDescription: '2d' }
|
||||
})
|
||||
expect(fetchViaPty).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({
|
||||
configDir: canonicalAuthPath,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath },
|
||||
provenance: 'managed:account-1:inactive-preview',
|
||||
stripAuthEnv: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => {
|
||||
setPlatform('darwin')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
appGetPathMock.mockReturnValue(tempDir)
|
||||
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
|
||||
const credentialsJson = JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'managed-keychain-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
mkdirSync(ownedAuthPath, { recursive: true })
|
||||
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
|
||||
const canonicalAuthPath = realpathSync(ownedAuthPath)
|
||||
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson)
|
||||
vi.mocked(fetchViaPty).mockResolvedValueOnce({
|
||||
provider: 'claude',
|
||||
session: {
|
||||
usedPercent: 12,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 34,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
fableWeekly: {
|
||||
usedPercent: 58,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: '3d'
|
||||
},
|
||||
updatedAt: 1,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
const result = await fetchManagedAccountUsage(
|
||||
{ id: 'account-1', managedAuthPath: ownedAuthPath },
|
||||
{ allowUsagePanelSupplement: true }
|
||||
)
|
||||
|
||||
expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' })
|
||||
expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith(
|
||||
credentialsJson,
|
||||
canonicalAuthPath
|
||||
)
|
||||
expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath)
|
||||
})
|
||||
|
||||
it('stages refreshed macOS inactive account credentials before Fable preview', async () => {
|
||||
setPlatform('darwin')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
appGetPathMock.mockReturnValue(tempDir)
|
||||
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
|
||||
const staleCredentialsJson = JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'stale-access',
|
||||
refreshToken: 'stale-refresh',
|
||||
expiresAt: Date.now() - 60_000
|
||||
}
|
||||
})
|
||||
mkdirSync(ownedAuthPath, { recursive: true })
|
||||
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
|
||||
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'fresh-access',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'fresh-refresh'
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
|
||||
{
|
||||
status: 200
|
||||
}
|
||||
)
|
||||
)
|
||||
vi.mocked(fetchViaPty).mockResolvedValueOnce({
|
||||
provider: 'claude',
|
||||
session: {
|
||||
usedPercent: 12,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 34,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
fableWeekly: {
|
||||
usedPercent: 58,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: '3d'
|
||||
},
|
||||
updatedAt: 1,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
const result = await fetchManagedAccountUsage(
|
||||
{ id: 'account-1', managedAuthPath: ownedAuthPath },
|
||||
{ allowUsagePanelSupplement: true }
|
||||
)
|
||||
|
||||
const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0]
|
||||
expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' })
|
||||
expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'fresh-refresh'
|
||||
}
|
||||
})
|
||||
expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith(
|
||||
'account-1',
|
||||
stagedCredentialsJson
|
||||
)
|
||||
})
|
||||
|
||||
it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => {
|
||||
setPlatform('darwin')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
appGetPathMock.mockReturnValue(tempDir)
|
||||
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
|
||||
mkdirSync(ownedAuthPath, { recursive: true })
|
||||
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
|
||||
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'managed-keychain-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(fetchViaPty).mockResolvedValueOnce({
|
||||
provider: 'claude',
|
||||
session: {
|
||||
usedPercent: 91,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 3,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
fableWeekly: {
|
||||
usedPercent: 58,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: '3d'
|
||||
},
|
||||
updatedAt: 1,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
const result = await fetchManagedAccountUsage(
|
||||
{ id: 'account-1', managedAuthPath: ownedAuthPath },
|
||||
{ allowUsagePanelSupplement: true }
|
||||
)
|
||||
|
||||
expect(result.fableWeekly).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes and persists an expiring inactive account before fetching usage', async () => {
|
||||
setPlatform('linux')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
|
|||
import { fetchViaPty } from './claude-pty'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import {
|
||||
deleteActiveClaudeKeychainCredentialsStrict,
|
||||
readActiveClaudeKeychainCredentials,
|
||||
readActiveClaudeKeychainCredentialsStrict,
|
||||
readManagedClaudeKeychainCredentials
|
||||
readManagedClaudeKeychainCredentials,
|
||||
writeActiveClaudeKeychainCredentials
|
||||
} from '../claude-accounts/keychain'
|
||||
import {
|
||||
readClaudeManagedAuthFile,
|
||||
|
|
@ -885,7 +887,7 @@ export type InactiveClaudeAccountInfo = {
|
|||
}
|
||||
|
||||
type ManagedCredentialsLocation =
|
||||
| { kind: 'keychain'; accountId: string }
|
||||
| { kind: 'keychain'; accountId: string; managedAuthPath: string }
|
||||
| { kind: 'file'; managedAuthPath: string }
|
||||
|
||||
// Why: resolves where an inactive account's credentials live without
|
||||
|
|
@ -907,7 +909,7 @@ function resolveManagedCredentialsLocation(
|
|||
// macOS stores host managed credentials in the Keychain; everything else
|
||||
// (and WSL, handled above) stores them as a file under the managed dir.
|
||||
if (process.platform === 'darwin') {
|
||||
return { kind: 'keychain', accountId: account.id }
|
||||
return { kind: 'keychain', accountId: account.id, managedAuthPath }
|
||||
}
|
||||
return { kind: 'file', managedAuthPath }
|
||||
}
|
||||
|
|
@ -966,12 +968,127 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchManagedAccountUsage(
|
||||
function getManagedUsagePanelAuthPreparation(
|
||||
account: InactiveClaudeAccountInfo,
|
||||
location: ManagedCredentialsLocation
|
||||
): ClaudeRuntimeAuthPreparation | null {
|
||||
if (process.platform === 'win32') {
|
||||
return null
|
||||
}
|
||||
if (account.managedAuthRuntime === 'wsl') {
|
||||
if (!account.wslLinuxAuthPath || !account.wslDistro) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
configDir: location.managedAuthPath,
|
||||
runtime: 'wsl',
|
||||
wslDistro: account.wslDistro,
|
||||
wslLinuxConfigDir: account.wslLinuxAuthPath,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath },
|
||||
stripAuthEnv: true,
|
||||
provenance: `managed:${account.id}:inactive-preview`
|
||||
}
|
||||
}
|
||||
return {
|
||||
configDir: location.managedAuthPath,
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath },
|
||||
stripAuthEnv: true,
|
||||
provenance: `managed:${account.id}:inactive-preview`
|
||||
}
|
||||
}
|
||||
|
||||
function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean {
|
||||
return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1)
|
||||
}
|
||||
|
||||
function canTrustManagedUsagePanelSupplement(
|
||||
oauthLimits: ProviderRateLimits,
|
||||
cliLimits: ProviderRateLimits,
|
||||
options: { requireMatchingOAuthWindow: boolean }
|
||||
): boolean {
|
||||
if (!options.requireMatchingOAuthWindow) {
|
||||
return true
|
||||
}
|
||||
const sharedWindowMatches = [
|
||||
oauthLimits.session && cliLimits.session
|
||||
? windowsAgree(oauthLimits.session, cliLimits.session)
|
||||
: null,
|
||||
oauthLimits.weekly && cliLimits.weekly
|
||||
? windowsAgree(oauthLimits.weekly, cliLimits.weekly)
|
||||
: null
|
||||
].filter((match): match is boolean => match !== null)
|
||||
// Why: macOS inactive previews temporarily stage managed credentials in a
|
||||
// scoped Keychain item. If an older Claude build ignores scoped Keychains,
|
||||
// matching OAuth windows prevent active-account Fable data from leaking in.
|
||||
return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean)
|
||||
}
|
||||
|
||||
async function withManagedPreviewKeychainCredentials<T>(
|
||||
location: ManagedCredentialsLocation,
|
||||
credentialsJson: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (location.kind !== 'keychain') {
|
||||
return fn()
|
||||
}
|
||||
await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath)
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function readStagedManagedPreviewCredentials(
|
||||
location: ManagedCredentialsLocation
|
||||
): Promise<string | null> {
|
||||
if (location.kind !== 'keychain') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManagedUsagePanelSupplement(input: {
|
||||
account: InactiveClaudeAccountInfo
|
||||
location: ManagedCredentialsLocation
|
||||
credentialsJson: string
|
||||
oauthLimits: ProviderRateLimits
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
|
||||
if (!authPreparation) {
|
||||
return null
|
||||
}
|
||||
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
|
||||
const cliLimits = await fetchViaPty({ authPreparation })
|
||||
if (
|
||||
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
|
||||
requireMatchingOAuthWindow: input.location.kind === 'keychain'
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const refreshedCredentials = await readStagedManagedPreviewCredentials(input.location)
|
||||
if (refreshedCredentials && refreshedCredentials !== input.credentialsJson) {
|
||||
await writeManagedCredentialsJson(input.location, refreshedCredentials)
|
||||
}
|
||||
return cliLimits
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchManagedAccountUsage(
|
||||
account: InactiveClaudeAccountInfo,
|
||||
options: { allowUsagePanelSupplement?: boolean } = {}
|
||||
): Promise<ProviderRateLimits> {
|
||||
const location = resolveManagedCredentialsLocation(account)
|
||||
const credentialsJson = location ? await readManagedCredentialsJson(location) : null
|
||||
if (!credentialsJson) {
|
||||
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
|
||||
if (!location || !credentialsJson) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
|
|
@ -988,7 +1105,7 @@ export async function fetchManagedAccountUsage(
|
|||
// single-use refresh tokens fresh so a later switch-in never materializes a
|
||||
// stale token. Persistence failure is non-fatal: we still try the fetch.
|
||||
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
|
||||
if (location && isOauthTokenExpiring(credentialsJson)) {
|
||||
if (isOauthTokenExpiring(credentialsJson)) {
|
||||
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
|
||||
if (refreshed) {
|
||||
try {
|
||||
|
|
@ -997,6 +1114,7 @@ export async function fetchManagedAccountUsage(
|
|||
// Keep going with the refreshed token in memory even if the write
|
||||
// failed; worst case the next poll refreshes again.
|
||||
}
|
||||
credentialsJson = refreshed
|
||||
token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token
|
||||
}
|
||||
}
|
||||
|
|
@ -1013,7 +1131,32 @@ export async function fetchManagedAccountUsage(
|
|||
}
|
||||
|
||||
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
|
||||
// path materializes credentials via ClaudeRuntimeAuthService, which would
|
||||
// interfere with the active account's auth state.
|
||||
return fetchViaOAuth(token)
|
||||
// path is used only as a supplement after OAuth succeeds, and it points
|
||||
// directly at the managed account's isolated config so selection is unchanged.
|
||||
const oauthLimits = await fetchViaOAuth(token)
|
||||
if (
|
||||
!canSupplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
authPreparation: undefined,
|
||||
allowUsagePanelSupplement: options.allowUsagePanelSupplement === true
|
||||
})
|
||||
) {
|
||||
return oauthLimits
|
||||
}
|
||||
try {
|
||||
const cliLimits = await fetchManagedUsagePanelSupplement({
|
||||
account,
|
||||
location,
|
||||
credentialsJson,
|
||||
oauthLimits
|
||||
})
|
||||
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(
|
||||
undefined,
|
||||
parseOAuthCredentialsJson(credentialsJson, 'credentials-file'),
|
||||
err
|
||||
)
|
||||
return oauthLimits
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -621,6 +621,19 @@ describe('RateLimitService', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('allows usage-panel Fable supplements for inactive Claude account previews', async () => {
|
||||
const service = new RateLimitService()
|
||||
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
|
||||
service.setInactiveClaudeAccountsResolver(() => [account])
|
||||
vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now()))
|
||||
|
||||
await service.fetchInactiveClaudeAccountsOnOpen()
|
||||
|
||||
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
})
|
||||
|
||||
it('does not start overlapping inactive Codex preview fetches', async () => {
|
||||
const service = new RateLimitService()
|
||||
const accountFetch = deferred<ProviderRateLimits>()
|
||||
|
|
|
|||
|
|
@ -382,7 +382,9 @@ export class RateLimitService {
|
|||
continue
|
||||
}
|
||||
try {
|
||||
const fresh = await fetchManagedAccountUsage(account)
|
||||
const fresh = await fetchManagedAccountUsage(account, {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement()
|
||||
})
|
||||
if (
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
|
|
|
|||
|
|
@ -901,70 +901,63 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element {
|
|||
// Inline usage bars (compact bars for inactive accounts in the switcher)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InlineUsageBars({
|
||||
export function InlineUsageBars({
|
||||
limits,
|
||||
isFetching
|
||||
}: {
|
||||
limits: ProviderRateLimits
|
||||
isFetching: boolean
|
||||
}): React.JSX.Element {
|
||||
const sessionLeft = limits.session
|
||||
? Math.max(0, Math.round(100 - limits.session.usedPercent))
|
||||
: null
|
||||
const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null
|
||||
const fableLeft = limits.fableWeekly
|
||||
? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent))
|
||||
: null
|
||||
const usageWindows = [
|
||||
limits.session
|
||||
? {
|
||||
key: 'session',
|
||||
left: Math.max(0, Math.round(100 - limits.session.usedPercent)),
|
||||
label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')
|
||||
}
|
||||
: null,
|
||||
limits.weekly
|
||||
? {
|
||||
key: 'weekly',
|
||||
left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)),
|
||||
label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')
|
||||
}
|
||||
: null,
|
||||
limits.fableWeekly
|
||||
? {
|
||||
key: 'fableWeekly',
|
||||
left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)),
|
||||
label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')
|
||||
}
|
||||
: null
|
||||
].filter((window): window is { key: string; left: number; label: string } => window !== null)
|
||||
|
||||
return (
|
||||
<div className={`flex w-full items-center gap-2 ${isFetching ? 'animate-pulse' : ''}`}>
|
||||
{sessionLeft !== null && (
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`grid w-full items-center gap-1.5 ${isFetching ? 'animate-pulse' : ''}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.max(1, usageWindows.length)}, minmax(0, 1fr))`
|
||||
}}
|
||||
>
|
||||
{usageWindows.map((window) => (
|
||||
<div key={window.key} className="flex min-w-0 items-center gap-1">
|
||||
<div className="h-[4px] min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${barColor(sessionLeft)}`}
|
||||
style={{ width: `${sessionLeft}%` }}
|
||||
className={`h-full rounded-full ${barColor(window.left)}`}
|
||||
style={{ width: `${window.left}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{sessionLeft}
|
||||
{translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')}
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{window.left}
|
||||
{window.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{weeklyLeft !== null && (
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${barColor(weeklyLeft)}`}
|
||||
style={{ width: `${weeklyLeft}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{weeklyLeft}
|
||||
{translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fableLeft !== null && (
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full ${barColor(fableLeft)}`}
|
||||
style={{ width: `${fableLeft}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
|
||||
{fableLeft}
|
||||
{translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && (
|
||||
))}
|
||||
{usageWindows.length === 0 && limits.status === 'error' ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, 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) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
AgentIcon: () => null
|
||||
}))
|
||||
|
||||
function claudeLimits(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: {
|
||||
usedPercent: 32,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 16,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
fableWeekly: {
|
||||
usedPercent: 42,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
}
|
||||
|
||||
describe('InlineUsageBars', () => {
|
||||
it('renders Claude Fable usage in inactive account preview rows', async () => {
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<InlineUsageBars limits={claudeLimits()} isFetching={false} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('68% 5h')
|
||||
expect(markup).toContain('84% wk')
|
||||
expect(markup).toContain('58% Fable')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue