mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
fix(status-bar): harden usage roster interactions
This commit is contained in:
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user