diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index bf1a7490f41..7a43e13f225 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -63,6 +63,7 @@ import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from import { AgentIcon } from '@/lib/agent-catalog' import { formatWindowLabel } from '@/lib/window-label-formatter' import { UsageRosterPanel } from './UsageRosterPanel' +import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' @@ -86,7 +87,6 @@ import { } from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' import { - getDisplayedUsagePercentage, normalizeUsagePercentageDisplay, type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' @@ -749,7 +749,7 @@ export function ClaudeSwitcherMenu({ void loadAccounts().catch((error) => { console.error('Failed to load Claude accounts for status bar:', error) }) - }, [loadAccounts, open, claudeAccountSyncKey]) + }, [loadAccounts, claudeAccountSyncKey]) const handleOpenChange = useCallback((nextOpen: boolean): void => { setOpen(nextOpen) @@ -1102,7 +1102,8 @@ function WindowLabel({ }): React.JSX.Element { return ( - {getDisplayedUsagePercentage(w.usedPercent, display)}%{showLabel ? ` ${label}` : ''} + {formatUsagePercentageLabel(w.usedPercent, display)} + {showLabel ? ` ${label}` : ''} ) } @@ -1208,20 +1209,18 @@ export function ProviderSegment({ if (p.buckets && p.buckets.length > 0) { const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.name)) - // Roster shows the tightest bucket (highest % used) to stay compact. - const tightestBucket = - visibleBuckets.length > 0 - ? visibleBuckets.reduce((a, b) => (b.usedPercent > a.usedPercent ? b : a)) - : null return ( - {tightestBucket ? ( - - {tightestBucket.name} {getDisplayedUsagePercentage(tightestBucket.usedPercent, display)} - % - - ) : p.session ? ( + {visibleBuckets.map((bucket, index) => ( + + {index > 0 ? · : null} + + {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} + + + ))} + {visibleBuckets.length === 0 && p.session ? ( w !== null) - // Roster: show only the tightest window (highest % used) with a threshold-colored - // number so the footer stays compact; the dropdown still lists every window. - const tightestWindow = - visibleWindows.length > 0 - ? visibleWindows.reduce((a, b) => (b.window.usedPercent > a.window.usedPercent ? b : a)) - : null - return ( - {tightestWindow && ( - - )} + {visibleWindows.map((window, index) => ( + + {index > 0 ? · : null} + + + ))} {isStale && } ) @@ -1388,14 +1383,12 @@ export function CodexSwitcherMenu({ }, [accountsExpanded]) useEffect(() => { - // Why: the status bar keeps its own lightweight account snapshot for the - // dropdown. Settings account actions mutate the main-process store outside - // this component, so we refresh when the persisted account roster changes - // or when the menu opens instead of leaving a stale account list mounted. + // Why: the roster mounts this switcher on demand, while the sync key covers + // account mutations without refetching again when its submenu opens. void loadAccounts().catch((error) => { console.error('Failed to load Codex accounts for status bar:', error) }) - }, [loadAccounts, open, codexAccountSyncKey]) + }, [loadAccounts, codexAccountSyncKey]) const handleSelectAccount = async ( accountId: string | null, @@ -2160,8 +2153,12 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele openSettingsPage() } const handleOpenProviderAccounts = (provider: ProviderRateLimits['provider']): void => { + const sectionId = getUsageProviderAccountsSectionId(provider) + if (!sectionId) { + return + } setUsageMenuOpen(false) - openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: `accounts-${provider}` }) + openSettingsTarget({ pane: 'accounts', repoId: null, sectionId }) openSettingsPage() } @@ -2237,6 +2234,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele onRefresh={handleRefresh} onOpenProvider={handleOpenProviderAccounts} onSignIn={handleOpenProviderAccounts} + canSignIn={(provider) => getUsageProviderAccountsSectionId(provider) !== null} onManageAccounts={handleManageAccounts} onUsageDetails={handleUsageDetails} renderRow={(p, rowNode) => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx new file mode 100644 index 00000000000..638a130cf2c --- /dev/null +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -0,0 +1,38 @@ +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', () => ({ + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: ({ agent }: { agent: string }) => +})) + +import { UsageRow } from './UsageRosterPanel' + +const signedOutCodex: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: 0, + error: 'ChatGPT authentication required to read rate limits', + status: 'error' +} + +describe('UsageRow', () => { + it('renders sign-in as row copy instead of nesting an interactive button', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('not signed in') + expect(markup).toContain('Sign in') + expect(markup).not.toContain(' void + state: UsageRosterRowState + showSignInAction: boolean }): React.JSX.Element { const sections = usedSections(p) - const signedOut = sections.length === 0 + const hasUsage = sections.length > 0 const name = getProviderDisplayName(p.provider) const plan = formatPlanLabel(p.planType) - const reset = signedOut ? null : soonestResetLabel(sections) + const reset = hasUsage ? soonestResetLabel(sections) : null return ( @@ -79,30 +83,22 @@ export function UsageRow({ {name} {plan ? · {plan} : null} - {signedOut ? ( + {!hasUsage ? ( <> - {translate( - 'auto.components.status.bar.UsageRosterPanel.notSignedIn', - 'not signed in' - )} + {state.statusLabel} - { - e.stopPropagation() - onSignIn(p.provider) - }} - className="ml-auto shrink-0 rounded-md border border-border bg-secondary px-2.5 py-0.5 text-xs text-foreground hover:bg-accent" - > - {translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')} - + {showSignInAction ? ( + + {translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')} + + ) : null} > ) : reset ? ( {reset} ) : null} - {signedOut ? null : ( + {hasUsage ? ( {sections.map((s) => { const used = clampUsedPercent(s.window.usedPercent) @@ -123,7 +119,7 @@ export function UsageRow({ ) })} - )} + ) : null} ) } @@ -140,6 +136,7 @@ export function UsageRosterPanel({ onRefresh, onOpenProvider, onSignIn, + canSignIn, onManageAccounts, onUsageDetails, renderRow @@ -150,6 +147,7 @@ export function UsageRosterPanel({ onRefresh: () => void onOpenProvider: (provider: ProviderId) => void onSignIn: (provider: ProviderId) => void + canSignIn: (provider: ProviderId) => boolean onManageAccounts: () => void onUsageDetails: () => void // Lets the host wrap a provider's row in a richer control (e.g. the @@ -172,64 +170,71 @@ export function UsageRosterPanel({ {translate('auto.components.status.bar.UsageRosterPanel.allAgents', 'all agents')} - { + event.preventDefault() + onRefresh() + }} aria-label={translate( 'auto.components.status.bar.StatusBar.3325d996cb', 'Refresh rate limits' )} - className="rounded p-0.5 hover:bg-accent hover:text-foreground" + className="size-5 justify-center p-0" > - + {sorted.map((p) => { - const rowNode = + const state = getUsageRosterRowState(p, usedSections(p).length > 0) + const showSignInAction = state.kind === 'sign-in' && canSignIn(p.provider) + const rowNode = ( + + ) + if (showSignInAction) { + return ( + onSignIn(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" + > + {rowNode} + + ) + } const custom = renderRow?.(p, rowNode) if (custom) { return {custom} } return ( - onOpenProvider(p.provider)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onOpenProvider(p.provider) - } - }} - className="flex cursor-pointer items-center px-3.5 py-2.5 hover:bg-accent/60" + onSelect={() => onOpenProvider(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" > {rowNode} - + ) })} - {translate( 'auto.components.status.bar.UsageRosterPanel.usageDetails', 'Usage details & history' )} - - + {translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')} - + ) } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts new file mode 100644 index 00000000000..cbcfcda0492 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' + +describe('getUsageProviderAccountsSectionId', () => { + it('routes providers only to settings sections that exist', () => { + expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') + expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') + expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') + expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') + expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') + }) + + it('does not invent an Accounts section for CLI-owned Kimi credentials', () => { + expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts new file mode 100644 index 00000000000..59ea7104249 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -0,0 +1,25 @@ +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +export function getUsageProviderAccountsSectionId( + provider: ProviderRateLimits['provider'] +): string | null { + switch (provider) { + case 'claude': + return 'accounts-claude' + case 'codex': + return 'accounts-codex' + case 'gemini': + case 'antigravity': + // Why: Antigravity usage currently shares Gemini's OAuth configuration. + return 'accounts-gemini' + case 'opencode-go': + return 'accounts-opencode-go' + case 'minimax': + return 'accounts-minimax' + case 'grok': + return 'accounts-grok' + case 'kimi': + // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + return null + } +} 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 new file mode 100644 index 00000000000..b63a9a3ad5b --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getUsageRosterRowState } from './usage-roster-row-state' + +function provider(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'ok', + ...overrides + } +} + +describe('getUsageRosterRowState', () => { + it('keeps fetching providers in a loading state instead of calling them signed out', () => { + expect(getUsageRosterRowState(provider({ status: 'fetching' }), false)).toEqual({ + kind: 'loading', + statusLabel: 'Loading usage…' + }) + }) + + it('preserves transient Claude failure copy instead of offering sign-in', () => { + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'OAuth token is stale', + usageMetadata: { failureKind: 'stale-token' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refreshing sign-in' }) + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'network unavailable', + usageMetadata: { failureKind: 'network' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Network issue' }) + }) + + it('offers sign-in only for confirmed signed-out failures', () => { + expect( + getUsageRosterRowState( + provider({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + expect( + getUsageRosterRowState( + provider({ + provider: 'codex', + status: 'error', + error: 'ChatGPT authentication required to read rate limits' + }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + }) + + it('does not turn an expired CLI-owned Kimi token into a sign-in action', () => { + expect( + getUsageRosterRowState( + provider({ + provider: 'kimi', + status: 'error', + error: 'Kimi token expired — open Kimi to refresh' + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refresh failed' }) + }) + + it('distinguishes unavailable and empty successful responses', () => { + expect( + getUsageRosterRowState( + provider({ status: 'unavailable', error: 'Claude CLI not found' }), + false + ) + ).toEqual({ kind: 'unavailable', statusLabel: 'Usage unavailable' }) + expect(getUsageRosterRowState(provider(), false)).toEqual({ + kind: 'empty', + statusLabel: 'No usage data' + }) + }) + + it('lets real usage data win over a stale error status', () => { + expect(getUsageRosterRowState(provider({ status: 'error' }), true)).toEqual({ + kind: 'usage', + statusLabel: null + }) + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.ts new file mode 100644 index 00000000000..68762327599 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.ts @@ -0,0 +1,77 @@ +import { translate } from '@/i18n/i18n' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { getProviderUsageStatusLabel } from './usage-error-copy' + +export type UsageRosterRowState = { + kind: 'usage' | 'loading' | 'sign-in' | 'unavailable' | 'error' | 'empty' + statusLabel: string | null +} + +const CONFIRMED_SIGN_OUT_PATTERNS = [ + /\bnot signed in\b/i, + /\bnot logged in\b/i, + /\blogged out\b/i, + /\bauthentication required\b/i, + /\b(?:sign|log)[ -]?in required\b/i, + /\bplease (?:sign|log) in\b/i, + /\bplease reauthenticate\b/i +] + +function isConfirmedSignedOut(provider: ProviderRateLimits): boolean { + if (provider.usageMetadata?.failureKind === 'missing-credentials') { + return true + } + // Why: credential refresh and network failures can mention auth while live + // sessions remain valid; only explicit signed-out copy earns a sign-in CTA. + if (provider.usageMetadata?.failureKind) { + return false + } + const error = provider.error + return Boolean(error && CONFIRMED_SIGN_OUT_PATTERNS.some((pattern) => pattern.test(error))) +} + +export function getUsageRosterRowState( + provider: ProviderRateLimits, + hasUsage: boolean +): UsageRosterRowState { + if (hasUsage) { + return { kind: 'usage', statusLabel: null } + } + if (provider.status === 'idle' || provider.status === 'fetching') { + return { + kind: 'loading', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.loadingUsage', + 'Loading usage…' + ) + } + } + if (isConfirmedSignedOut(provider)) { + return { + kind: 'sign-in', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.notSignedIn', + 'not signed in' + ) + } + } + if (provider.status === 'error') { + return { kind: 'error', statusLabel: getProviderUsageStatusLabel(provider) } + } + if (provider.status === 'unavailable') { + return { + kind: 'unavailable', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.usageUnavailable', + 'Usage unavailable' + ) + } + } + return { + kind: 'empty', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.noUsageData', + 'No usage data' + ) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cefb48a3c16..b59bbd3bf4f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3240,7 +3240,10 @@ "openDetails": "Open usage details", "notSignedIn": "not signed in", "allAgents": "all agents", - "usageDetails": "Usage details & history" + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ca7f018653e..c5e3d9b0398 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3240,7 +3240,10 @@ "openDetails": "Open usage details", "notSignedIn": "not signed in", "allAgents": "all agents", - "usageDetails": "Usage details & history" + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e558690f9d7..e4997704343 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3240,7 +3240,10 @@ "openDetails": "Open usage details", "notSignedIn": "not signed in", "allAgents": "all agents", - "usageDetails": "Usage details & history" + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 56a8671e6da..9ed973f8110 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3240,7 +3240,10 @@ "openDetails": "Open usage details", "notSignedIn": "not signed in", "allAgents": "all agents", - "usageDetails": "Usage details & history" + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1958598c51e..ff39313ef09 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3240,7 +3240,10 @@ "openDetails": "Open usage details", "notSignedIn": "not signed in", "allAgents": "all agents", - "usageDetails": "Usage details & history" + "usageDetails": "Usage details & history", + "loadingUsage": "Loading usage…", + "usageUnavailable": "Usage unavailable", + "noUsageData": "No usage data" } } }, diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index c53d7198a85..fbf682633d1 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -283,6 +283,55 @@ describe('watchProviderAccounts', () => { }) describe('fetchProviderAccountsSnapshot', () => { + it('deduplicates concurrent local reads but does not cache completed snapshots', async () => { + let resolveClaude!: (state: ClaudeRateLimitAccountsState) => void + let resolveCodex!: (state: CodexRateLimitAccountsState) => void + claudeListLocal.mockImplementation( + () => new Promise((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockImplementation( + () => new Promise((resolve) => (resolveCodex = resolve)) + ) + + const first = fetchProviderAccountsSnapshot(LOCAL) + const second = fetchProviderAccountsSnapshot(LOCAL) + + expect(second).toBe(first) + expect(claudeListLocal).toHaveBeenCalledTimes(1) + expect(codexListLocal).toHaveBeenCalledTimes(1) + + resolveClaude(emptyClaudeState()) + resolveCodex(emptyCodexState()) + await Promise.all([first, second]) + + claudeListLocal.mockResolvedValue(emptyClaudeState()) + codexListLocal.mockResolvedValue(emptyCodexState()) + await fetchProviderAccountsSnapshot(LOCAL) + expect(claudeListLocal).toHaveBeenCalledTimes(2) + expect(codexListLocal).toHaveBeenCalledTimes(2) + }) + + it('isolates in-flight snapshots by remote account owner', async () => { + const first = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-1' }) + const second = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-2' }) + await flushMicrotasks() + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) + const firstCallbacks = runtimeEnvironmentSubscribe.mock.calls[0]?.[1] as SubscriptionCallbacks + const secondCallbacks = runtimeEnvironmentSubscribe.mock.calls[1]?.[1] as SubscriptionCallbacks + firstCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('one') } + }) + secondCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('two') } + }) + + await expect(first).resolves.toMatchObject({ codex: { activeAccountId: 'codex-one' } }) + await expect(second).resolves.toMatchObject({ codex: { activeAccountId: 'codex-two' } }) + }) + it('resolves with the first remote snapshot and closes the subscription', async () => { const pending = fetchProviderAccountsSnapshot(REMOTE) await flushMicrotasks() diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index ec579069c87..2f304aba160 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -33,6 +33,13 @@ const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 // refreshes, and those refreshes can crawl behind broken auth. Give the call // room to finish instead of reporting failure for an applied switch. const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 +const pendingProviderAccountsSnapshots = new Map>() + +function getProviderAccountsOwnerKey( + settings: Pick | null | undefined +): string { + return settings?.activeRuntimeEnvironmentId?.trim() || 'local' +} export function hasRemoteProviderAccountOwner( settings: Pick | null | undefined @@ -194,10 +201,16 @@ export function watchProviderAccounts( // One-shot convenience over watchProviderAccounts for surfaces that only need // the current snapshot (status-bar switcher menus). -export async function fetchProviderAccountsSnapshot( +export function fetchProviderAccountsSnapshot( settings: Pick | null | undefined ): Promise { - return await new Promise((resolve, reject) => { + const ownerKey = getProviderAccountsOwnerKey(settings) + const pending = pendingProviderAccountsSnapshots.get(ownerKey) + if (pending) { + return pending + } + + const request = new Promise((resolve, reject) => { const watcher = watchProviderAccounts(settings, { onSnapshot: (snapshot) => { watcher.close() @@ -209,6 +222,16 @@ export async function fetchProviderAccountsSnapshot( } }) }) + pendingProviderAccountsSnapshots.set(ownerKey, request) + const clearPending = (): void => { + if (pendingProviderAccountsSnapshots.get(ownerKey) === request) { + pendingProviderAccountsSnapshots.delete(ownerKey) + } + } + // Why: both status-bar switchers mount together; share their in-flight read + // without caching the result past completion or across account owners. + void request.then(clearPending, clearPending) + return request } export async function selectClaudeProviderAccount(