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:
Brennan Benson
2026-07-20 17:46:09 -07:00
committed by GitHub
parent 3468b434d6
commit 580f8eb492
31 changed files with 1547 additions and 228 deletions
+5
View File
@@ -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'
})
+2
View File
@@ -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 6079% 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'
)
}
}
+13
View File
@@ -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"
}
}
},
+13
View File
@@ -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"
}
}
},
+13
View File
@@ -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"
}
}
},
+13
View File
@@ -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"
}
}
},
+13
View File
@@ -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(
+28
View File
@@ -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()
+14
View File
@@ -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),
+4
View File
@@ -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
)
}
}
+2
View File
@@ -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: {},
+2
View File
@@ -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'. */
+14
View File
@@ -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')
})
})
+7
View File
@@ -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
}
+3
View File
@@ -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