mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(status-bar): consolidate agent usage into a single roster popover (#8761)
* feat(status-bar): consolidate agent usage into a single roster popover The footer usage cluster is now one quiet, borderless control: each agent shows its tightest window as neutral text (letter badges at icon-only width), and clicking anywhere opens a consolidated Usage popover listing every agent worst-first — icon, name, plan, soonest reset, and per-window threshold-colored bars. Claude/Codex rows drill into their existing account switcher, runtime toggle, and Codex reset credits via a submenu variant of ProviderDetailsMenu; all other providers drill into their detail panel. Row actions and the footer links route to Settings. Also: barColor's <60% band goes green -> neutral so color is reserved for approaching limits (threshold color now lives only in the popover, the always-visible bar stays monochrome), Codex plan_type is surfaced as ProviderRateLimits.planType for the "Codex · Plus" label, and the Fable weekly window is labeled "Fable" so it no longer collides with "wk". * fix(status-bar): single-line sign-in row + review dedup Signed-out roster rows now read as one line (name · "not signed in" · right-aligned Sign in) instead of a floating button over an orphaned second line. Review follow-ups: the non-submenu ProviderDetailsMenu branch reuses the extracted panelBody, and the icon-only letter badge is one shared component so its has-data dot condition can't drift. * fix(status-bar): harden usage roster interactions * chore: remove unrelated formatting change * chore(skills): refresh bundle manifest for rc.2 * fix(status-bar): preserve usage roster semantics * fix(status-bar): preserve roster menu interactions * chore: remove unrelated formatting changes * fix(status-bar): keep usage reset countdowns live * feat(status-bar): toggle compact usage summary * fix(status-bar): simplify usage mode toggle * feat(status-bar): replace usage footer toggle with Compact/Detailed segmented control Swap the bottom-of-popover on/off switch for a SettingsSegmentedControl at the top of the Usage popover (view-switcher pattern), so both modes are named and discoverable on first open. Reuses the repo's canonical Compact/Detailed vocabulary from the Workspace card-layout control.
This commit is contained in:
@@ -103,6 +103,7 @@ import {
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
|
||||
import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode'
|
||||
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
|
||||
import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice'
|
||||
import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides'
|
||||
@@ -5181,6 +5182,7 @@ export class Store {
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
this.state.ui?.usagePercentageDisplay
|
||||
),
|
||||
statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode),
|
||||
// Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires).
|
||||
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
|
||||
@@ -5278,6 +5280,9 @@ export class Store {
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay
|
||||
),
|
||||
statusBarUsageMode: normalizeStatusBarUsageMode(
|
||||
sanitizedUpdates.statusBarUsageMode ?? this.state.ui?.statusBarUsageMode
|
||||
),
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
|
||||
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
|
||||
),
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('Codex backend rate-limit requests', () => {
|
||||
availableCount: 1,
|
||||
nextExpiresAt: Date.parse('2027-01-15T12:00:00Z')
|
||||
},
|
||||
planType: 'plus',
|
||||
status: 'ok'
|
||||
})
|
||||
|
||||
|
||||
@@ -532,6 +532,8 @@ async function fetchViaBackend(
|
||||
provider: 'codex',
|
||||
session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300),
|
||||
weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080),
|
||||
// Surfaced for the status-bar Usage row (e.g. "Codex · Plus").
|
||||
planType: payload.plan_type,
|
||||
...(payload.rate_limit_reset_credits !== undefined
|
||||
? {
|
||||
rateLimitResetCredits:
|
||||
|
||||
@@ -219,6 +219,7 @@ export const UiUpdate = z
|
||||
_grokStatusBarDefaultAdded: z.boolean().optional(),
|
||||
statusBarVisible: z.boolean().optional(),
|
||||
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
|
||||
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
|
||||
dismissedUpdateVersion: NullableString.optional(),
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
pendingUpdateNudgeId: NullableString.optional(),
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -51,10 +54,13 @@ import {
|
||||
barColor,
|
||||
clampUsedPercent,
|
||||
formatResetCreditExpiry,
|
||||
getProviderDisplayName,
|
||||
getProviderUsageStatusLabel
|
||||
} from './tooltip'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { UsageRosterPanel, getTightestUsageSection } from './UsageRosterPanel'
|
||||
import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target'
|
||||
import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter'
|
||||
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
|
||||
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
|
||||
@@ -63,7 +69,10 @@ import { isStatusBarItemAvailable } from './status-bar-agent-gating'
|
||||
import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility'
|
||||
import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta'
|
||||
import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice'
|
||||
import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy'
|
||||
import {
|
||||
STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS,
|
||||
shouldOpenStatusBarContextMenu
|
||||
} from './status-bar-context-menu-policy'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
|
||||
@@ -85,6 +94,10 @@ import {
|
||||
type UsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
import { formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
import {
|
||||
normalizeStatusBarUsageMode,
|
||||
type StatusBarUsageMode
|
||||
} from '../../../../shared/status-bar-usage-mode'
|
||||
|
||||
type StatusBarProps = {
|
||||
floatingTerminalOpen: boolean
|
||||
@@ -656,14 +669,20 @@ function AccountRuntimeToggle<TGroup extends { key: string; label: string }>({
|
||||
)
|
||||
}
|
||||
|
||||
function ClaudeSwitcherMenu({
|
||||
// Exported so its account-switch/reset logic is preserved for row drill-in even
|
||||
// though the footer now opens the consolidated UsageRosterPanel first.
|
||||
export function ClaudeSwitcherMenu({
|
||||
claude,
|
||||
compact,
|
||||
iconOnly
|
||||
iconOnly,
|
||||
asSubmenu = false,
|
||||
triggerContent
|
||||
}: {
|
||||
claude: ProviderRateLimits
|
||||
compact: boolean
|
||||
iconOnly: boolean
|
||||
asSubmenu?: boolean
|
||||
triggerContent?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [accountsExpanded, setAccountsExpanded] = useState(false)
|
||||
@@ -732,7 +751,7 @@ 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)
|
||||
@@ -824,6 +843,8 @@ function ClaudeSwitcherMenu({
|
||||
provider={claude}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
asSubmenu={asSubmenu}
|
||||
triggerContent={triggerContent}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.3dd7ddfae1',
|
||||
'Open Claude details and account switcher'
|
||||
@@ -1082,99 +1103,94 @@ function InlineUsageSkeleton(): React.JSX.Element {
|
||||
function WindowLabel({
|
||||
w,
|
||||
label,
|
||||
display
|
||||
display,
|
||||
showLabel = true
|
||||
}: {
|
||||
w: RateLimitWindow
|
||||
label: string
|
||||
display: UsagePercentageDisplay
|
||||
showLabel?: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{formatUsagePercentageLabel(w.usedPercent, display)} {label}
|
||||
{formatUsagePercentageLabel(w.usedPercent, display)}
|
||||
{showLabel ? ` ${label}` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: show only Flash and latest Pro in the bar; the rest (Flash Lite, experimental) would clutter it.
|
||||
// Single-letter provider badge for the icon-only (narrow) status bar. Shared by
|
||||
// the roster trigger and ProviderDetailsMenu so the dot's has-data condition
|
||||
// and markup can't drift between the two.
|
||||
function ProviderLetterBadge({ p }: { p: ProviderRateLimits }): React.JSX.Element {
|
||||
const hasData = Boolean(p.session || p.weekly || p.fableWeekly || p.monthly || p.buckets?.length)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${hasData ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
|
||||
/>
|
||||
{getProviderLetter(p.provider)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getProviderLetter(provider: ProviderRateLimits['provider']): string {
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return 'C'
|
||||
case 'gemini':
|
||||
return 'G'
|
||||
case 'opencode-go':
|
||||
return 'O'
|
||||
case 'kimi':
|
||||
return 'K'
|
||||
case 'antigravity':
|
||||
return 'A'
|
||||
case 'minimax':
|
||||
return 'M'
|
||||
case 'grok':
|
||||
return 'R'
|
||||
case 'codex':
|
||||
return 'X'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider segment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy.
|
||||
const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro'])
|
||||
|
||||
export function ProviderSegment({
|
||||
function VerboseProviderUsage({
|
||||
p,
|
||||
compact,
|
||||
display
|
||||
}: {
|
||||
p: ProviderRateLimits | null
|
||||
p: ProviderRateLimits
|
||||
compact: boolean
|
||||
display: UsagePercentageDisplay
|
||||
}): React.JSX.Element {
|
||||
const provider = p?.provider ?? 'claude'
|
||||
const statusLabel = p ? getProviderUsageStatusLabel(p) : ''
|
||||
|
||||
// Idle / initial load
|
||||
if (!p || p.status === 'idle') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<span className="animate-pulse">···</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Fetching with no prior data
|
||||
if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<span className="animate-pulse">···</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable (CLI not installed)
|
||||
if (p.status === 'unavailable') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground/50">
|
||||
<ProviderIcon provider={provider} /> --
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Error with no data
|
||||
if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<AlertTriangle size={11} className="text-muted-foreground/80" />
|
||||
{!compact && <span className="text-[11px] font-medium">{statusLabel}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Has data (ok, fetching with stale data, or error with stale data)
|
||||
const isStale = p.status === 'error'
|
||||
|
||||
if (p.buckets && p.buckets.length > 0) {
|
||||
const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.name))
|
||||
const visibleBuckets = p.buckets.filter((bucket) => STATUS_BAR_BUCKET_NAMES.has(bucket.name))
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon provider={provider} />
|
||||
{visibleBuckets.map((bucket, i) => (
|
||||
<>
|
||||
{visibleBuckets.map((bucket, index) => (
|
||||
<React.Fragment key={bucket.name}>
|
||||
{i > 0 && <span className="text-muted-foreground">·</span>}
|
||||
{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 && (
|
||||
{visibleBuckets.length === 0 && p.session ? (
|
||||
<WindowLabel
|
||||
w={p.session}
|
||||
label={formatRateLimitWindowChipLabel(p.session)}
|
||||
display={display}
|
||||
/>
|
||||
)}
|
||||
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1200,7 +1216,7 @@ export function ProviderSegment({
|
||||
label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable')
|
||||
}
|
||||
: null,
|
||||
// Why: show monthly on the chip only when it's the sole window (Grok); providers with session/weekly keep it tooltip-only.
|
||||
// Why: monthly stays inline for monthly-only providers; otherwise the detail panel carries it.
|
||||
p.monthly && !p.session && !p.weekly
|
||||
? {
|
||||
key: 'monthly',
|
||||
@@ -1208,33 +1224,121 @@ export function ProviderSegment({
|
||||
label: formatRateLimitWindowChipLabel(p.monthly)
|
||||
}
|
||||
: null
|
||||
].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null)
|
||||
].filter((window): window is { key: string; window: RateLimitWindow; label: string } => {
|
||||
return window !== null
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{p.session && !compact ? (
|
||||
<MiniBar usedPct={clampUsedPercent(p.session.usedPercent)} display={display} />
|
||||
) : null}
|
||||
{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} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderSegment({
|
||||
p,
|
||||
compact,
|
||||
display,
|
||||
mode = 'verbose'
|
||||
}: {
|
||||
p: ProviderRateLimits | null
|
||||
compact: boolean
|
||||
display: UsagePercentageDisplay
|
||||
mode?: StatusBarUsageMode
|
||||
}): React.JSX.Element {
|
||||
const provider = p?.provider ?? 'claude'
|
||||
const statusLabel = p ? getProviderUsageStatusLabel(p) : ''
|
||||
|
||||
// Idle / initial load
|
||||
if (!p || p.status === 'idle') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<span className="animate-pulse">···</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const tightest = getTightestUsageSection(p)
|
||||
|
||||
// Fetching with no prior data
|
||||
if (p.status === 'fetching' && !tightest) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<span className="animate-pulse">···</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable (CLI not installed)
|
||||
if (p.status === 'unavailable') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground/50">
|
||||
<ProviderIcon provider={provider} /> --
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Error with no data
|
||||
if (p.status === 'error' && !tightest) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ProviderIcon provider={provider} />
|
||||
<AlertTriangle size={11} className="text-muted-foreground/80" />
|
||||
{!compact && <span className="text-[11px] font-medium">{statusLabel}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Has data (ok, fetching with stale data, or error with stale data)
|
||||
const isStale = p.status === 'error'
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon provider={provider} />
|
||||
{p.session && !compact && (
|
||||
<MiniBar usedPct={clampUsedPercent(p.session.usedPercent)} display={display} />
|
||||
{mode === 'verbose' ? (
|
||||
<VerboseProviderUsage p={p} compact={compact} display={display} />
|
||||
) : (
|
||||
<>
|
||||
{tightest && !compact ? (
|
||||
<MiniBar usedPct={clampUsedPercent(tightest.window.usedPercent)} display={display} />
|
||||
) : null}
|
||||
{tightest ? (
|
||||
<WindowLabel
|
||||
w={tightest.window}
|
||||
label={tightest.label}
|
||||
display={display}
|
||||
showLabel={!compact}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{visibleWindows.map((window, index) => (
|
||||
<React.Fragment key={window.key}>
|
||||
{index > 0 && <span className="text-muted-foreground">·</span>}
|
||||
<WindowLabel w={window.window} label={window.label} display={display} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexSwitcherMenu({
|
||||
export function CodexSwitcherMenu({
|
||||
codex,
|
||||
compact,
|
||||
iconOnly
|
||||
iconOnly,
|
||||
asSubmenu = false,
|
||||
triggerContent
|
||||
}: {
|
||||
codex: ProviderRateLimits
|
||||
compact: boolean
|
||||
iconOnly: boolean
|
||||
asSubmenu?: boolean
|
||||
triggerContent?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [accountsExpanded, setAccountsExpanded] = useState(false)
|
||||
@@ -1318,11 +1422,12 @@ function CodexSwitcherMenu({
|
||||
}, [accountsExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
// Why: refresh our local account snapshot when the roster changes or the menu opens, since Settings mutates the store out-of-band.
|
||||
// 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,
|
||||
@@ -1498,7 +1603,10 @@ function CodexSwitcherMenu({
|
||||
provider={codex}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
// Why: reset credits render beside the reset action below; the generic summary would duplicate them.
|
||||
asSubmenu={asSubmenu}
|
||||
triggerContent={triggerContent}
|
||||
// Why: Codex reset credits render beside the reset action below; showing
|
||||
// them in the generic provider summary duplicates the same metadata.
|
||||
hidePanelResetCredits
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.ba55303942',
|
||||
@@ -1519,7 +1627,7 @@ function CodexSwitcherMenu({
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Dialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogContent className="sm:max-w-[420px]" {...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{translate('auto.components.status.bar.StatusBar.972a1ff497', 'Reset Codex limits?')}
|
||||
@@ -1723,7 +1831,9 @@ export function ProviderDetailsMenu({
|
||||
hidePanelResetCredits = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
children
|
||||
children,
|
||||
asSubmenu = false,
|
||||
triggerContent
|
||||
}: {
|
||||
provider: ProviderRateLimits
|
||||
compact: boolean
|
||||
@@ -1734,21 +1844,62 @@ export function ProviderDetailsMenu({
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
// When set, render as a drill-in submenu (used by the consolidated Usage
|
||||
// popover) with triggerContent as the full-width row instead of a segment.
|
||||
asSubmenu?: boolean
|
||||
triggerContent?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const usagePercentageDisplay = normalizeUsagePercentageDisplay(
|
||||
useAppStore((s) => s.usagePercentageDisplay)
|
||||
)
|
||||
const skipCloseAutoFocusRef = useRef(false)
|
||||
const menuFocusHandoff = useStatusBarMenuFocusHandoff()
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
if (nextOpen) {
|
||||
skipCloseAutoFocusRef.current = false
|
||||
menuFocusHandoff.reset()
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
const panelBody = (
|
||||
<>
|
||||
{topContent}
|
||||
<div className="p-2">
|
||||
{/* Why: provider-specific action sections may render richer reset-credit UI. */}
|
||||
<ProviderPanel
|
||||
p={provider}
|
||||
showResetCredits={!hidePanelResetCredits}
|
||||
usagePercentageDisplay={usagePercentageDisplay}
|
||||
/>
|
||||
</div>
|
||||
{children ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{children}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (asSubmenu) {
|
||||
return (
|
||||
<DropdownMenuSub open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuSubTrigger className="w-full items-center gap-3 px-3.5 py-2.5">
|
||||
{triggerContent}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
collisionPadding={{ top: 8, bottom: 32, left: 8, right: 8 }}
|
||||
className="max-h-(--radix-dropdown-menu-content-available-height) w-[300px] overflow-y-auto p-0 scrollbar-sleek"
|
||||
>
|
||||
{panelBody}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1758,65 +1909,22 @@ export function ProviderDetailsMenu({
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{iconOnly ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly || provider.fableWeekly || provider.monthly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{provider.provider === 'claude'
|
||||
? 'C'
|
||||
: provider.provider === 'gemini'
|
||||
? 'G'
|
||||
: provider.provider === 'opencode-go'
|
||||
? 'O'
|
||||
: provider.provider === 'kimi'
|
||||
? 'K'
|
||||
: provider.provider === 'antigravity'
|
||||
? 'A'
|
||||
: provider.provider === 'minimax'
|
||||
? 'M'
|
||||
: provider.provider === 'grok'
|
||||
? 'R'
|
||||
: 'X'}
|
||||
</span>
|
||||
</span>
|
||||
<ProviderLetterBadge p={provider} />
|
||||
) : (
|
||||
<ProviderSegment p={provider} compact={compact} display={usagePercentageDisplay} />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[260px]"
|
||||
onPointerDownOutside={() => {
|
||||
skipCloseAutoFocusRef.current = true
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!skipCloseAutoFocusRef.current) {
|
||||
return
|
||||
}
|
||||
skipCloseAutoFocusRef.current = false
|
||||
// Why: focus the clicked surface (esp. xterm) — Radix's default trigger restore steals that first click.
|
||||
event.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={menuFocusHandoff.onPointerDownOutside}
|
||||
onCloseAutoFocus={menuFocusHandoff.onCloseAutoFocus}
|
||||
>
|
||||
{topContent}
|
||||
<div className="p-2">
|
||||
{/* Why: provider-specific action sections may render richer reset-credit UI. */}
|
||||
<ProviderPanel
|
||||
p={provider}
|
||||
showResetCredits={!hidePanelResetCredits}
|
||||
usagePercentageDisplay={usagePercentageDisplay}
|
||||
/>
|
||||
</div>
|
||||
{children ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{children}
|
||||
</>
|
||||
) : null}
|
||||
{panelBody}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
@@ -1824,11 +1932,44 @@ export function ProviderDetailsMenu({
|
||||
|
||||
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
function useStatusBarMenuFocusHandoff(): {
|
||||
reset: () => void
|
||||
onPointerDownOutside: () => void
|
||||
onCloseAutoFocus: (event: Event) => void
|
||||
} {
|
||||
const skipCloseAutoFocusRef = useRef(false)
|
||||
return {
|
||||
reset: () => {
|
||||
skipCloseAutoFocusRef.current = false
|
||||
},
|
||||
onPointerDownOutside: () => {
|
||||
skipCloseAutoFocusRef.current = true
|
||||
},
|
||||
onCloseAutoFocus: (event) => {
|
||||
if (!skipCloseAutoFocusRef.current) {
|
||||
return
|
||||
}
|
||||
skipCloseAutoFocusRef.current = false
|
||||
// Why: Radix trigger restoration steals the first click from surfaces such as xterm.
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Element | null {
|
||||
const floatingTerminalShortcut = useShortcutLabel('floatingTerminal.toggle')
|
||||
const rateLimits = useAppStore((s) => s.rateLimits)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const usagePercentageDisplay = normalizeUsagePercentageDisplay(
|
||||
useAppStore((s) => s.usagePercentageDisplay)
|
||||
)
|
||||
const statusBarUsageMode = normalizeStatusBarUsageMode(useAppStore((s) => s.statusBarUsageMode))
|
||||
const setStatusBarUsageMode = useAppStore((s) => s.setStatusBarUsageMode)
|
||||
const [usageMenuOpen, setUsageMenuOpen] = useState(false)
|
||||
const usageMenuFocusHandoff = useStatusBarMenuFocusHandoff()
|
||||
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
|
||||
const statusBarItems = useAppStore((s) => s.statusBarItems)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
@@ -2000,6 +2141,46 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
: 'Show Floating Workspace'
|
||||
const showFloatingWorkspaceAttentionDot = !floatingTerminalOpen && hasFloatingUnread
|
||||
|
||||
// Why: the roster must contain only status items the user left visible;
|
||||
// otherwise an empty trigger would bypass those visibility controls.
|
||||
const rosterProviders = [
|
||||
showClaude ? visibleClaude : null,
|
||||
showCodex ? visibleCodex : null,
|
||||
showGemini ? visibleGemini : null,
|
||||
showAntigravity ? visibleAntigravity : null,
|
||||
showOpencodeGo ? visibleOpencodeGo : null,
|
||||
showKimi ? visibleKimi : null,
|
||||
showMiniMax ? visibleMiniMax : null,
|
||||
showGrok ? visibleGrok : null
|
||||
].filter((p): p is ProviderRateLimits => p !== null)
|
||||
|
||||
const handleManageAccounts = (): void => {
|
||||
setUsageMenuOpen(false)
|
||||
openSettingsTarget({ pane: 'accounts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
const handleUsageDetails = (): void => {
|
||||
setUsageMenuOpen(false)
|
||||
openSettingsTarget({ pane: 'stats', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
const handleOpenProviderAccounts = (provider: ProviderRateLimits['provider']): void => {
|
||||
const sectionId = getUsageProviderAccountsSectionId(provider)
|
||||
if (!sectionId) {
|
||||
return
|
||||
}
|
||||
setUsageMenuOpen(false)
|
||||
openSettingsTarget({ pane: 'accounts', repoId: null, sectionId })
|
||||
openSettingsPage()
|
||||
}
|
||||
const handleUsageMenuOpenChange = (nextOpen: boolean): void => {
|
||||
if (nextOpen) {
|
||||
usageMenuFocusHandoff.reset()
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
}
|
||||
setUsageMenuOpen(nextOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRefCallback}
|
||||
@@ -2021,83 +2202,110 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
showEmptyUsageCta ? (
|
||||
<StatusBarUsageEmptyCta />
|
||||
) : null
|
||||
) : (
|
||||
// Why: anchor the one-time %-display callout to this meter cluster, not a global toast.
|
||||
) : hasVisibleUsageMeters ? (
|
||||
// Consolidated roster pill → opens the all-agents Usage popover (mock parity).
|
||||
<UsagePercentageDisplayChangeNotice hasVisibleUsageMeters={hasVisibleUsageMeters}>
|
||||
{showClaude && (
|
||||
<ClaudeSwitcherMenu claude={visibleClaude} compact={compact} iconOnly={iconOnly} />
|
||||
)}
|
||||
{showCodex && (
|
||||
<CodexSwitcherMenu codex={visibleCodex} compact={compact} iconOnly={iconOnly} />
|
||||
)}
|
||||
{showGemini && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleGemini}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.d2375976eb',
|
||||
'Open Gemini usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showAntigravity && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleAntigravity}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.antigravityUsageDetails',
|
||||
'Open Antigravity usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showOpencodeGo && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleOpencodeGo}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.629251f4b6',
|
||||
'Open OpenCode Go usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showKimi && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleKimi}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.fda8146810',
|
||||
'Open Kimi usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showMiniMax && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleMiniMax}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.06741a2f3d',
|
||||
'Open MiniMax usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showGrok && (
|
||||
<ProviderDetailsMenu
|
||||
provider={visibleGrok}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.StatusBar.grokUsageAria',
|
||||
'Open Grok usage details'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu
|
||||
open={usageMenuOpen}
|
||||
onOpenChange={handleUsageMenuOpenChange}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-3 rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.UsageRosterPanel.title',
|
||||
'Usage'
|
||||
)}
|
||||
>
|
||||
{rosterProviders.map((p) =>
|
||||
iconOnly ? (
|
||||
// Narrow status bar: fall back to main's compact letter badge.
|
||||
<span key={p.provider} title={getProviderDisplayName(p.provider)}>
|
||||
<ProviderLetterBadge p={p} />
|
||||
</span>
|
||||
) : (
|
||||
<ProviderSegment
|
||||
key={p.provider}
|
||||
p={p}
|
||||
compact={compact}
|
||||
display={usagePercentageDisplay}
|
||||
mode={statusBarUsageMode}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
{...STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS}
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
// Keep the popover (and its drill-in submenus) above the status
|
||||
// bar instead of overlapping it — bottom padding ≈ footer height.
|
||||
collisionPadding={{ top: 8, bottom: 32, left: 8, right: 8 }}
|
||||
className="w-[360px] p-0"
|
||||
onPointerDownOutside={usageMenuFocusHandoff.onPointerDownOutside}
|
||||
onCloseAutoFocus={usageMenuFocusHandoff.onCloseAutoFocus}
|
||||
>
|
||||
<UsageRosterPanel
|
||||
providers={rosterProviders}
|
||||
display={usagePercentageDisplay}
|
||||
statusBarUsageMode={statusBarUsageMode}
|
||||
onStatusBarUsageModeChange={setStatusBarUsageMode}
|
||||
isRefreshing={isRefreshing || anyFetching}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenProvider={handleOpenProviderAccounts}
|
||||
onSignIn={handleOpenProviderAccounts}
|
||||
canSignIn={(provider) => getUsageProviderAccountsSectionId(provider) !== null}
|
||||
onManageAccounts={handleManageAccounts}
|
||||
onUsageDetails={handleUsageDetails}
|
||||
renderRow={(p, rowNode) => {
|
||||
// Every provider drills into its detail panel (parity with the
|
||||
// per-provider dropdowns on main); Claude/Codex additionally get
|
||||
// the account switcher + runtime toggle + Codex reset credits.
|
||||
if (p.provider === 'claude') {
|
||||
return (
|
||||
<ClaudeSwitcherMenu
|
||||
claude={p}
|
||||
compact={compact}
|
||||
iconOnly={false}
|
||||
asSubmenu
|
||||
triggerContent={rowNode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (p.provider === 'codex') {
|
||||
return (
|
||||
<CodexSwitcherMenu
|
||||
codex={p}
|
||||
compact={compact}
|
||||
iconOnly={false}
|
||||
asSubmenu
|
||||
triggerContent={rowNode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ProviderDetailsMenu
|
||||
provider={p}
|
||||
compact={compact}
|
||||
iconOnly={false}
|
||||
asSubmenu
|
||||
triggerContent={rowNode}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.UsageRosterPanel.openDetails',
|
||||
'Open usage details'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</UsagePercentageDisplayChangeNotice>
|
||||
)}
|
||||
) : null}
|
||||
{anyVisible && !isEmptyUsageState && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
now: 1_000_000_000,
|
||||
useResetCountdownClock: vi.fn(() => 1_000_000_000)
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
AgentIcon: ({ agent }: { agent: string }) => <span data-agent-icon={agent} />
|
||||
}))
|
||||
vi.mock('@/hooks/useResetCountdownClock', () => ({
|
||||
useResetCountdownClock: mocks.useResetCountdownClock
|
||||
}))
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect: _onSelect,
|
||||
...props
|
||||
}: React.PropsWithChildren<{ onSelect?: () => void }>) => <div {...props}>{children}</div>
|
||||
}))
|
||||
|
||||
import { UsageRosterPanel, 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', () => {
|
||||
beforeEach(() => {
|
||||
mocks.useResetCountdownClock.mockClear()
|
||||
})
|
||||
|
||||
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
|
||||
now={mocks.now}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('not signed in')
|
||||
expect(markup).toContain('Sign in')
|
||||
expect(markup).not.toContain('<button')
|
||||
})
|
||||
|
||||
it('keeps the bar fill consistent with the remaining percentage label', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<UsageRow
|
||||
p={{
|
||||
...signedOutCodex,
|
||||
session: {
|
||||
usedPercent: 25,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
},
|
||||
status: 'ok',
|
||||
error: null
|
||||
}}
|
||||
display="remaining"
|
||||
state={{ kind: 'usage', statusLabel: null }}
|
||||
showSignInAction={false}
|
||||
now={mocks.now}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('75%')
|
||||
expect(markup).toContain('width:75%')
|
||||
expect(markup).not.toContain('width:25%')
|
||||
})
|
||||
|
||||
it('uses one shared clock for live reset labels across the roster', () => {
|
||||
const sessionReset = mocks.now + 2 * 60_000
|
||||
const weeklyReset = mocks.now + 7 * 24 * 60 * 60_000
|
||||
const markup = renderToStaticMarkup(
|
||||
<UsageRosterPanel
|
||||
providers={[
|
||||
{
|
||||
...signedOutCodex,
|
||||
session: {
|
||||
usedPercent: 25,
|
||||
windowMinutes: 300,
|
||||
resetsAt: sessionReset,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 10,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: weeklyReset,
|
||||
resetDescription: null
|
||||
},
|
||||
status: 'ok',
|
||||
error: null
|
||||
}
|
||||
]}
|
||||
display="used"
|
||||
statusBarUsageMode="verbose"
|
||||
onStatusBarUsageModeChange={() => {}}
|
||||
isRefreshing={false}
|
||||
onRefresh={() => {}}
|
||||
onOpenProvider={() => {}}
|
||||
onSignIn={() => {}}
|
||||
canSignIn={() => true}
|
||||
onManageAccounts={() => {}}
|
||||
onUsageDetails={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mocks.useResetCountdownClock).toHaveBeenCalledOnce()
|
||||
expect(mocks.useResetCountdownClock).toHaveBeenCalledWith([sessionReset, weeklyReset])
|
||||
expect(markup).toContain('Resets in 2m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UsageRosterPanel density picker', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useResetCountdownClock.mockClear()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderPanel(
|
||||
statusBarUsageMode: 'verbose' | 'compact',
|
||||
onStatusBarUsageModeChange: (mode: 'verbose' | 'compact') => void
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<UsageRosterPanel
|
||||
providers={[]}
|
||||
display="used"
|
||||
statusBarUsageMode={statusBarUsageMode}
|
||||
onStatusBarUsageModeChange={onStatusBarUsageModeChange}
|
||||
isRefreshing={false}
|
||||
onRefresh={() => {}}
|
||||
onOpenProvider={() => {}}
|
||||
onSignIn={() => {}}
|
||||
canSignIn={() => true}
|
||||
onManageAccounts={() => {}}
|
||||
onUsageDetails={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function segmentButton(label: string): HTMLButtonElement {
|
||||
const button = [...container.querySelectorAll('button')].find(
|
||||
(node) => node.textContent === label
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error(`missing "${label}" segment`)
|
||||
}
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
it('offers named Detailed/Compact segments and marks the active one', () => {
|
||||
renderPanel('compact', () => {})
|
||||
|
||||
expect(container.textContent).toContain('Detailed')
|
||||
expect(container.textContent).toContain('Compact')
|
||||
expect(segmentButton('Compact').getAttribute('aria-checked')).toBe('true')
|
||||
expect(segmentButton('Detailed').getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('switches mode when a segment is chosen', () => {
|
||||
const onStatusBarUsageModeChange = vi.fn()
|
||||
renderPanel('compact', onStatusBarUsageModeChange)
|
||||
|
||||
act(() => {
|
||||
segmentButton('Detailed').click()
|
||||
})
|
||||
expect(onStatusBarUsageModeChange).toHaveBeenLastCalledWith('verbose')
|
||||
|
||||
act(() => {
|
||||
segmentButton('Compact').click()
|
||||
})
|
||||
expect(onStatusBarUsageModeChange).toHaveBeenLastCalledWith('compact')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import React from 'react'
|
||||
import { ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { SettingsSegmentedControl } from '@/components/settings/SettingsFormControls'
|
||||
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
|
||||
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
|
||||
import {
|
||||
clampUsedPercent,
|
||||
getDisplayedUsagePercentage,
|
||||
type UsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
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'
|
||||
import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode'
|
||||
|
||||
type ProviderId = ProviderRateLimits['provider']
|
||||
export type UsageSection = { label: string; window: RateLimitWindow }
|
||||
|
||||
// Windows/buckets that actually carry data — the null ones are absent limits.
|
||||
function usedSections(p: ProviderRateLimits): UsageSection[] {
|
||||
return getWindowSections(p).filter((s): s is UsageSection => s.window !== null)
|
||||
}
|
||||
|
||||
function providerMaxUsed(sections: UsageSection[]): number {
|
||||
return sections.length > 0
|
||||
? Math.max(...sections.map((s) => clampUsedPercent(s.window.usedPercent)))
|
||||
: 0
|
||||
}
|
||||
|
||||
// Buckets (Gemini Flash/Pro) keep their model name; windows use their duration.
|
||||
function shortLabel(
|
||||
p: ProviderRateLimits,
|
||||
section: UsageSection,
|
||||
useRemainingDuration = false
|
||||
): string {
|
||||
if (p.buckets?.some((b) => b.name === section.label)) {
|
||||
return section.label
|
||||
}
|
||||
// fableWeekly shares the 7d window with weekly; label it distinctly so the two
|
||||
// don't both render as "wk".
|
||||
if (section.window === p.fableWeekly) {
|
||||
return 'Fable'
|
||||
}
|
||||
return useRemainingDuration
|
||||
? formatRateLimitWindowChipLabel(section.window)
|
||||
: formatWindowLabel(section.window.windowMinutes)
|
||||
}
|
||||
|
||||
export function getTightestUsageSection(p: ProviderRateLimits): UsageSection | null {
|
||||
const sections = usedSections(p)
|
||||
if (sections.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: the footer promises one quiet summary per provider; choose urgency by
|
||||
// consumption even when the user displays the complementary “% left” value.
|
||||
const tightest = sections.reduce((current, candidate) =>
|
||||
clampUsedPercent(candidate.window.usedPercent) > clampUsedPercent(current.window.usedPercent)
|
||||
? candidate
|
||||
: current
|
||||
)
|
||||
return { ...tightest, label: shortLabel(p, tightest, true) }
|
||||
}
|
||||
|
||||
// The soonest-resetting window summarizes the agent's next reset in one line.
|
||||
function soonestResetLabel(sections: UsageSection[], now: number): string | null {
|
||||
const resets = sections
|
||||
.map((s) => s.window.resetsAt)
|
||||
.filter((r): r is number => typeof r === 'number' && Number.isFinite(r))
|
||||
if (resets.length === 0) {
|
||||
return null
|
||||
}
|
||||
return formatResetCountdown(Math.min(...resets) - now)
|
||||
}
|
||||
|
||||
// Presentational row: a compact header (icon · name · plan · reset) with
|
||||
// the per-window metrics beneath, so the reset stays visible and multi-window
|
||||
// agents stay short. The wrapper supplies padding + interaction (drill-in
|
||||
// submenu or plain clickable row).
|
||||
export function UsageRow({
|
||||
p,
|
||||
display,
|
||||
state,
|
||||
showSignInAction,
|
||||
now
|
||||
}: {
|
||||
p: ProviderRateLimits
|
||||
display: UsagePercentageDisplay
|
||||
state: UsageRosterRowState
|
||||
showSignInAction: boolean
|
||||
now: number
|
||||
}): React.JSX.Element {
|
||||
const sections = usedSections(p)
|
||||
const hasUsage = sections.length > 0
|
||||
const name = getProviderDisplayName(p.provider)
|
||||
const plan = formatPlanLabel(p.planType)
|
||||
const reset = hasUsage ? soonestResetLabel(sections, now) : null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md border border-border bg-secondary">
|
||||
<ProviderIcon provider={p.provider} />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate text-[13px] font-medium text-foreground">
|
||||
{name}
|
||||
{plan ? <span className="font-normal text-muted-foreground"> · {plan}</span> : null}
|
||||
</span>
|
||||
{!hasUsage ? (
|
||||
<>
|
||||
<span className="min-w-0 truncate text-[11px] text-muted-foreground">
|
||||
{state.statusLabel}
|
||||
</span>
|
||||
{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>
|
||||
{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)
|
||||
const shown = getDisplayedUsagePercentage(s.window.usedPercent, display)
|
||||
return (
|
||||
<span key={s.label} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">{shortLabel(p, s)}</span>
|
||||
<span className="h-[5px] w-7 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className={`block h-full rounded-full ${barColor(used)}`}
|
||||
style={{ width: `${shown}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={`tabular-nums text-[11px] ${usageTextColorClass(used)}`}>
|
||||
{shown}%
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated "Usage" popover — one row per agent (icon · name · reset ·
|
||||
* per-window bars), opened from the status-bar roster pill. Deep per-agent
|
||||
* actions route to Settings via the callbacks.
|
||||
*/
|
||||
export function UsageRosterPanel({
|
||||
providers,
|
||||
display,
|
||||
statusBarUsageMode,
|
||||
onStatusBarUsageModeChange,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onOpenProvider,
|
||||
onSignIn,
|
||||
canSignIn,
|
||||
onManageAccounts,
|
||||
onUsageDetails,
|
||||
renderRow
|
||||
}: {
|
||||
providers: ProviderRateLimits[]
|
||||
display: UsagePercentageDisplay
|
||||
statusBarUsageMode: StatusBarUsageMode
|
||||
onStatusBarUsageModeChange: (mode: StatusBarUsageMode) => void
|
||||
isRefreshing: boolean
|
||||
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
|
||||
// Claude/Codex account-switch drill-in submenu); return null to use the
|
||||
// default clickable row.
|
||||
renderRow?: (p: ProviderRateLimits, row: React.ReactNode) => React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
// Why: one boundary-scheduled clock keeps every open row current without per-provider timers.
|
||||
const now = useResetCountdownClock(
|
||||
providers.flatMap((provider) =>
|
||||
usedSections(provider).map((section) => section.window.resetsAt)
|
||||
)
|
||||
)
|
||||
// Worst-first so the agent nearest a limit sits on top.
|
||||
const sorted = [...providers].sort(
|
||||
(a, b) => providerMaxUsed(usedSections(b)) - providerMaxUsed(usedSections(a))
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-[360px] text-xs">
|
||||
<div className="flex items-center justify-between px-3.5 pb-2 pt-3">
|
||||
<span className="text-[13px] font-semibold text-foreground">
|
||||
{translate('auto.components.status.bar.UsageRosterPanel.title', 'Usage')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-[11px]">
|
||||
{translate('auto.components.status.bar.UsageRosterPanel.allAgents', 'all agents')}
|
||||
</span>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
onRefresh()
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.StatusBar.3325d996cb',
|
||||
'Refresh rate limits'
|
||||
)}
|
||||
className="size-5 justify-center p-0"
|
||||
>
|
||||
<RefreshCw size={12} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
{/* Density picker lives at the top of the popover it controls (view-switcher
|
||||
pattern) so both modes are named and discoverable on first open. */}
|
||||
<div className="px-3.5 pb-2.5">
|
||||
<SettingsSegmentedControl<StatusBarUsageMode>
|
||||
value={statusBarUsageMode}
|
||||
onChange={onStatusBarUsageModeChange}
|
||||
ariaLabel={translate(
|
||||
'auto.components.status.bar.UsageRosterPanel.footerDetailAria',
|
||||
'Usage footer detail'
|
||||
)}
|
||||
size="sm"
|
||||
equalWidth
|
||||
options={[
|
||||
{
|
||||
value: 'verbose',
|
||||
label: translate('auto.components.status.bar.UsageRosterPanel.detailed', 'Detailed')
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: translate('auto.components.status.bar.UsageRosterPanel.compact', 'Compact')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/70" />
|
||||
{sorted.map((p) => {
|
||||
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}
|
||||
now={now}
|
||||
/>
|
||||
)
|
||||
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 (
|
||||
<DropdownMenuItem
|
||||
key={p.provider}
|
||||
onSelect={() => onOpenProvider(p.provider)}
|
||||
className="w-full cursor-pointer rounded-none px-3.5 py-2.5"
|
||||
>
|
||||
{rowNode}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-border/70" />
|
||||
<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" />
|
||||
</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" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ describe('ProviderSegment monthly window', () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={grokMonthlyLimits('ok')} compact={false} display="used" />
|
||||
<ProviderSegment p={grokMonthlyLimits('ok')} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('25% used 30d')
|
||||
@@ -58,16 +58,19 @@ describe('ProviderSegment monthly window', () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={grokMonthlyLimits('fetching')} compact={false} display="used" />
|
||||
<ProviderSegment
|
||||
p={grokMonthlyLimits('fetching')}
|
||||
compact={false}
|
||||
display="used"
|
||||
mode="compact"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('25% used 30d')
|
||||
expect(markup).not.toContain('···')
|
||||
})
|
||||
|
||||
// Why: providers with session/weekly windows (OpenCode Go) keep monthly
|
||||
// tooltip-only so the chip stays uncluttered.
|
||||
it('keeps monthly out of the chip when session and weekly windows exist', async () => {
|
||||
it('shows only the highest-used window when several windows exist', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
|
||||
const limits: ProviderRateLimits = {
|
||||
@@ -80,12 +83,35 @@ describe('ProviderSegment monthly window', () => {
|
||||
status: 'ok'
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" />
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('10% used 5h')
|
||||
expect(markup).toContain('20% used wk')
|
||||
expect(markup).not.toContain('30d')
|
||||
expect(markup).toContain('30% used 30d')
|
||||
expect(markup).not.toContain('10% used')
|
||||
expect(markup).not.toContain('20% used')
|
||||
})
|
||||
|
||||
it('selects a named bucket as the tightest provider window', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'gemini',
|
||||
session: null,
|
||||
weekly: null,
|
||||
buckets: [
|
||||
{ ...windowOf(25, 300), name: 'Flash' },
|
||||
{ ...windowOf(80, 300), name: 'Pro' }
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('80% used Pro')
|
||||
expect(markup).not.toContain('25% used')
|
||||
})
|
||||
|
||||
// Why: #8378 — status-bar chip showed fixed window size ("5h") while the
|
||||
@@ -106,15 +132,39 @@ describe('ProviderSegment monthly window', () => {
|
||||
status: 'ok'
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" />
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('42% used 2h 33m')
|
||||
expect(markup).not.toContain('5h')
|
||||
expect(markup).toContain('10% used 6d')
|
||||
// The consolidated footer intentionally renders only the tightest window.
|
||||
expect(markup).not.toContain('10% used')
|
||||
expect(markup).not.toContain('wk')
|
||||
} finally {
|
||||
dateNow.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores every inline window in verbose mode', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'claude',
|
||||
session: windowOf(10, 300),
|
||||
weekly: windowOf(20, 10_080),
|
||||
fableWeekly: windowOf(30, 10_080),
|
||||
monthly: windowOf(40, 43_200),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={limits} compact={false} display="used" mode="verbose" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('10% used 5h')
|
||||
expect(markup).toContain('20% used wk')
|
||||
expect(markup).toContain('30% used Fable')
|
||||
expect(markup).not.toContain('40% used')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -563,11 +563,12 @@ describe('clampUsedPercent', () => {
|
||||
})
|
||||
|
||||
describe('barColor', () => {
|
||||
// Why: thresholds are on % used (consumption). Guard against flipping back
|
||||
// to remaining-based colors without noticing.
|
||||
it('maps used percent to green / yellow / red bands', () => {
|
||||
expect(barColor(0)).toBe('bg-green-500')
|
||||
expect(barColor(59)).toBe('bg-green-500')
|
||||
// Why: thresholds are on % used (consumption). The <60 band is neutral (not
|
||||
// green) so the always-visible meter stays quiet until a limit nears; guard
|
||||
// against flipping back to green or to remaining-based colors without noticing.
|
||||
it('maps used percent to neutral / yellow / red bands', () => {
|
||||
expect(barColor(0)).toBe('bg-muted-foreground/40')
|
||||
expect(barColor(59)).toBe('bg-muted-foreground/40')
|
||||
expect(barColor(60)).toBe('bg-yellow-500')
|
||||
expect(barColor(79)).toBe('bg-yellow-500')
|
||||
expect(barColor(80)).toBe('bg-red-500')
|
||||
|
||||
@@ -185,12 +185,11 @@ export function getWindowSections(
|
||||
// `text-background` for primary text and `text-background/50` for secondary
|
||||
// to stay readable inside the inverted tooltip container.
|
||||
|
||||
// Why: color always tracks % used so urgency reads correctly even when the meter
|
||||
// fills with % remaining (#8560) — low remaining still turns red, not green.
|
||||
// Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%).
|
||||
// Why: urgency color tracks % used even when fill represents % remaining;
|
||||
// low usage stays neutral so persistent chrome stays quiet.
|
||||
export function barColor(usedPct: number): string {
|
||||
if (usedPct < 60) {
|
||||
return 'bg-green-500'
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
if (usedPct < 80) {
|
||||
return 'bg-yellow-500'
|
||||
|
||||
@@ -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,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatPlanLabel, usageTextColorClass } from './usage-roster-formatting'
|
||||
|
||||
describe('formatPlanLabel', () => {
|
||||
it('capitalizes a single-word plan', () => {
|
||||
expect(formatPlanLabel('plus')).toBe('Plus')
|
||||
expect(formatPlanLabel('pro')).toBe('Pro')
|
||||
expect(formatPlanLabel('business')).toBe('Business')
|
||||
})
|
||||
|
||||
it('title-cases multi-token plans across separators', () => {
|
||||
expect(formatPlanLabel('chatgpt_business')).toBe('ChatGPT Business')
|
||||
expect(formatPlanLabel('CHATGPT_PLUS')).toBe('ChatGPT Plus')
|
||||
expect(formatPlanLabel('team-plus')).toBe('Team Plus')
|
||||
expect(formatPlanLabel('pro trial')).toBe('Pro Trial')
|
||||
})
|
||||
|
||||
it('returns null when there is no usable plan', () => {
|
||||
expect(formatPlanLabel(null)).toBeNull()
|
||||
expect(formatPlanLabel(undefined)).toBeNull()
|
||||
expect(formatPlanLabel('')).toBeNull()
|
||||
expect(formatPlanLabel(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageTextColorClass', () => {
|
||||
it('stays neutral below the 60% caution line', () => {
|
||||
expect(usageTextColorClass(0)).toBe('text-foreground')
|
||||
expect(usageTextColorClass(59)).toBe('text-foreground')
|
||||
})
|
||||
|
||||
it('turns amber in the 60–79% caution band', () => {
|
||||
expect(usageTextColorClass(60)).toBe('text-yellow-500')
|
||||
expect(usageTextColorClass(79)).toBe('text-yellow-500')
|
||||
})
|
||||
|
||||
it('turns red at the 80% critical line and above', () => {
|
||||
expect(usageTextColorClass(80)).toBe('text-red-500')
|
||||
expect(usageTextColorClass(100)).toBe('text-red-500')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
// Pure formatting for the consolidated Usage roster, split out so it can be unit
|
||||
// tested without pulling in React / UI dependencies.
|
||||
|
||||
// "plus" -> "Plus", "chatgpt_business" -> "ChatGPT Business". Codex is the only
|
||||
// provider that reports a plan today; others render just the name.
|
||||
export function formatPlanLabel(planType: string | null | undefined): string | null {
|
||||
const trimmed = planType?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
return trimmed
|
||||
.split(/[\s_-]+/)
|
||||
.map((word) => {
|
||||
const normalized = word.toLowerCase()
|
||||
return normalized === 'chatgpt'
|
||||
? 'ChatGPT'
|
||||
: normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Mirrors barColor's 60/80 thresholds so the number matches its bar; neutral
|
||||
// inherits the foreground color (STYLEGUIDE: color reserved for state).
|
||||
export function usageTextColorClass(usedPercent: number): string {
|
||||
if (usedPercent >= 80) {
|
||||
return 'text-red-500'
|
||||
}
|
||||
if (usedPercent >= 60) {
|
||||
return 'text-yellow-500'
|
||||
}
|
||||
return 'text-foreground'
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3298,6 +3298,19 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open Settings",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"UsageRosterPanel": {
|
||||
"title": "Usage",
|
||||
"openDetails": "Open usage details",
|
||||
"notSignedIn": "not signed in",
|
||||
"allAgents": "all agents",
|
||||
"usageDetails": "Usage details & history",
|
||||
"loadingUsage": "Loading usage…",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noUsageData": "No usage data",
|
||||
"detailed": "Detailed",
|
||||
"compact": "Compact",
|
||||
"footerDetailAria": "Usage footer detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3275,6 +3275,19 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open Settings",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"UsageRosterPanel": {
|
||||
"title": "Usage",
|
||||
"openDetails": "Open usage details",
|
||||
"notSignedIn": "not signed in",
|
||||
"allAgents": "all agents",
|
||||
"usageDetails": "Usage details & history",
|
||||
"loadingUsage": "Loading usage…",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noUsageData": "No usage data",
|
||||
"detailed": "Detailed",
|
||||
"compact": "Compact",
|
||||
"footerDetailAria": "Usage footer detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3275,6 +3275,19 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open Settings",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"UsageRosterPanel": {
|
||||
"title": "Usage",
|
||||
"openDetails": "Open usage details",
|
||||
"notSignedIn": "not signed in",
|
||||
"allAgents": "all agents",
|
||||
"usageDetails": "Usage details & history",
|
||||
"loadingUsage": "Loading usage…",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noUsageData": "No usage data",
|
||||
"detailed": "Detailed",
|
||||
"compact": "Compact",
|
||||
"footerDetailAria": "Usage footer detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3275,6 +3275,19 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open Settings",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"UsageRosterPanel": {
|
||||
"title": "Usage",
|
||||
"openDetails": "Open usage details",
|
||||
"notSignedIn": "not signed in",
|
||||
"allAgents": "all agents",
|
||||
"usageDetails": "Usage details & history",
|
||||
"loadingUsage": "Loading usage…",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noUsageData": "No usage data",
|
||||
"detailed": "Detailed",
|
||||
"compact": "Compact",
|
||||
"footerDetailAria": "Usage footer detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3275,6 +3275,19 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open Settings",
|
||||
"gotIt": "Got it"
|
||||
},
|
||||
"UsageRosterPanel": {
|
||||
"title": "Usage",
|
||||
"openDetails": "Open usage details",
|
||||
"notSignedIn": "not signed in",
|
||||
"allAgents": "all agents",
|
||||
"usageDetails": "Usage details & history",
|
||||
"loadingUsage": "Loading usage…",
|
||||
"usageUnavailable": "Usage unavailable",
|
||||
"noUsageData": "No usage data",
|
||||
"detailed": "Detailed",
|
||||
"compact": "Compact",
|
||||
"footerDetailAria": "Usage footer detail"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -283,6 +283,84 @@ 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('does not share a local read with a remote environment named local', 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 local = fetchProviderAccountsSnapshot(LOCAL)
|
||||
const remote = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'local' })
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(remote).not.toBe(local)
|
||||
expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(1)
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'ready', snapshot: snapshotFixture('remote-local') }
|
||||
})
|
||||
resolveClaude(emptyClaudeState())
|
||||
resolveCodex(emptyCodexState())
|
||||
|
||||
await expect(remote).resolves.toMatchObject({
|
||||
codex: { activeAccountId: 'codex-remote-local' }
|
||||
})
|
||||
await expect(local).resolves.toMatchObject({ codex: { activeAccountId: null } })
|
||||
})
|
||||
|
||||
it('resolves with the first remote snapshot and closes the subscription', async () => {
|
||||
const pending = fetchProviderAccountsSnapshot(REMOTE)
|
||||
await flushMicrotasks()
|
||||
|
||||
@@ -33,6 +33,16 @@ 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 {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
// Why: environment ids are user-controlled strings; prefix the target kind
|
||||
// so a remote id such as “local” cannot share the desktop's pending read.
|
||||
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
|
||||
}
|
||||
|
||||
export function hasRemoteProviderAccountOwner(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
@@ -194,10 +204,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 +225,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(
|
||||
|
||||
@@ -1452,6 +1452,34 @@ describe('createUISlice hydratePersistedUI', () => {
|
||||
expect(store.getState().usagePercentageDisplay).toBe('used')
|
||||
})
|
||||
|
||||
it('persists and hydrates the status bar usage mode', () => {
|
||||
const setUI = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
const store = createUIStore()
|
||||
|
||||
expect(store.getState().statusBarUsageMode).toBe('verbose')
|
||||
|
||||
store.getState().setStatusBarUsageMode('compact')
|
||||
|
||||
expect(store.getState().statusBarUsageMode).toBe('compact')
|
||||
expect(setUI).toHaveBeenCalledWith({ statusBarUsageMode: 'compact' })
|
||||
|
||||
store.getState().hydratePersistedUI(makePersistedUI({ statusBarUsageMode: 'verbose' }))
|
||||
expect(store.getState().statusBarUsageMode).toBe('verbose')
|
||||
})
|
||||
|
||||
it('defaults invalid status bar usage modes to verbose', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
statusBarUsageMode: 'expanded' as PersistedUIState['statusBarUsageMode']
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().statusBarUsageMode).toBe('verbose')
|
||||
})
|
||||
|
||||
it('clamps persisted workspace board column width', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
normalizeUsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
import {
|
||||
DEFAULT_STATUS_BAR_USAGE_MODE,
|
||||
normalizeStatusBarUsageMode,
|
||||
type StatusBarUsageMode
|
||||
} from '../../../../shared/status-bar-usage-mode'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
@@ -878,6 +883,8 @@ export type UISlice = {
|
||||
setStatusBarVisible: (v: boolean) => void
|
||||
usagePercentageDisplay: UsagePercentageDisplay
|
||||
setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void
|
||||
statusBarUsageMode: StatusBarUsageMode
|
||||
setStatusBarUsageMode: (mode: StatusBarUsageMode) => void
|
||||
workspacePortScan: { key: string; result: WorkspacePortScanResult } | null
|
||||
workspacePortScansByKey: Record<string, WorkspacePortScanResult>
|
||||
workspacePortScanRefreshing: boolean
|
||||
@@ -2109,6 +2116,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
usagePercentageDisplayChangeNoticeDismissed: true
|
||||
})
|
||||
},
|
||||
statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE,
|
||||
setStatusBarUsageMode: (mode) => {
|
||||
const normalized = normalizeStatusBarUsageMode(mode)
|
||||
window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error)
|
||||
set({ statusBarUsageMode: normalized })
|
||||
},
|
||||
workspacePortScan: null,
|
||||
workspacePortScansByKey: {},
|
||||
workspacePortScanRefreshing: false,
|
||||
@@ -2377,6 +2390,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
statusBarItems: statusBarItemsWithGrok,
|
||||
statusBarVisible: ui.statusBarVisible ?? true,
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay),
|
||||
statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode),
|
||||
// Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false.
|
||||
petVisible: ui.petVisible ?? ui.sidekickVisible ?? true,
|
||||
petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT),
|
||||
|
||||
@@ -79,6 +79,7 @@ import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cu
|
||||
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
||||
import { normalizeUiLanguage } from '../../../shared/ui-language'
|
||||
import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display'
|
||||
import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode'
|
||||
import type { RateLimitState } from '../../../shared/rate-limit-types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
|
||||
import {
|
||||
@@ -3455,6 +3456,9 @@ function mergeWebUIState(
|
||||
),
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay
|
||||
),
|
||||
statusBarUsageMode: normalizeStatusBarUsageMode(
|
||||
safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order
|
||||
import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy'
|
||||
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy'
|
||||
import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display'
|
||||
import { DEFAULT_STATUS_BAR_USAGE_MODE } from './status-bar-usage-mode'
|
||||
|
||||
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
|
||||
export {
|
||||
@@ -465,6 +466,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
|
||||
statusBarVisible: true,
|
||||
usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE,
|
||||
dismissedUpdateVersion: null,
|
||||
lastUpdateCheckAt: null,
|
||||
trustedOrcaHooks: {},
|
||||
|
||||
@@ -76,6 +76,8 @@ export type ProviderRateLimits = {
|
||||
grantedAt: number | null
|
||||
}[]
|
||||
} | null
|
||||
/** Subscription plan tier for the active account (Codex `plan_type`, e.g. "plus"). */
|
||||
planType?: string | null
|
||||
/** Unix ms timestamp of the last successful data update. */
|
||||
updatedAt: number
|
||||
/** Human-readable error message, null when status is 'ok'. */
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeStatusBarUsageMode } from './status-bar-usage-mode'
|
||||
|
||||
describe('normalizeStatusBarUsageMode', () => {
|
||||
it('defaults missing and invalid values to verbose', () => {
|
||||
expect(normalizeStatusBarUsageMode(undefined)).toBe('verbose')
|
||||
expect(normalizeStatusBarUsageMode('expanded')).toBe('verbose')
|
||||
})
|
||||
|
||||
it('preserves supported modes', () => {
|
||||
expect(normalizeStatusBarUsageMode('verbose')).toBe('verbose')
|
||||
expect(normalizeStatusBarUsageMode('compact')).toBe('compact')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export type StatusBarUsageMode = 'verbose' | 'compact'
|
||||
|
||||
export const DEFAULT_STATUS_BAR_USAGE_MODE: StatusBarUsageMode = 'verbose'
|
||||
|
||||
export function normalizeStatusBarUsageMode(value: unknown): StatusBarUsageMode {
|
||||
return value === 'verbose' || value === 'compact' ? value : DEFAULT_STATUS_BAR_USAGE_MODE
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
LocalWindowsRuntimePreference
|
||||
} from './project-execution-runtime'
|
||||
import type { UsagePercentageDisplay } from './usage-percentage-display'
|
||||
import type { StatusBarUsageMode } from './status-bar-usage-mode'
|
||||
import type { PersistedNativeChatSessionOptions } from './native-chat-session-options'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
@@ -3233,6 +3234,8 @@ export type PersistedUIState = {
|
||||
statusBarVisible: boolean
|
||||
/** Why: this is client-side presentation, not a provider/account or execution-host setting. */
|
||||
usagePercentageDisplay?: UsagePercentageDisplay
|
||||
/** Client-side footer presentation; verbose preserves the pre-roster all-window default. */
|
||||
statusBarUsageMode?: StatusBarUsageMode
|
||||
dismissedUpdateVersion: string | null
|
||||
lastUpdateCheckAt: number | null
|
||||
pendingUpdateNudgeId?: string | null
|
||||
|
||||
Reference in New Issue
Block a user