fix(status-bar): harden usage roster interactions

This commit is contained in:
Brennan Benson
2026-07-15 01:23:58 -07:00
parent adb1a8327c
commit 9b84516aed
14 changed files with 443 additions and 91 deletions
@@ -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 (
<span className="tabular-nums">
{getDisplayedUsagePercentage(w.usedPercent, display)}%{showLabel ? ` ${label}` : ''}
{formatUsagePercentageLabel(w.usedPercent, display)}
{showLabel ? ` ${label}` : ''}
</span>
)
}
@@ -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 (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{tightestBucket ? (
<span className="tabular-nums">
{tightestBucket.name} {getDisplayedUsagePercentage(tightestBucket.usedPercent, display)}
%
</span>
) : p.session ? (
{visibleBuckets.map((bucket, index) => (
<React.Fragment key={bucket.name}>
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
<span className="tabular-nums">
{bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)}
</span>
</React.Fragment>
))}
{visibleBuckets.length === 0 && p.session ? (
<WindowLabel
w={p.session}
label={formatWindowLabel(p.session.windowMinutes)}
@@ -1268,24 +1267,20 @@ export function ProviderSegment({
: null
].filter((w): w is { key: string; window: RateLimitWindow; label: string } => 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 (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{tightestWindow && (
<WindowLabel
w={tightestWindow.window}
label={tightestWindow.label}
display={display}
showLabel={!compact}
/>
)}
{visibleWindows.map((window, index) => (
<React.Fragment key={window.key}>
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
<WindowLabel
w={window.window}
label={window.label}
display={display}
showLabel={!compact}
/>
</React.Fragment>
))}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
)
@@ -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) => {
@@ -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 }) => <span data-agent-icon={agent} />
}))
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(
<UsageRow
p={signedOutCodex}
display="used"
state={{ kind: 'sign-in', statusLabel: 'not signed in' }}
showSignInAction
/>
)
expect(markup).toContain('not signed in')
expect(markup).toContain('Sign in')
expect(markup).not.toContain('<button')
})
})
@@ -1,5 +1,6 @@
import React from 'react'
import { ChevronRight, RefreshCw } from 'lucide-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { formatWindowLabel } from '@/lib/window-label-formatter'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
@@ -11,6 +12,7 @@ import {
import { barColor, formatResetCountdown, getWindowSections, ProviderIcon } from './tooltip'
import { getProviderDisplayName } from './usage-error-copy'
import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting'
import { getUsageRosterRowState, type UsageRosterRowState } from './usage-roster-row-state'
type ProviderId = ProviderRateLimits['provider']
type Section = { label: string; window: RateLimitWindow }
@@ -57,17 +59,19 @@ function soonestResetLabel(sections: Section[]): string | null {
export function UsageRow({
p,
display,
onSignIn
state,
showSignInAction
}: {
p: ProviderRateLimits
display: UsagePercentageDisplay
onSignIn: (provider: ProviderId) => 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 (
<div className="flex min-w-0 flex-1 flex-col gap-1">
@@ -79,30 +83,22 @@ export function UsageRow({
{name}
{plan ? <span className="font-normal text-muted-foreground"> · {plan}</span> : null}
</span>
{signedOut ? (
{!hasUsage ? (
<>
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
{translate(
'auto.components.status.bar.UsageRosterPanel.notSignedIn',
'not signed in'
)}
{state.statusLabel}
</span>
<button
type="button"
onClick={(e) => {
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')}
</button>
{showSignInAction ? (
<span className="ml-auto shrink-0 rounded-md border border-border bg-secondary px-2.5 py-0.5 text-xs text-foreground">
{translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')}
</span>
) : null}
</>
) : reset ? (
<span className="shrink-0 text-[11px] text-muted-foreground">{reset}</span>
) : null}
</div>
{signedOut ? null : (
{hasUsage ? (
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 pl-[30px]">
{sections.map((s) => {
const used = clampUsedPercent(s.window.usedPercent)
@@ -123,7 +119,7 @@ export function UsageRow({
)
})}
</div>
)}
) : null}
</div>
)
}
@@ -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({
<span className="text-[11px]">
{translate('auto.components.status.bar.UsageRosterPanel.allAgents', 'all agents')}
</span>
<button
type="button"
onClick={onRefresh}
<DropdownMenuItem
onSelect={(event) => {
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"
>
<RefreshCw size={12} className={isRefreshing ? 'animate-spin' : ''} />
</button>
</DropdownMenuItem>
</div>
</div>
<div className="border-t border-border/70" />
{sorted.map((p) => {
const rowNode = <UsageRow p={p} display={display} onSignIn={onSignIn} />
const state = getUsageRosterRowState(p, usedSections(p).length > 0)
const showSignInAction = state.kind === 'sign-in' && canSignIn(p.provider)
const rowNode = (
<UsageRow p={p} display={display} state={state} showSignInAction={showSignInAction} />
)
if (showSignInAction) {
return (
<DropdownMenuItem
key={p.provider}
onSelect={() => onSignIn(p.provider)}
className="w-full cursor-pointer rounded-none px-3.5 py-2.5"
>
{rowNode}
</DropdownMenuItem>
)
}
const custom = renderRow?.(p, rowNode)
if (custom) {
return <React.Fragment key={p.provider}>{custom}</React.Fragment>
}
return (
<div
<DropdownMenuItem
key={p.provider}
role="button"
tabIndex={0}
onClick={() => 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}
</div>
</DropdownMenuItem>
)
})}
<div className="border-t border-border/70" />
<button
type="button"
onClick={onUsageDetails}
className="flex w-full items-center justify-between px-3.5 py-2.5 text-[13px] text-foreground hover:bg-accent/60"
<DropdownMenuItem
onSelect={onUsageDetails}
className="w-full cursor-pointer justify-between rounded-none px-3.5 py-2.5 text-[13px] text-foreground"
>
{translate(
'auto.components.status.bar.UsageRosterPanel.usageDetails',
'Usage details & history'
)}
<ChevronRight size={14} className="text-muted-foreground" />
</button>
<button
type="button"
onClick={onManageAccounts}
className="flex w-full items-center justify-between px-3.5 py-2.5 text-[13px] text-foreground hover:bg-accent/60"
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onManageAccounts}
className="w-full cursor-pointer justify-between rounded-none px-3.5 py-2.5 text-[13px] text-foreground"
>
{translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')}
<ChevronRight size={14} className="text-muted-foreground" />
</button>
</DropdownMenuItem>
</div>
)
}
@@ -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()
})
})
@@ -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
}
}
@@ -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> = {}): 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
})
})
})
@@ -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'
)
}
}
+4 -1
View File
@@ -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"
}
}
},
+4 -1
View File
@@ -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"
}
}
},
+4 -1
View File
@@ -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"
}
}
},
+4 -1
View File
@@ -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"
}
}
},
+4 -1
View File
@@ -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"
}
}
},
@@ -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<ClaudeRateLimitAccountsState>((resolve) => (resolveClaude = resolve))
)
codexListLocal.mockImplementation(
() => new Promise<CodexRateLimitAccountsState>((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()
@@ -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<string, Promise<ProviderAccountsSnapshot>>()
function getProviderAccountsOwnerKey(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): string {
return settings?.activeRuntimeEnvironmentId?.trim() || 'local'
}
export function hasRemoteProviderAccountOwner(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): Promise<ProviderAccountsSnapshot> {
return await new Promise((resolve, reject) => {
const ownerKey = getProviderAccountsOwnerKey(settings)
const pending = pendingProviderAccountsSnapshots.get(ownerKey)
if (pending) {
return pending
}
const request = new Promise<ProviderAccountsSnapshot>((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(