From 580f8eb4924d1abfe9db5089dc5fcd78320d88fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:46:09 -0700 Subject: [PATCH] feat(status-bar): consolidate agent usage into a single roster popover (#8761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- src/main/persistence.ts | 5 + .../rate-limits/codex-fetcher-backend.test.ts | 1 + src/main/rate-limits/codex-fetcher.ts | 2 + .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../src/components/status-bar/StatusBar.tsx | 620 ++++++++++++------ .../status-bar/UsageRosterPanel.test.tsx | 206 ++++++ .../status-bar/UsageRosterPanel.tsx | 306 +++++++++ .../provider-segment-monthly-window.test.tsx | 72 +- .../src/components/status-bar/tooltip.test.ts | 11 +- .../src/components/status-bar/tooltip.tsx | 7 +- .../usage-provider-settings-target.test.ts | 18 + .../usage-provider-settings-target.ts | 25 + .../usage-roster-formatting.test.ts | 41 ++ .../status-bar/usage-roster-formatting.ts | 32 + .../status-bar/usage-roster-row-state.test.ts | 104 +++ .../status-bar/usage-roster-row-state.ts | 77 +++ src/renderer/src/i18n/locales/en.json | 13 + src/renderer/src/i18n/locales/es.json | 13 + src/renderer/src/i18n/locales/ja.json | 13 + src/renderer/src/i18n/locales/ko.json | 13 + src/renderer/src/i18n/locales/zh.json | 13 + .../runtime-provider-accounts-client.test.ts | 78 +++ .../runtime-provider-accounts-client.ts | 30 +- src/renderer/src/store/slices/ui.test.ts | 28 + src/renderer/src/store/slices/ui.ts | 14 + src/renderer/src/web/web-preload-api.ts | 4 + src/shared/constants.ts | 2 + src/shared/rate-limit-types.ts | 2 + src/shared/status-bar-usage-mode.test.ts | 14 + src/shared/status-bar-usage-mode.ts | 7 + src/shared/types.ts | 3 + 31 files changed, 1547 insertions(+), 228 deletions(-) create mode 100644 src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx create mode 100644 src/renderer/src/components/status-bar/UsageRosterPanel.tsx create mode 100644 src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-provider-settings-target.ts create mode 100644 src/renderer/src/components/status-bar/usage-roster-formatting.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-roster-formatting.ts create mode 100644 src/renderer/src/components/status-bar/usage-roster-row-state.test.ts create mode 100644 src/renderer/src/components/status-bar/usage-roster-row-state.ts create mode 100644 src/shared/status-bar-usage-mode.test.ts create mode 100644 src/shared/status-bar-usage-mode.ts diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2e5b8255367..0fb057c8c7b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 ), diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index d345d94a5bf..9102e54ab09 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -81,6 +81,7 @@ describe('Codex backend rate-limit requests', () => { availableCount: 1, nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') }, + planType: 'plus', status: 'ok' }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 77eef555a68..dd328e007d5 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -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: diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index b5adcf617b4..48d014b0de9 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index ec626421357..3fd5a3d72c0 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -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({ ) } -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 ( - {formatUsagePercentageLabel(w.usedPercent, display)} {label} + {formatUsagePercentageLabel(w.usedPercent, display)} + {showLabel ? ` ${label}` : ''} ) } -// 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 ( + + + {getProviderLetter(p.provider)} + + ) +} + +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 ( - - - ··· - - ) - } - - // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { - return ( - - - ··· - - ) - } - - // Unavailable (CLI not installed) - if (p.status === 'unavailable') { - return ( - - -- - - ) - } - - // Error with no data - if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { - return ( - - - - {!compact && {statusLabel}} - - ) - } - - // 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 ( - - - {visibleBuckets.map((bucket, i) => ( + <> + {visibleBuckets.map((bucket, index) => ( - {i > 0 && ·} + {index > 0 ? · : null} {bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)} ))} - {visibleBuckets.length === 0 && p.session && ( + {visibleBuckets.length === 0 && p.session ? ( - )} - {isStale && } - + ) : 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 ? ( + + ) : null} + {visibleWindows.map((window, index) => ( + + {index > 0 ? · : null} + + + ))} + + ) +} + +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 ( + + + ··· + + ) + } + + const tightest = getTightestUsageSection(p) + + // Fetching with no prior data + if (p.status === 'fetching' && !tightest) { + return ( + + + ··· + + ) + } + + // Unavailable (CLI not installed) + if (p.status === 'unavailable') { + return ( + + -- + + ) + } + + // Error with no data + if (p.status === 'error' && !tightest) { + return ( + + + + {!compact && {statusLabel}} + + ) + } + + // Has data (ok, fetching with stale data, or error with stale data) + const isStale = p.status === 'error' return ( - {p.session && !compact && ( - + {mode === 'verbose' ? ( + + ) : ( + <> + {tightest && !compact ? ( + + ) : null} + {tightest ? ( + + ) : null} + )} - {visibleWindows.map((window, index) => ( - - {index > 0 && ·} - - - ))} {isStale && } ) } -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} > - + {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} +
+ {/* Why: provider-specific action sections may render richer reset-credit UI. */} + +
+ {children ? ( + <> + + {children} + + ) : null} + + ) + + if (asSubmenu) { + return ( + + + {triggerContent} + + + {panelBody} + + + ) + } + return ( @@ -1758,65 +1909,22 @@ export function ProviderDetailsMenu({ aria-label={ariaLabel} > {iconOnly ? ( - - - - {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'} - - + ) : ( )} { - 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} -
- {/* Why: provider-specific action sections may render richer reset-credit UI. */} - -
- {children ? ( - <> - - {children} - - ) : null} + {panelBody}
) @@ -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 (
) : 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). - {showClaude && ( - - )} - {showCodex && ( - - )} - {showGemini && ( - - )} - {showAntigravity && ( - - )} - {showOpencodeGo && ( - - )} - {showKimi && ( - - )} - {showMiniMax && ( - - )} - {showGrok && ( - - )} + + + + + + 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 ( + + ) + } + if (p.provider === 'codex') { + return ( + + ) + } + return ( + + ) + }} + /> + + - )} + ) : null} {anyVisible && !isEmptyUsageState && ( diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx new file mode 100644 index 00000000000..c61faf88367 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -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 }) => +})) +vi.mock('@/hooks/useResetCountdownClock', () => ({ + useResetCountdownClock: mocks.useResetCountdownClock +})) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + onSelect: _onSelect, + ...props + }: React.PropsWithChildren<{ onSelect?: () => void }>) =>
{children}
+})) + +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( + + ) + + expect(markup).toContain('not signed in') + expect(markup).toContain('Sign in') + expect(markup).not.toContain(' { + const markup = renderToStaticMarkup( + + ) + + 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( + {}} + 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( + {}} + 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') + }) +}) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx new file mode 100644 index 00000000000..fa11825d140 --- /dev/null +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -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 ( +
+
+ + + + + {name} + {plan ? · {plan} : null} + + {!hasUsage ? ( + <> + + {state.statusLabel} + + {showSignInAction ? ( + + {translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')} + + ) : null} + + ) : reset ? ( + {reset} + ) : null} +
+ {hasUsage ? ( +
+ {sections.map((s) => { + const used = clampUsedPercent(s.window.usedPercent) + const shown = getDisplayedUsagePercentage(s.window.usedPercent, display) + return ( + + {shortLabel(p, s)} + + + + + {shown}% + + + ) + })} +
+ ) : null} +
+ ) +} + +/** + * 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 ( +
+
+ + {translate('auto.components.status.bar.UsageRosterPanel.title', 'Usage')} + +
+ + {translate('auto.components.status.bar.UsageRosterPanel.allAgents', 'all agents')} + + { + event.preventDefault() + onRefresh() + }} + aria-label={translate( + 'auto.components.status.bar.StatusBar.3325d996cb', + 'Refresh rate limits' + )} + className="size-5 justify-center p-0" + > + + +
+
+ {/* Density picker lives at the top of the popover it controls (view-switcher + pattern) so both modes are named and discoverable on first open. */} +
+ + 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') + } + ]} + /> +
+
+ {sorted.map((p) => { + const state = getUsageRosterRowState(p, usedSections(p).length > 0) + const showSignInAction = state.kind === 'sign-in' && canSignIn(p.provider) + const rowNode = ( + + ) + if (showSignInAction) { + return ( + onSignIn(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" + > + {rowNode} + + ) + } + const custom = renderRow?.(p, rowNode) + if (custom) { + return {custom} + } + return ( + onOpenProvider(p.provider)} + className="w-full cursor-pointer rounded-none px-3.5 py-2.5" + > + {rowNode} + + ) + })} +
+ + {translate( + 'auto.components.status.bar.UsageRosterPanel.usageDetails', + 'Usage details & history' + )} + + + + {translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')} + + +
+ ) +} diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 09919903161..ef4ec96e0ef 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -48,7 +48,7 @@ describe('ProviderSegment monthly window', () => { const { ProviderSegment } = await import('./StatusBar') const markup = renderToStaticMarkup( - + ) expect(markup).toContain('25% used 30d') @@ -58,16 +58,19 @@ describe('ProviderSegment monthly window', () => { const { ProviderSegment } = await import('./StatusBar') const markup = renderToStaticMarkup( - + ) 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( - + ) - 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( + + ) + + 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( - + ) 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( + + ) + + expect(markup).toContain('10% used 5h') + expect(markup).toContain('20% used wk') + expect(markup).toContain('30% used Fable') + expect(markup).not.toContain('40% used') + }) }) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index ed80c7b9ab8..2dc23cf100a 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -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') diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 2a45500ddb8..fecb07d0d48 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -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' diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts new file mode 100644 index 00000000000..cbcfcda0492 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' + +describe('getUsageProviderAccountsSectionId', () => { + it('routes providers only to settings sections that exist', () => { + expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') + expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') + expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') + expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') + expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') + }) + + it('does not invent an Accounts section for CLI-owned Kimi credentials', () => { + expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts new file mode 100644 index 00000000000..59ea7104249 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -0,0 +1,25 @@ +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +export function getUsageProviderAccountsSectionId( + provider: ProviderRateLimits['provider'] +): string | null { + switch (provider) { + case 'claude': + return 'accounts-claude' + case 'codex': + return 'accounts-codex' + case 'gemini': + case 'antigravity': + // Why: Antigravity usage currently shares Gemini's OAuth configuration. + return 'accounts-gemini' + case 'opencode-go': + return 'accounts-opencode-go' + case 'minimax': + return 'accounts-minimax' + case 'grok': + return 'accounts-grok' + case 'kimi': + // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + return null + } +} diff --git a/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts b/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts new file mode 100644 index 00000000000..a4e71244731 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-formatting.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-roster-formatting.ts b/src/renderer/src/components/status-bar/usage-roster-formatting.ts new file mode 100644 index 00000000000..614b540b5a5 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-formatting.ts @@ -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' +} diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts new file mode 100644 index 00000000000..b63a9a3ad5b --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getUsageRosterRowState } from './usage-roster-row-state' + +function provider(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'ok', + ...overrides + } +} + +describe('getUsageRosterRowState', () => { + it('keeps fetching providers in a loading state instead of calling them signed out', () => { + expect(getUsageRosterRowState(provider({ status: 'fetching' }), false)).toEqual({ + kind: 'loading', + statusLabel: 'Loading usage…' + }) + }) + + it('preserves transient Claude failure copy instead of offering sign-in', () => { + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'OAuth token is stale', + usageMetadata: { failureKind: 'stale-token' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refreshing sign-in' }) + expect( + getUsageRosterRowState( + provider({ + status: 'error', + error: 'network unavailable', + usageMetadata: { failureKind: 'network' } + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Network issue' }) + }) + + it('offers sign-in only for confirmed signed-out failures', () => { + expect( + getUsageRosterRowState( + provider({ status: 'error', usageMetadata: { failureKind: 'missing-credentials' } }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + expect( + getUsageRosterRowState( + provider({ + provider: 'codex', + status: 'error', + error: 'ChatGPT authentication required to read rate limits' + }), + false + ) + ).toEqual({ kind: 'sign-in', statusLabel: 'not signed in' }) + }) + + it('does not turn an expired CLI-owned Kimi token into a sign-in action', () => { + expect( + getUsageRosterRowState( + provider({ + provider: 'kimi', + status: 'error', + error: 'Kimi token expired — open Kimi to refresh' + }), + false + ) + ).toEqual({ kind: 'error', statusLabel: 'Refresh failed' }) + }) + + it('distinguishes unavailable and empty successful responses', () => { + expect( + getUsageRosterRowState( + provider({ status: 'unavailable', error: 'Claude CLI not found' }), + false + ) + ).toEqual({ kind: 'unavailable', statusLabel: 'Usage unavailable' }) + expect(getUsageRosterRowState(provider(), false)).toEqual({ + kind: 'empty', + statusLabel: 'No usage data' + }) + }) + + it('lets real usage data win over a stale error status', () => { + expect(getUsageRosterRowState(provider({ status: 'error' }), true)).toEqual({ + kind: 'usage', + statusLabel: null + }) + }) +}) diff --git a/src/renderer/src/components/status-bar/usage-roster-row-state.ts b/src/renderer/src/components/status-bar/usage-roster-row-state.ts new file mode 100644 index 00000000000..68762327599 --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-roster-row-state.ts @@ -0,0 +1,77 @@ +import { translate } from '@/i18n/i18n' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { getProviderUsageStatusLabel } from './usage-error-copy' + +export type UsageRosterRowState = { + kind: 'usage' | 'loading' | 'sign-in' | 'unavailable' | 'error' | 'empty' + statusLabel: string | null +} + +const CONFIRMED_SIGN_OUT_PATTERNS = [ + /\bnot signed in\b/i, + /\bnot logged in\b/i, + /\blogged out\b/i, + /\bauthentication required\b/i, + /\b(?:sign|log)[ -]?in required\b/i, + /\bplease (?:sign|log) in\b/i, + /\bplease reauthenticate\b/i +] + +function isConfirmedSignedOut(provider: ProviderRateLimits): boolean { + if (provider.usageMetadata?.failureKind === 'missing-credentials') { + return true + } + // Why: credential refresh and network failures can mention auth while live + // sessions remain valid; only explicit signed-out copy earns a sign-in CTA. + if (provider.usageMetadata?.failureKind) { + return false + } + const error = provider.error + return Boolean(error && CONFIRMED_SIGN_OUT_PATTERNS.some((pattern) => pattern.test(error))) +} + +export function getUsageRosterRowState( + provider: ProviderRateLimits, + hasUsage: boolean +): UsageRosterRowState { + if (hasUsage) { + return { kind: 'usage', statusLabel: null } + } + if (provider.status === 'idle' || provider.status === 'fetching') { + return { + kind: 'loading', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.loadingUsage', + 'Loading usage…' + ) + } + } + if (isConfirmedSignedOut(provider)) { + return { + kind: 'sign-in', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.notSignedIn', + 'not signed in' + ) + } + } + if (provider.status === 'error') { + return { kind: 'error', statusLabel: getProviderUsageStatusLabel(provider) } + } + if (provider.status === 'unavailable') { + return { + kind: 'unavailable', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.usageUnavailable', + 'Usage unavailable' + ) + } + } + return { + kind: 'empty', + statusLabel: translate( + 'auto.components.status.bar.UsageRosterPanel.noUsageData', + 'No usage data' + ) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e5f93b0bcb0..8924345d3cc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de5d52e3224..475da5a98b7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b356273ee0a..c4465f1a46c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 12e2c9b75a3..a67574c5e58 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 93edb6ba3a1..8a772231435 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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" } } }, diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index c53d7198a85..2bd3f65e8d9 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -283,6 +283,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((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockImplementation( + () => new Promise((resolve) => (resolveCodex = resolve)) + ) + + const first = fetchProviderAccountsSnapshot(LOCAL) + const second = fetchProviderAccountsSnapshot(LOCAL) + + expect(second).toBe(first) + expect(claudeListLocal).toHaveBeenCalledTimes(1) + expect(codexListLocal).toHaveBeenCalledTimes(1) + + resolveClaude(emptyClaudeState()) + resolveCodex(emptyCodexState()) + await Promise.all([first, second]) + + claudeListLocal.mockResolvedValue(emptyClaudeState()) + codexListLocal.mockResolvedValue(emptyCodexState()) + await fetchProviderAccountsSnapshot(LOCAL) + expect(claudeListLocal).toHaveBeenCalledTimes(2) + expect(codexListLocal).toHaveBeenCalledTimes(2) + }) + + it('isolates in-flight snapshots by remote account owner', async () => { + const first = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-1' }) + const second = fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: 'env-2' }) + await flushMicrotasks() + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledTimes(2) + const firstCallbacks = runtimeEnvironmentSubscribe.mock.calls[0]?.[1] as SubscriptionCallbacks + const secondCallbacks = runtimeEnvironmentSubscribe.mock.calls[1]?.[1] as SubscriptionCallbacks + firstCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('one') } + }) + secondCallbacks.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('two') } + }) + + await expect(first).resolves.toMatchObject({ codex: { activeAccountId: 'codex-one' } }) + await expect(second).resolves.toMatchObject({ codex: { activeAccountId: 'codex-two' } }) + }) + + it('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((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockImplementation( + () => new Promise((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() diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index ec579069c87..6a43ddf8a6e 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -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>() + +function getProviderAccountsOwnerKey( + settings: Pick | 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 | 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 | null | undefined ): Promise { - return await new Promise((resolve, reject) => { + const ownerKey = getProviderAccountsOwnerKey(settings) + const pending = pendingProviderAccountsSnapshots.get(ownerKey) + if (pending) { + return pending + } + + const request = new Promise((resolve, reject) => { const watcher = watchProviderAccounts(settings, { onSnapshot: (snapshot) => { watcher.close() @@ -209,6 +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( diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index f7fa68279f1..b9d9454de08 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index fc0d5eba962..1a46226d356 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 workspacePortScanRefreshing: boolean @@ -2109,6 +2116,12 @@ export const createUISlice: StateCreator = (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 = (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), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f65fe06075e..ba97c10f127 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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 ) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d33cbd7e1f9..a9dc020ac3f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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: {}, diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 0e77decc76e..46e79b79ac2 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -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'. */ diff --git a/src/shared/status-bar-usage-mode.test.ts b/src/shared/status-bar-usage-mode.test.ts new file mode 100644 index 00000000000..f5d2e3e384c --- /dev/null +++ b/src/shared/status-bar-usage-mode.test.ts @@ -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') + }) +}) diff --git a/src/shared/status-bar-usage-mode.ts b/src/shared/status-bar-usage-mode.ts new file mode 100644 index 00000000000..aa780e92909 --- /dev/null +++ b/src/shared/status-bar-usage-mode.ts @@ -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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 20924539f0f..438a831648b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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