From b523c5311f16e807bbc2c969dcba0fa56df70231 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:18:12 -0700 Subject: [PATCH] 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 --- src/main/rate-limits/claude-fetcher.test.ts | 248 +++++++++++++++++- src/main/rate-limits/claude-fetcher.ts | 163 +++++++++++- src/main/rate-limits/service.test.ts | 13 + src/main/rate-limits/service.ts | 4 +- .../src/components/status-bar/StatusBar.tsx | 89 +++---- .../status-bar/inline-usage-bars.test.tsx | 53 ++++ 6 files changed, 508 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/status-bar/inline-usage-bars.test.tsx diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 24ee44f7c..6571177a9 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -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-')) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 5f283e58e..b546d21b8 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -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( + location: ManagedCredentialsLocation, + credentialsJson: string, + fn: () => Promise +): Promise { + 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 { + 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 { + 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 { 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 + } } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index c5c389bd0..5af9abca0 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -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() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index af7431bca..5225485ee 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -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) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index eb43c2c56..d08ffca7a 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -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 ( -
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {fableLeft !== null && ( -
-
-
-
- - {fableLeft} - {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } 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 new file mode 100644 index 000000000..0160978be --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -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( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +})