mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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".
This commit is contained in:
@@ -544,6 +544,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:
|
||||
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -53,11 +56,13 @@ import {
|
||||
barColor,
|
||||
clampUsedPercent,
|
||||
formatResetCreditExpiry,
|
||||
getProviderDisplayName,
|
||||
getProviderUsageStatusLabel
|
||||
} from './tooltip'
|
||||
import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { formatWindowLabel } from '@/lib/window-label-formatter'
|
||||
import { UsageRosterPanel } from './UsageRosterPanel'
|
||||
import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart'
|
||||
import { UpdateStatusSegment } from './UpdateStatusSegment'
|
||||
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
|
||||
@@ -81,6 +86,7 @@ import {
|
||||
} from '@/runtime/runtime-provider-accounts-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getDisplayedUsagePercentage,
|
||||
normalizeUsagePercentageDisplay,
|
||||
type UsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
@@ -660,14 +666,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)
|
||||
@@ -832,6 +844,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'
|
||||
@@ -943,21 +957,6 @@ function ClaudeSwitcherMenu({
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mini progress bar (shows consumption / % used, grey)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MiniBar({ usedPct }: { usedPct: number }): React.JSX.Element {
|
||||
return (
|
||||
<div className="w-[48px] h-[6px] rounded-full bg-muted overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-muted-foreground/40"
|
||||
style={{ width: `${clampUsedPercent(usedPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline usage bars (compact bars for inactive accounts in the switcher)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1093,19 +1092,43 @@ 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}
|
||||
{getDisplayedUsagePercentage(w.usedPercent, display)}%{showLabel ? ` ${label}` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Single-letter provider badge for the icon-only (narrow) status bar.
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1171,24 +1194,27 @@ function ProviderSegment({
|
||||
|
||||
if (p.buckets && p.buckets.length > 0) {
|
||||
const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.name))
|
||||
// Roster shows the tightest bucket (highest % used) to stay compact.
|
||||
const tightestBucket =
|
||||
visibleBuckets.length > 0
|
||||
? visibleBuckets.reduce((a, b) => (b.usedPercent > a.usedPercent ? b : a))
|
||||
: null
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon provider={provider} />
|
||||
{visibleBuckets.map((bucket, i) => (
|
||||
<React.Fragment key={bucket.name}>
|
||||
{i > 0 && <span className="text-muted-foreground">·</span>}
|
||||
<span className="tabular-nums">
|
||||
{bucket.name} {formatUsagePercentageLabel(bucket.usedPercent, display)}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{visibleBuckets.length === 0 && p.session && (
|
||||
{tightestBucket ? (
|
||||
<span className="tabular-nums">
|
||||
{tightestBucket.name} {getDisplayedUsagePercentage(tightestBucket.usedPercent, display)}
|
||||
%
|
||||
</span>
|
||||
) : p.session ? (
|
||||
<WindowLabel
|
||||
w={p.session}
|
||||
label={formatWindowLabel(p.session.windowMinutes)}
|
||||
display={display}
|
||||
showLabel={!compact}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
|
||||
</span>
|
||||
)
|
||||
@@ -1218,29 +1244,41 @@ function ProviderSegment({
|
||||
: null
|
||||
].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null)
|
||||
|
||||
// Roster: show only the tightest window (highest % used) with a threshold-colored
|
||||
// number so the footer stays compact; the dropdown still lists every window.
|
||||
const tightestWindow =
|
||||
visibleWindows.length > 0
|
||||
? visibleWindows.reduce((a, b) => (b.window.usedPercent > a.window.usedPercent ? b : a))
|
||||
: null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon provider={provider} />
|
||||
{p.session && !compact && <MiniBar usedPct={clampUsedPercent(p.session.usedPercent)} />}
|
||||
{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>
|
||||
))}
|
||||
{tightestWindow && (
|
||||
<WindowLabel
|
||||
w={tightestWindow.window}
|
||||
label={tightestWindow.label}
|
||||
display={display}
|
||||
showLabel={!compact}
|
||||
/>
|
||||
)}
|
||||
{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)
|
||||
@@ -1516,6 +1554,8 @@ function CodexSwitcherMenu({
|
||||
provider={codex}
|
||||
compact={compact}
|
||||
iconOnly={iconOnly}
|
||||
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
|
||||
@@ -1746,7 +1786,9 @@ export function ProviderDetailsMenu({
|
||||
hidePanelResetCredits = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
children
|
||||
children,
|
||||
asSubmenu = false,
|
||||
triggerContent
|
||||
}: {
|
||||
provider: ProviderRateLimits
|
||||
compact: boolean
|
||||
@@ -1757,6 +1799,10 @@ 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(
|
||||
@@ -1772,6 +1818,41 @@ export function ProviderDetailsMenu({
|
||||
onOpenChange?.(nextOpen)
|
||||
}
|
||||
|
||||
const panelBody = (
|
||||
<>
|
||||
{topContent}
|
||||
<div className="p-2">
|
||||
<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
|
||||
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>
|
||||
@@ -1785,23 +1866,7 @@ export function ProviderDetailsMenu({
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly || provider.fableWeekly ? '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 className="text-muted-foreground">{getProviderLetter(provider.provider)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<ProviderSegment p={provider} compact={compact} display={usagePercentageDisplay} />
|
||||
@@ -1857,6 +1922,12 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
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 [usageMenuOpen, setUsageMenuOpen] = useState(false)
|
||||
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
|
||||
const statusBarItems = useAppStore((s) => s.statusBarItems)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
@@ -2059,6 +2130,35 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
// floating-workspace activity and clears via the shared unread paths.
|
||||
const showFloatingWorkspaceAttentionDot = !floatingTerminalOpen && hasFloatingUnread
|
||||
|
||||
// Consolidated roster: the leading pill dot reflects the worst usage tier across
|
||||
// every visible meter.
|
||||
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 => {
|
||||
setUsageMenuOpen(false)
|
||||
openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: `accounts-${provider}` })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRefCallback}
|
||||
@@ -2086,81 +2186,107 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
|
||||
<StatusBarUsageEmptyCta />
|
||||
) : null
|
||||
) : (
|
||||
// Why: one-time usage-display change callout anchors to this cluster so
|
||||
// it sits next to the meters the user is confused by, not a global toast.
|
||||
// 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={setUsageMenuOpen} 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}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground"
|
||||
title={getProviderDisplayName(p.provider)}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
p.session || p.weekly || p.fableWeekly || p.monthly || p.buckets?.length
|
||||
? 'bg-muted-foreground/60'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
{getProviderLetter(p.provider)}
|
||||
</span>
|
||||
) : (
|
||||
<ProviderSegment
|
||||
key={p.provider}
|
||||
p={p}
|
||||
compact={compact}
|
||||
display={usagePercentageDisplay}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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"
|
||||
>
|
||||
<UsageRosterPanel
|
||||
providers={rosterProviders}
|
||||
display={usagePercentageDisplay}
|
||||
isRefreshing={isRefreshing || anyFetching}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenProvider={handleOpenProviderAccounts}
|
||||
onSignIn={handleOpenProviderAccounts}
|
||||
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>
|
||||
)}
|
||||
{anyVisible && !isEmptyUsageState && (
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react'
|
||||
import { ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { 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'
|
||||
|
||||
type ProviderId = ProviderRateLimits['provider']
|
||||
type Section = { label: string; window: RateLimitWindow }
|
||||
|
||||
// Windows/buckets that actually carry data — the null ones are absent limits.
|
||||
function usedSections(p: ProviderRateLimits): Section[] {
|
||||
return getWindowSections(p).filter((s): s is Section => s.window !== null)
|
||||
}
|
||||
|
||||
function providerMaxUsed(sections: Section[]): 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: Section): 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 formatWindowLabel(section.window.windowMinutes)
|
||||
}
|
||||
|
||||
// The soonest-resetting window summarizes the agent's next reset in one line.
|
||||
function soonestResetLabel(sections: Section[]): string | null {
|
||||
const resets = sections
|
||||
.map((s) => s.window.resetsAt)
|
||||
.filter((r): r is number => typeof r === 'number')
|
||||
if (resets.length === 0) {
|
||||
return null
|
||||
}
|
||||
return formatResetCountdown(Math.min(...resets) - Date.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,
|
||||
onSignIn
|
||||
}: {
|
||||
p: ProviderRateLimits
|
||||
display: UsagePercentageDisplay
|
||||
onSignIn: (provider: ProviderId) => void
|
||||
}): React.JSX.Element {
|
||||
const sections = usedSections(p)
|
||||
const signedOut = sections.length === 0
|
||||
const name = getProviderDisplayName(p.provider)
|
||||
const plan = formatPlanLabel(p.planType)
|
||||
const reset = signedOut ? null : soonestResetLabel(sections)
|
||||
|
||||
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>
|
||||
{!signedOut && reset ? (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{reset}</span>
|
||||
) : null}
|
||||
{signedOut ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSignIn(p.provider)
|
||||
}}
|
||||
className="ml-auto shrink-0 rounded-md border border-border bg-secondary px-2.5 py-0.5 text-xs text-foreground hover:bg-accent"
|
||||
>
|
||||
{translate('auto.components.status.bar.StatusBar.c35af53b73', 'Sign in')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{signedOut ? (
|
||||
<div className="pl-[30px] text-[11px] text-muted-foreground">
|
||||
{translate('auto.components.status.bar.UsageRosterPanel.notSignedIn', 'not signed in')}
|
||||
</div>
|
||||
) : (
|
||||
<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: `${used}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={`tabular-nums text-[11px] ${usageTextColorClass(used)}`}>
|
||||
{shown}%
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onOpenProvider,
|
||||
onSignIn,
|
||||
onManageAccounts,
|
||||
onUsageDetails,
|
||||
renderRow
|
||||
}: {
|
||||
providers: ProviderRateLimits[]
|
||||
display: UsagePercentageDisplay
|
||||
isRefreshing: boolean
|
||||
onRefresh: () => void
|
||||
onOpenProvider: (provider: ProviderId) => void
|
||||
onSignIn: (provider: ProviderId) => void
|
||||
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 {
|
||||
// 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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.StatusBar.3325d996cb',
|
||||
'Refresh rate limits'
|
||||
)}
|
||||
className="rounded p-0.5 hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw size={12} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/70" />
|
||||
{sorted.map((p) => {
|
||||
const rowNode = <UsageRow p={p} display={display} onSignIn={onSignIn} />
|
||||
const custom = renderRow?.(p, rowNode)
|
||||
if (custom) {
|
||||
return <React.Fragment key={p.provider}>{custom}</React.Fragment>
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={p.provider}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenProvider(p.provider)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenProvider(p.provider)
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer items-center px-3.5 py-2.5 hover:bg-accent/60"
|
||||
>
|
||||
{rowNode}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-border/70" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUsageDetails}
|
||||
className="flex w-full items-center justify-between px-3.5 py-2.5 text-[13px] text-foreground hover:bg-accent/60"
|
||||
>
|
||||
{translate(
|
||||
'auto.components.status.bar.UsageRosterPanel.usageDetails',
|
||||
'Usage details & history'
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageAccounts}
|
||||
className="flex w-full items-center justify-between px-3.5 py-2.5 text-[13px] text-foreground hover:bg-accent/60"
|
||||
>
|
||||
{translate('auto.components.status.bar.StatusBar.75ded02687', 'Manage Accounts…')}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -518,11 +518,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')
|
||||
|
||||
@@ -201,10 +201,12 @@ export function getWindowSections(
|
||||
|
||||
// Why: color-coded by consumption so users can quickly gauge urgency.
|
||||
// Matches common harness usage meters (Claude/Codex): bars fill with % used.
|
||||
// Green = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%).
|
||||
// Neutral = comfortable (<60% used), yellow = caution (60-80%), red = critical (≥80%).
|
||||
// Why: the <60 band is neutral (not green) so the always-visible meter stays quiet
|
||||
// until a limit nears — color = state (STYLEGUIDE) — and matches MiniBar's grey fill.
|
||||
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,40 @@
|
||||
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('team-plus')).toBe('Team Plus')
|
||||
expect(formatPlanLabel('pro trial')).toBe('Pro Trial')
|
||||
})
|
||||
|
||||
it('returns null when there is no usable plan', () => {
|
||||
expect(formatPlanLabel(null)).toBeNull()
|
||||
expect(formatPlanLabel(undefined)).toBeNull()
|
||||
expect(formatPlanLabel('')).toBeNull()
|
||||
expect(formatPlanLabel(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageTextColorClass', () => {
|
||||
it('stays neutral below the 60% caution line', () => {
|
||||
expect(usageTextColorClass(0)).toBe('text-foreground')
|
||||
expect(usageTextColorClass(59)).toBe('text-foreground')
|
||||
})
|
||||
|
||||
it('turns amber in the 60–79% caution band', () => {
|
||||
expect(usageTextColorClass(60)).toBe('text-yellow-500')
|
||||
expect(usageTextColorClass(79)).toBe('text-yellow-500')
|
||||
})
|
||||
|
||||
it('turns red at the 80% critical line and above', () => {
|
||||
expect(usageTextColorClass(80)).toBe('text-red-500')
|
||||
expect(usageTextColorClass(100)).toBe('text-red-500')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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) => word.charAt(0).toUpperCase() + word.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'
|
||||
}
|
||||
@@ -3230,6 +3230,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3230,6 +3230,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3230,6 +3230,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3230,6 +3230,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3230,6 +3230,13 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'. */
|
||||
|
||||
Reference in New Issue
Block a user