Flip usage bars from percent-remaining to percent-used (#8167)

Aligns status bar, tooltip, popover mocks, and mobile usage bars with the
Claude/Codex harness convention (consumption meters) so a fresh account
reads empty/green and a depleted one reads full/red, instead of the
inverted "left" framing that misread as "full = exhausted".
This commit is contained in:
Jinjing 2026-07-10 15:39:38 -07:00 committed by GitHub
parent b93a7eece1
commit b9ccc0d17f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 162 additions and 97 deletions

View File

@ -21,9 +21,9 @@ export {
hasRenderableUsage
} from './account-usage-state'
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
// percent used. Color thresholds invert accordingly.
// Why: matches desktop StatusBar — bars show percent used (consumption), same
// as Claude/Codex harness meters. Fresh account is empty/green; depleted is
// full/red.
export function UsageBar({
label,
usedPercent,
@ -35,13 +35,15 @@ export function UsageBar({
unavailable: boolean
loading?: boolean
}) {
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
// Why: same consumption bands as desktop barColor (green <60, amber <80, red ≥80).
const barColor =
remaining == null
used == null
? colors.textMuted
: remaining <= 10
: used >= 80
? colors.statusRed
: remaining <= 30
: used >= 60
? colors.statusAmber
: colors.statusGreen
return (
@ -52,7 +54,7 @@ export function UsageBar({
style={[
styles.usageFill,
{
width: `${remaining ?? 0}%`,
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
@ -61,9 +63,7 @@ export function UsageBar({
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
</Text>
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
</View>
)

View File

@ -2433,7 +2433,7 @@
}
/* Brief one-shot pulse when the Codex limits popover swaps accounts and the
"100% left" label flashes from red to green. */
"0% used" label flashes from red to green. */
@keyframes feature-wall-meta-pulse {
0% {
color: rgb(220 38 38);

View File

@ -77,10 +77,11 @@ function Popover(props: {
pulseKey: number
}): JSX.Element {
const { expanded, targeted, swapped, pulseKey } = props
const sessionPctText = swapped ? '100% left' : '4% left'
// Why: bars show % used (consumption), matching the live status-bar meter.
const sessionPctText = swapped ? '0% used' : '96% used'
const sessionResetText = swapped ? 'Resets in 5h' : 'Resets in 47m'
const sessionFillWidth = swapped ? '100%' : '4%'
const weeklyFillWidth = '62%'
const sessionFillWidth = swapped ? '0%' : '96%'
const weeklyFillWidth = '38%'
return (
<div
@ -144,7 +145,7 @@ function Popover(props: {
<span>
{translate(
'auto.components.feature.wall.agents.orchestration.UsagePage.05ce4ecdd3',
'62% left'
'38% used'
)}
</span>
}
@ -201,15 +202,15 @@ function Popover(props: {
<SwitchAccount
accountWidthClassName="w-24"
tag="Team"
fillPct={100}
metaText="100%"
fillPct={0}
metaText="0%"
highlighted={targeted}
/>
<SwitchAccount
accountWidthClassName="w-32"
tag={null}
fillPct={78}
metaText="78%"
fillPct={22}
metaText="22%"
highlighted={false}
/>
</div>
@ -295,11 +296,12 @@ function SwitchAccount(props: {
// The 340px-wide light pill at the bottom of the panel that the popover tip
// points down to. Codex chip is the "active" one — it animates from a near-
// empty red bar to a full green bar after the account swap.
// full red bar (% used) to an empty green bar after the account swap.
function BottomBar(props: { swapped: boolean }): JSX.Element {
const codexFillWidth = props.swapped ? '100%' : '4%'
// Why: match live status-bar consumption meters (% used), same as the popover.
const codexFillWidth = props.swapped ? '0%' : '96%'
const codexFillColor = props.swapped ? 'rgb(34 197 94)' : 'rgb(239 68 68)'
const codexMeta = props.swapped ? '100% 5h · 96% wk' : '4% 47m'
const codexMeta = props.swapped ? '0% used 5h · 4% used wk' : '96% used 47m'
return (
<div
className="absolute bottom-[22px] left-1/2 flex -translate-x-1/2 items-center gap-3.5 rounded-lg border border-border bg-muted/60 px-3.5 py-1.5 text-[11px] shadow-[0_1px_2px_rgba(24,24,27,0.04)]"
@ -308,12 +310,12 @@ function BottomBar(props: { swapped: boolean }): JSX.Element {
<div className="inline-flex items-center gap-1.5 font-mono text-[10.5px] text-muted-foreground">
<ClaudeIcon size={12} />
<span className="block h-1 w-9 overflow-hidden rounded-full bg-foreground/[0.12]">
<span className="block h-full rounded-full bg-emerald-500" style={{ width: '71%' }} />
<span className="block h-full rounded-full bg-emerald-500" style={{ width: '29%' }} />
</span>
<span>
{translate(
'auto.components.feature.wall.agents.orchestration.UsagePage.64265cb295',
'71% 5h'
'29% used 5h'
)}
</span>
</div>

View File

@ -51,6 +51,7 @@ import {
ProviderIcon,
ProviderPanel,
barColor,
clampUsedPercent,
formatResetCreditExpiry,
getProviderUsageStatusLabel
} from './tooltip'
@ -932,15 +933,15 @@ function ClaudeSwitcherMenu({
}
// ---------------------------------------------------------------------------
// Mini progress bar (shows remaining capacity, grey)
// Mini progress bar (shows consumption / % used, grey)
// ---------------------------------------------------------------------------
function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element {
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: `${Math.min(100, Math.max(0, leftPct))}%` }}
style={{ width: `${clampUsedPercent(usedPct)}%` }}
/>
</div>
)
@ -957,29 +958,32 @@ export function InlineUsageBars({
limits: ProviderRateLimits
isFetching: boolean
}): React.JSX.Element {
// Why: show % used (consumption), not remaining — matches harness meters (#7551).
// Keep the "used" word in compact labels so bare "32% 5h" is not read as remaining.
const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')
const usageWindows = [
limits.session
? {
key: 'session',
left: Math.max(0, Math.round(100 - limits.session.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')
used: clampUsedPercent(limits.session.usedPercent),
label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '5h')
}
: null,
limits.weekly
? {
key: 'weekly',
left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')
used: clampUsedPercent(limits.weekly.usedPercent),
label: translate('auto.components.status.bar.StatusBar.5c938d39ac', 'wk')
}
: null,
limits.fableWeekly
? {
key: 'fableWeekly',
left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')
used: clampUsedPercent(limits.fableWeekly.usedPercent),
label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', 'Fable')
}
: null
].filter((window): window is { key: string; left: number; label: string } => window !== null)
].filter((window): window is { key: string; used: number; label: string } => window !== null)
return (
<div
@ -992,13 +996,13 @@ export function InlineUsageBars({
<div key={window.key} className="flex min-w-0 items-center gap-1">
<div className="h-[4px] min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${barColor(window.left)}`}
style={{ width: `${window.left}%` }}
className={`h-full rounded-full ${barColor(window.used)}`}
style={{ width: `${window.used}%` }}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{window.left}
{window.label}
{window.used}
{usedSuffix} {window.label}
</span>
</div>
))}
@ -1071,14 +1075,16 @@ function InlineUsageSkeleton(): React.JSX.Element {
}
// ---------------------------------------------------------------------------
// Window label (shows percent remaining)
// Window label (shows percent used / consumption)
// ---------------------------------------------------------------------------
function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element {
const left = Math.max(0, Math.round(100 - w.usedPercent))
const used = clampUsedPercent(w.usedPercent)
// Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit.
return (
<span className="tabular-nums">
{left}% {label}
{used}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label}
</span>
)
}
@ -1150,12 +1156,13 @@ function ProviderSegment({
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{visibleBuckets.map((bucket, i) => {
const left = Math.max(0, Math.round(100 - bucket.usedPercent))
const used = clampUsedPercent(bucket.usedPercent)
return (
<React.Fragment key={bucket.name}>
{i > 0 && <span className="text-muted-foreground">·</span>}
<span className="tabular-nums">
{bucket.name} {left}%
{bucket.name} {used}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
</span>
</React.Fragment>
)
@ -1195,7 +1202,7 @@ function ProviderSegment({
return (
<span className="inline-flex items-center gap-1.5">
<ProviderIcon provider={provider} />
{p.session && !compact && <MiniBar leftPct={Math.max(0, 100 - p.session.usedPercent)} />}
{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>}

View File

@ -46,8 +46,9 @@ describe('InlineUsageBars', () => {
<InlineUsageBars limits={claudeLimits()} isFetching={false} />
)
expect(markup).toContain('68% 5h')
expect(markup).toContain('84% wk')
expect(markup).toContain('58% Fable')
// Why: bars show % used with explicit "used" so compact labels are not ambiguous.
expect(markup).toContain('32% used 5h')
expect(markup).toContain('16% used wk')
expect(markup).toContain('42% used Fable')
})
})

View File

@ -28,7 +28,8 @@ vi.mock('./tooltip', () => ({
ProviderPanel: function ProviderPanel(props: Record<string, unknown>) {
return { type: 'ProviderPanel', props }
},
barColor: () => 'bg-green-500'
barColor: () => 'bg-green-500',
clampUsedPercent: (n: number) => Math.max(0, Math.min(100, Math.round(n)))
}))
vi.mock('@/components/ui/dropdown-menu', () => ({

View File

@ -22,6 +22,8 @@ vi.mock('@/i18n/i18n', () => ({
}))
import {
barColor,
clampUsedPercent,
formatResetCreditExpiry,
formatResetCountdown,
getProviderUsageErrorMessage,
@ -409,7 +411,7 @@ describe('ProviderPanel reset rendering', () => {
expect(markup).toContain('Resets in 6d 17h')
})
it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => {
it('renders MiniMax session as usedPercent so the value matches the bar', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 6, 4, 15, 0))
const p = provider({
@ -425,14 +427,13 @@ describe('ProviderPanel reset rendering', () => {
const markup = renderToStaticMarkup(ProviderPanel({ p }))
// Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the
// same source field so the two views stay consistent.
expect(markup).toContain('65%')
expect(markup).toContain('% left')
expect(markup).not.toContain('100% left')
// Why: bars show consumption (% used), matching harness meters (#7551).
expect(markup).toContain('35%')
expect(markup).toContain('% used')
expect(markup).not.toContain('% left')
})
it('clamps MiniMax session to 0% left when usedPercent reports 100', () => {
it('clamps MiniMax session to 100% used when usedPercent reports 100', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 6, 4, 15, 0))
const p = provider({
@ -448,7 +449,52 @@ describe('ProviderPanel reset rendering', () => {
const markup = renderToStaticMarkup(ProviderPanel({ p }))
expect(markup).toContain('0% left')
expect(markup).toContain('100%')
expect(markup).toContain('% used')
})
it('clamps over-100 usedPercent to 100% used in the panel', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 6, 4, 15, 0))
const p = provider({
provider: 'minimax',
status: 'ok',
session: {
usedPercent: 140,
windowMinutes: 300,
resetsAt: Date.now() + 2 * 60 * 60_000,
resetDescription: null
}
})
const markup = renderToStaticMarkup(ProviderPanel({ p }))
expect(markup).toContain('100%')
expect(markup).toContain('width:100%')
expect(markup).not.toContain('140%')
})
})
describe('clampUsedPercent', () => {
it('rounds and clamps into 0100', () => {
expect(clampUsedPercent(-3)).toBe(0)
expect(clampUsedPercent(32.4)).toBe(32)
expect(clampUsedPercent(32.6)).toBe(33)
expect(clampUsedPercent(100)).toBe(100)
expect(clampUsedPercent(140)).toBe(100)
})
})
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')
expect(barColor(60)).toBe('bg-yellow-500')
expect(barColor(79)).toBe('bg-yellow-500')
expect(barColor(80)).toBe('bg-red-500')
expect(barColor(100)).toBe('bg-red-500')
})
})

View File

@ -191,13 +191,19 @@ export function getWindowSections(
// `text-background` for primary text and `text-background/50` for secondary
// to stay readable inside the inverted tooltip container.
// Why: color-coded by remaining capacity so users can quickly gauge urgency.
// Green = comfortable (>40% left), yellow = caution (20-40%), red = critical (<20%).
export function barColor(leftPct: number): string {
if (leftPct > 40) {
// Why: single clamp for bar width + label so status bar and tooltip never diverge.
export function clampUsedPercent(usedPercent: number): number {
return Math.max(0, Math.min(100, Math.round(usedPercent)))
}
// 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%).
export function barColor(usedPct: number): string {
if (usedPct < 60) {
return 'bg-green-500'
}
if (leftPct > 20) {
if (usedPct < 80) {
return 'bg-yellow-500'
}
return 'bg-red-500'
@ -282,7 +288,9 @@ export function ProviderPanel({
if (!w) {
return null
}
const leftPct = Math.max(0, Math.round(100 - w.usedPercent))
// Why: show % used (consumption), not remaining — matches Claude/Codex
// harness meters and avoids the "full green bar = exhausted" misread (#7551).
const usedPct = clampUsedPercent(w.usedPercent)
const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null
return (
@ -290,14 +298,14 @@ export function ProviderPanel({
<div className={`font-medium ${textClass}`}>{label}</div>
<div className={`h-[6px] w-full overflow-hidden rounded-full ${emptyBarClass}`}>
<div
className={`h-full rounded-full ${barColor(leftPct)} transition-all duration-300`}
style={{ width: `${Math.min(100, Math.max(0, leftPct))}%` }}
className={`h-full rounded-full ${barColor(usedPct)} transition-all duration-300`}
style={{ width: `${usedPct}%` }}
/>
</div>
<div className={`flex justify-between ${mutedClass}`}>
<span>
{leftPct}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% left')}
{usedPct}
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
</span>
{resetLabel && <span>{resetLabel}</span>}
</div>

View File

@ -2999,9 +2999,9 @@
"2483c60695": "···",
"c35af53b73": "Sign in",
"f19a63e7cd": "Sign in to see usage",
"5c938d39ac": "% wk",
"54e8d6bb2d": "% Fable",
"d79c3362c4": "% 5h",
"5c938d39ac": "wk",
"54e8d6bb2d": "Fable",
"d79c3362c4": "5h",
"a79c64f87e": "Fable",
"8295903d17": "Restart live Claude terminals before continuing old conversations after switching.",
"c98ea88392": "No other accounts",
@ -3169,7 +3169,7 @@
}
},
"tooltip": {
"cedb7b99e3": "% left",
"cedb7b99e3": "% used",
"6d6df77f41": "No data available",
"7f7f208060": "Monthly",
"252c096536": "Weekly",
@ -10996,11 +10996,11 @@
"945865332e": "Signing in"
},
"UsagePage": {
"64265cb295": "71% 5h",
"64265cb295": "29% used 5h",
"be5a165875": "Switch to",
"277a9c65a9": "Codex Account",
"4dce5ca3aa": "Resets in 4d 3h",
"05ce4ecdd3": "62% left",
"05ce4ecdd3": "38% used",
"0470aaed99": "Weekly",
"f421abf962": "Session",
"5e45fb1238": "Updated 1m ago",

View File

@ -2999,9 +2999,9 @@
"2483c60695": "···",
"c35af53b73": "Iniciar sesión",
"f19a63e7cd": "Inicia sesión para ver el uso",
"5c938d39ac": "% semana",
"54e8d6bb2d": "% Fable",
"d79c3362c4": "% 5h",
"5c938d39ac": "semana",
"54e8d6bb2d": "Fable",
"d79c3362c4": "5h",
"a79c64f87e": "Fable",
"8295903d17": "Reinicia los terminales Claude activos antes de continuar conversaciones antiguas después de cambiar.",
"c98ea88392": "Ninguna otra cuenta",
@ -3169,7 +3169,7 @@
}
},
"tooltip": {
"cedb7b99e3": "% restante",
"cedb7b99e3": "% usado",
"6d6df77f41": "No hay datos disponibles",
"7f7f208060": "Mensual",
"252c096536": "Semanal",
@ -10996,11 +10996,11 @@
"945865332e": "Iniciando sesión"
},
"UsagePage": {
"64265cb295": "71% 5h",
"64265cb295": "29% usado 5h",
"be5a165875": "Cambiar a",
"277a9c65a9": "Cuenta de Codex",
"4dce5ca3aa": "Se restablece en 4d 3h",
"05ce4ecdd3": "62% restante",
"05ce4ecdd3": "38% usado",
"0470aaed99": "Semanal",
"f421abf962": "Sesión",
"5e45fb1238": "Actualizado hace 1 min",

View File

@ -2999,9 +2999,9 @@
"2483c60695": "・・・",
"c35af53b73": "サインイン",
"f19a63e7cd": "サインインして使用状況を確認する",
"5c938d39ac": "%週",
"54e8d6bb2d": "% Fable",
"d79c3362c4": "% 5時間",
"5c938d39ac": "週",
"54e8d6bb2d": "Fable",
"d79c3362c4": "5時間",
"a79c64f87e": "Fable",
"8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude terminals を再起動します。",
"c98ea88392": "他のアカウントはありません",
@ -3169,7 +3169,7 @@
}
},
"tooltip": {
"cedb7b99e3": " ",
"cedb7b99e3": " 使用",
"6d6df77f41": "利用可能なデータがありません",
"7f7f208060": "毎月",
"252c096536": "毎週",
@ -10996,11 +10996,11 @@
"945865332e": "サインインする"
},
"UsagePage": {
"64265cb295": "71% 5時間",
"64265cb295": "29% 使用 5時間",
"be5a165875": "に切り替えます",
"277a9c65a9": "Codex アカウント",
"4dce5ca3aa": "4d 3h でリセット",
"05ce4ecdd3": "62%残っています",
"05ce4ecdd3": "38%使用",
"0470aaed99": "毎週",
"f421abf962": "セッション",
"5e45fb1238": "1分前に更新されました",

View File

@ -2999,9 +2999,9 @@
"2483c60695": "···",
"c35af53b73": "로그인",
"f19a63e7cd": "사용량을 보려면 로그인하세요.",
"5c938d39ac": "%주간",
"54e8d6bb2d": "% Fable",
"d79c3362c4": "% 5h",
"5c938d39ac": "주간",
"54e8d6bb2d": "Fable",
"d79c3362c4": "5h",
"a79c64f87e": "Fable",
"8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.",
"c98ea88392": "다른 계정 없음",
@ -3169,7 +3169,7 @@
}
},
"tooltip": {
"cedb7b99e3": "% 남음",
"cedb7b99e3": "% 사용",
"6d6df77f41": "데이터가 없습니다",
"7f7f208060": "월간",
"252c096536": "주간",
@ -10996,11 +10996,11 @@
"945865332e": "로그인 중"
},
"UsagePage": {
"64265cb295": "71% 5시간",
"64265cb295": "29% 사용 5시간",
"be5a165875": "다음으로 전환",
"277a9c65a9": "Codex 계정",
"4dce5ca3aa": "4일 3시간 후에 재설정됩니다.",
"05ce4ecdd3": "62% 남음",
"05ce4ecdd3": "38% 사용",
"0470aaed99": "주간",
"f421abf962": "세션",
"5e45fb1238": "1분 전에 업데이트됨",

View File

@ -2999,9 +2999,9 @@
"2483c60695": "···",
"c35af53b73": "登录",
"f19a63e7cd": "登录查看使用情况",
"5c938d39ac": "% 周",
"54e8d6bb2d": "% Fable",
"d79c3362c4": "% 5小时",
"5c938d39ac": "周",
"54e8d6bb2d": "Fable",
"d79c3362c4": "5小时",
"a79c64f87e": "Fable",
"8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。",
"c98ea88392": "没有其他账户",
@ -3169,7 +3169,7 @@
}
},
"tooltip": {
"cedb7b99e3": "剩余 %",
"cedb7b99e3": "% 已用",
"6d6df77f41": "无可用数据",
"7f7f208060": "每月",
"252c096536": "每周",
@ -10996,11 +10996,11 @@
"945865332e": "登录"
},
"UsagePage": {
"64265cb295": "71% 5小时",
"64265cb295": "29% 已用 5小时",
"be5a165875": "切换到",
"277a9c65a9": "Codex 账户",
"4dce5ca3aa": "4d 3h后重置",
"05ce4ecdd3": "还剩 62%",
"05ce4ecdd3": "已用 38%",
"0470aaed99": "每周",
"f421abf962": "会话",
"5e45fb1238": "更新于 1 分钟前",