mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
Show short Antigravity pool labels in status bar (#31007)
* Show short Antigravity pool labels in the status bar
Antigravity pools rendered their full "<group> · <agy window name>" in the
status bar. The host now publishes structured group/windowName fields on each
bucket, and the renderer labels pools as short group + window length
("Gemini 5h", "Claude/GPT wk"), with a reset countdown once a pool is urgent.
Old hosts without the fields keep today's full names; the click panel and
tooltips are unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
* Keep Antigravity status bar reset countdowns current
* Preserve rate-limit pool identities in status labels
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,8 @@ describe('fetchAntigravityRateLimits', () => {
|
||||
expect(result.buckets).toEqual([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 60,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('parseAntigravityUsageStdout', () => {
|
||||
{
|
||||
id: 'gemini-weekly',
|
||||
name: 'Gemini Models',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
@@ -34,6 +36,8 @@ describe('parseAntigravityUsageStdout', () => {
|
||||
{
|
||||
id: '3p-weekly',
|
||||
name: 'Claude and GPT models',
|
||||
group: 'Claude and GPT models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 0,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: new Date('2026-10-07T08:08:35Z').getTime(),
|
||||
@@ -120,6 +124,45 @@ describe('parseAntigravityUsageEnvelope', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('fills the structured group and window name the renderer labels pools from', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]).toMatchObject({
|
||||
group: 'Gemini Models',
|
||||
windowName: '5h Limit Remaining'
|
||||
})
|
||||
expect(reading?.buckets[1]).toMatchObject({ group: 'Gemini Models' })
|
||||
expect(reading?.buckets[1]).not.toHaveProperty('windowName')
|
||||
})
|
||||
|
||||
it('keeps only the four window fields on the session and weekly summaries', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [
|
||||
{ id: 'gemini-5h', name: '5h Limit Remaining', window: '5h', remaining_fraction: 1 },
|
||||
{ id: 'gemini-weekly', name: 'Weekly', window: 'weekly', remaining_fraction: 1 }
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const window = { usedPercent: 0, resetsAt: null, resetDescription: null }
|
||||
expect(reading?.session).toEqual({ ...window, windowMinutes: 300 })
|
||||
expect(reading?.weekly).toEqual({ ...window, windowMinutes: 10_080 })
|
||||
})
|
||||
|
||||
it('drops a disabled bucket instead of drawing it as unused', () => {
|
||||
const reading = parseAntigravityUsageEnvelope(
|
||||
envelope([
|
||||
@@ -171,12 +214,23 @@ describe('parseAntigravityUsageEnvelope', () => {
|
||||
envelope([
|
||||
{
|
||||
name: 'Gemini Models',
|
||||
buckets: [{ id: 'gemini-monthly', window: 'monthly', remaining_fraction: 0.4 }]
|
||||
buckets: [
|
||||
{
|
||||
id: 'gemini-monthly',
|
||||
name: 'Monthly Limit Remaining',
|
||||
window: 'monthly',
|
||||
remaining_fraction: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
expect(reading?.buckets[0]).toMatchObject({ usedPercent: 60, windowMinutes: 0 })
|
||||
expect(reading?.buckets[0]).toMatchObject({
|
||||
usedPercent: 60,
|
||||
windowMinutes: 0,
|
||||
windowName: 'Monthly Limit Remaining'
|
||||
})
|
||||
expect(reading?.session).toBeNull()
|
||||
expect(reading?.weekly).toBeNull()
|
||||
})
|
||||
|
||||
@@ -111,9 +111,13 @@ function parseBucket(
|
||||
return null
|
||||
}
|
||||
const usedPercent = Math.min(100, Math.max(0, Math.round((1 - fraction) * 100)))
|
||||
const windowName = readString(raw.name)
|
||||
return {
|
||||
id,
|
||||
name: formatBucketName(groupName, readString(raw.name), siblings),
|
||||
name: formatBucketName(groupName, windowName, siblings),
|
||||
// Why structured fields: the renderer builds short labels without splitting `name`.
|
||||
group: groupName,
|
||||
...(windowName ? { windowName } : {}),
|
||||
usedPercent,
|
||||
// Why 0 and not null: RateLimitWindow requires a number, and an unrecognised agy window still
|
||||
// carries a real remaining fraction worth showing as a named bucket.
|
||||
@@ -181,12 +185,8 @@ export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageR
|
||||
if (buckets.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why drop the id first: the summary is a RateLimitWindow, and the summariser only strips `name`,
|
||||
// so an id left on the bucket would ride into the published window.
|
||||
const windowsOf = (minutes: number): RateLimitBucket[] =>
|
||||
buckets
|
||||
.filter((bucket) => bucket.windowMinutes === minutes)
|
||||
.map(({ id: _id, ...bucket }) => bucket)
|
||||
const windowsOf = (minutes: number): AntigravityUsageBucket[] =>
|
||||
buckets.filter((bucket) => bucket.windowMinutes === minutes)
|
||||
return {
|
||||
session: deriveMostConstrainedWindow(windowsOf(SESSION_WINDOW_MINUTES)),
|
||||
weekly: deriveMostConstrainedWindow(windowsOf(WEEKLY_WINDOW_MINUTES)),
|
||||
|
||||
@@ -14,6 +14,6 @@ export function deriveMostConstrainedWindow(buckets: RateLimitBucket[]): RateLim
|
||||
const mostConstrained = buckets.reduce((worst, bucket) =>
|
||||
bucket.usedPercent > worst.usedPercent ? bucket : worst
|
||||
)
|
||||
const { name: _name, ...window } = mostConstrained
|
||||
return window
|
||||
const { usedPercent, windowMinutes, resetsAt, resetDescription } = mostConstrained
|
||||
return { usedPercent, windowMinutes, resetsAt, resetDescription }
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ import { getTightestUsageSection, getUsageHeadlineSection } from './UsageRosterP
|
||||
import { formatBareUsagePercentage, formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getStatusBarUsageSections } from './status-bar-usage-sections'
|
||||
import { GroupedPoolUsage } from './usage-grouped-pools'
|
||||
import { formatStatusBarSectionLabel, isGroupedUsagePool } from './usage-pool-label'
|
||||
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
|
||||
|
||||
function MiniBar({
|
||||
usedPct,
|
||||
@@ -251,6 +254,21 @@ export function ProviderSegment({
|
||||
const statusLabel = p ? getProviderUsageStatusLabel(p) : ''
|
||||
// Why: not a problem; Claude updates its own login the next time it runs.
|
||||
const calm = p ? isClaudeUsageWaitingForClaude(p) : false
|
||||
const tightest = p
|
||||
? mode === 'compact'
|
||||
? getUsageHeadlineSection(p)
|
||||
: getTightestUsageSection(p)
|
||||
: null
|
||||
const sections = getStatusBarUsageSections(p, mode)
|
||||
const groupedBuckets = sections.map((section) => section.window).filter(isGroupedUsagePool)
|
||||
const showLabels = mode === 'verbose' || !compact
|
||||
const now = useResetCountdownClock(
|
||||
showLabels
|
||||
? groupedBuckets
|
||||
.filter((bucket) => clampUsedPercent(bucket.usedPercent) >= USAGE_URGENT_PERCENT)
|
||||
.map((bucket) => bucket.resetsAt)
|
||||
: []
|
||||
)
|
||||
|
||||
// Idle / initial load
|
||||
if (!p || p.status === 'idle') {
|
||||
@@ -262,8 +280,6 @@ export function ProviderSegment({
|
||||
)
|
||||
}
|
||||
|
||||
const tightest = mode === 'compact' ? getUsageHeadlineSection(p) : getTightestUsageSection(p)
|
||||
|
||||
// Fetching with no prior data
|
||||
if (p.status === 'fetching' && !tightest) {
|
||||
return (
|
||||
@@ -311,7 +327,8 @@ export function ProviderSegment({
|
||||
// Has data (ok, fetching with stale data, or error with stale data)
|
||||
const isStale = p.status === 'error' && !calm
|
||||
const showBalance = isExtraUsageActive(p)
|
||||
const sections = getStatusBarUsageSections(p, mode)
|
||||
const showGroupedPools =
|
||||
mode === 'verbose' && groupedBuckets.length > 0 && groupedBuckets.length === sections.length
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@@ -319,18 +336,27 @@ export function ProviderSegment({
|
||||
{mode === 'verbose' && tightest && !compact ? (
|
||||
<MiniBar usedPct={clampUsedPercent(tightest.window.usedPercent)} display={display} />
|
||||
) : null}
|
||||
{sections.map((section, index) => (
|
||||
<React.Fragment key={section.key}>
|
||||
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
|
||||
<WindowLabel
|
||||
w={section.window}
|
||||
label={section.label}
|
||||
display={display}
|
||||
showLabel={mode === 'verbose' || !compact}
|
||||
labelFirst={section.labelFirst}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{showGroupedPools ? (
|
||||
<GroupedPoolUsage
|
||||
visibleBuckets={groupedBuckets}
|
||||
allBuckets={p.buckets ?? []}
|
||||
display={display}
|
||||
now={now}
|
||||
/>
|
||||
) : (
|
||||
sections.map((section, index) => (
|
||||
<React.Fragment key={section.key}>
|
||||
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
|
||||
<WindowLabel
|
||||
w={section.window}
|
||||
label={formatStatusBarSectionLabel(p, section, now)}
|
||||
display={display}
|
||||
showLabel={showLabels}
|
||||
labelFirst={section.labelFirst}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
{showBalance && p.extraUsage ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
|
||||
@@ -155,6 +155,38 @@ describe('UsageRow', () => {
|
||||
expect(markup).toContain('10%')
|
||||
})
|
||||
|
||||
it('keeps full Antigravity pool names in the click panel', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<UsageRow
|
||||
p={{
|
||||
provider: 'antigravity',
|
||||
session: null,
|
||||
weekly: null,
|
||||
buckets: [
|
||||
{
|
||||
name: 'Gemini Models · Weekly Limit Remaining',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 4,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
}
|
||||
],
|
||||
updatedAt: 0,
|
||||
status: 'ok',
|
||||
error: null
|
||||
}}
|
||||
display="used"
|
||||
state={{ kind: 'usage', statusLabel: null }}
|
||||
showSignInAction={false}
|
||||
now={mocks.now}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('Gemini Models · Weekly Limit Remaining')
|
||||
})
|
||||
|
||||
it('renders the tightest window inline in compact mode', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<UsageRow
|
||||
|
||||
@@ -48,6 +48,32 @@ function antigravityLimits(overrides: Partial<ProviderRateLimits> = {}): Provide
|
||||
}
|
||||
}
|
||||
|
||||
const FIVE_HOUR_MINUTES = 300
|
||||
|
||||
/** A tier with a 5h and a weekly pool per group, from a host that publishes `group`/`windowName`. */
|
||||
function groupedLimits(): ProviderRateLimits {
|
||||
const pool = (group: string, windowMinutes: number, usedPercent: number) => {
|
||||
const windowName =
|
||||
windowMinutes === FIVE_HOUR_MINUTES ? '5h Limit Remaining' : 'Weekly Limit Remaining'
|
||||
return {
|
||||
name: `${group} · ${windowName}`,
|
||||
group,
|
||||
windowName,
|
||||
...weeklyWindow(usedPercent),
|
||||
windowMinutes
|
||||
}
|
||||
}
|
||||
return antigravityLimits({
|
||||
weekly: weeklyWindow(4),
|
||||
buckets: [
|
||||
pool('Gemini Models', WEEKLY_MINUTES, 4),
|
||||
pool('Gemini Models', FIVE_HOUR_MINUTES, 0),
|
||||
pool('Claude and GPT models', WEEKLY_MINUTES, 0),
|
||||
pool('Claude and GPT models', FIVE_HOUR_MINUTES, 0)
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Antigravity status-bar segment', () => {
|
||||
it('renders both model-group pools by name', async () => {
|
||||
// Why: the verbose bucket allowlist was written for Gemini's experimental models, so
|
||||
@@ -63,6 +89,44 @@ describe('Antigravity status-bar segment', () => {
|
||||
expect(markup).toContain('100%')
|
||||
})
|
||||
|
||||
it('labels grouped pools by short group and window length', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={groupedLimits()} compact={false} display="used" mode="verbose" />
|
||||
)
|
||||
|
||||
// Why '>Gemini<': the group's `title` still holds the full name, so a bare match is vacuous.
|
||||
expect(markup).toContain('>Gemini<')
|
||||
expect(markup).toContain('>Claude/GPT<')
|
||||
expect(markup).toContain('5h')
|
||||
expect(markup).toContain('4%')
|
||||
expect(markup).not.toContain('Gemini Models ·')
|
||||
expect(markup).not.toContain('Limit Remaining')
|
||||
// 5h sorts before wk within a group.
|
||||
expect(markup.indexOf('5h')).toBeLessThan(markup.indexOf('wk'))
|
||||
})
|
||||
|
||||
it('uses the short pool label in compact mode', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={groupedLimits()} compact={false} display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('4%')
|
||||
expect(markup).toContain('Gemini wk')
|
||||
expect(markup).not.toContain('Limit Remaining')
|
||||
})
|
||||
|
||||
it('renders no pool label when the compact bar hides labels', async () => {
|
||||
const { ProviderSegment } = await import('./StatusBar')
|
||||
const markup = renderToStaticMarkup(
|
||||
<ProviderSegment p={groupedLimits()} compact display="used" mode="compact" />
|
||||
)
|
||||
|
||||
expect(markup).toContain('4%')
|
||||
expect(markup).not.toContain('Gemini')
|
||||
})
|
||||
|
||||
it('shows the weekly window when a tier reports no session pool', async () => {
|
||||
// Why: the verbose fallback chain was `session ?? monthly`, so a weekly-only provider with a
|
||||
// real limit rendered an empty segment.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
|
||||
import { ProviderSegment } from './StatusBarProviderSegment'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
i18n: { language: 'en' },
|
||||
translate: (_key: string, fallback: string, values: Record<string, string> = {}) =>
|
||||
Object.entries(values).reduce(
|
||||
(text, [key, value]) => text.replace(`{{${key}}}`, value),
|
||||
fallback
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null }))
|
||||
|
||||
const START = Date.UTC(2026, 9, 8, 12)
|
||||
const MINUTE = 60_000
|
||||
|
||||
function limits(usedPercent = 90): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'antigravity',
|
||||
session: null,
|
||||
weekly: null,
|
||||
buckets: [
|
||||
{
|
||||
name: 'Gemini Models · 5h Limit Remaining',
|
||||
group: 'Gemini Models',
|
||||
windowName: '5h Limit Remaining',
|
||||
usedPercent,
|
||||
windowMinutes: 300,
|
||||
resetsAt: START + 90 * MINUTE + 30_000,
|
||||
resetDescription: null
|
||||
}
|
||||
],
|
||||
status: 'ok',
|
||||
error: null,
|
||||
updatedAt: START
|
||||
}
|
||||
}
|
||||
|
||||
describe('status-bar pool countdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(START)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['verbose', 'used'],
|
||||
['verbose', 'remaining'],
|
||||
['compact', 'used'],
|
||||
['compact', 'remaining']
|
||||
] as const)('ticks without new quota data in %s mode with %s percentages', (mode, display) => {
|
||||
const { container } = render(
|
||||
<ProviderSegment p={limits()} compact={false} mode={mode} display={display} />
|
||||
)
|
||||
expect(container.textContent).toContain('5h (1h 30m)')
|
||||
expect(container.textContent).toContain(display === 'used' ? '90%' : '10%')
|
||||
|
||||
act(() => vi.advanceTimersByTime(30_001))
|
||||
expect(container.textContent).toContain('5h (1h 29m)')
|
||||
|
||||
act(() => vi.advanceTimersByTime(MINUTE))
|
||||
expect(container.textContent).toContain('5h (1h 28m)')
|
||||
})
|
||||
|
||||
it.each(['verbose', 'compact'] as const)(
|
||||
'identifies the limiting window when both pools reset together in %s mode',
|
||||
(mode) => {
|
||||
const p = limits()
|
||||
p.buckets?.push({
|
||||
name: 'Gemini Models · Weekly Limit Remaining',
|
||||
group: 'Gemini Models',
|
||||
windowName: 'Weekly Limit Remaining',
|
||||
usedPercent: 95,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: START + 90 * MINUTE + 30_000,
|
||||
resetDescription: null
|
||||
})
|
||||
const { container } = render(
|
||||
<ProviderSegment p={p} compact={false} mode={mode} display="remaining" />
|
||||
)
|
||||
|
||||
if (mode === 'verbose') {
|
||||
expect(container.textContent).toContain('10% 5h (1h 30m)')
|
||||
expect(container.textContent).toContain('5% wk (1h 30m)')
|
||||
} else {
|
||||
expect(container.textContent).toBe('5% Gemini wk (1h 30m)')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('shows now at the reset and stops scheduling updates', () => {
|
||||
vi.setSystemTime(START + 90 * MINUTE)
|
||||
const { container } = render(<ProviderSegment p={limits()} compact={false} display="used" />)
|
||||
expect(container.textContent).toContain('5h (0m)')
|
||||
act(() => vi.advanceTimersByTime(30_001))
|
||||
expect(container.textContent).toContain('5h (now)')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('starts the clock when an idle segment receives an urgent pool', () => {
|
||||
const { container, rerender } = render(
|
||||
<ProviderSegment p={null} compact={false} display="used" />
|
||||
)
|
||||
rerender(<ProviderSegment p={limits()} compact={false} display="used" />)
|
||||
act(() => vi.advanceTimersByTime(30_001))
|
||||
expect(container.textContent).toContain('1h 29m')
|
||||
})
|
||||
|
||||
it('does not schedule countdown updates for nonurgent pools', () => {
|
||||
const { container } = render(<ProviderSegment p={limits(79)} compact={false} display="used" />)
|
||||
expect(container.textContent).toContain('5h')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule countdown updates when compact labels are hidden', () => {
|
||||
const { container } = render(
|
||||
<ProviderSegment p={limits()} compact mode="compact" display="used" />
|
||||
)
|
||||
expect(container.textContent).toBe('90%')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['idle', 'unavailable'] as const)(
|
||||
'does not schedule countdown updates for a hidden %s reading',
|
||||
(status) => {
|
||||
const { container } = render(
|
||||
<ProviderSegment p={{ ...limits(), status }} compact={false} display="used" />
|
||||
)
|
||||
expect(container.textContent).not.toContain('1h 30m')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('stops countdown updates when the labels become hidden', () => {
|
||||
const { container, rerender } = render(
|
||||
<ProviderSegment p={limits()} compact={false} mode="compact" display="used" />
|
||||
)
|
||||
expect(container.textContent).toContain('1h 30m')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
rerender(<ProviderSegment p={limits()} compact mode="compact" display="used" />)
|
||||
expect(container.textContent).toBe('90%')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import type { RateLimitBucket } from '../../../../shared/rate-limit-types'
|
||||
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import {
|
||||
formatPoolGroupLabel,
|
||||
formatPoolWindowLabel,
|
||||
type GroupedUsagePool
|
||||
} from './usage-pool-label'
|
||||
import { formatBareUsagePercentage } from './usage-percentage-label'
|
||||
|
||||
// Shortest window first; an unknown window (0) sorts last. Stable, so ties keep agy's order.
|
||||
function compareWindows(a: RateLimitBucket, b: RateLimitBucket): number {
|
||||
if (a.windowMinutes === b.windowMinutes) {
|
||||
return 0
|
||||
}
|
||||
if (a.windowMinutes === 0) {
|
||||
return 1
|
||||
}
|
||||
if (b.windowMinutes === 0) {
|
||||
return -1
|
||||
}
|
||||
return a.windowMinutes - b.windowMinutes
|
||||
}
|
||||
|
||||
// Why not `name`: it repeats when agy omits bucket names, so key by length and its occurrence count.
|
||||
function keyedWindows(
|
||||
buckets: readonly RateLimitBucket[]
|
||||
): { bucket: RateLimitBucket; key: string }[] {
|
||||
const seen = new Map<number, number>()
|
||||
return [...buckets].sort(compareWindows).map((bucket) => {
|
||||
const occurrence = seen.get(bucket.windowMinutes) ?? 0
|
||||
seen.set(bucket.windowMinutes, occurrence + 1)
|
||||
return { bucket, key: `${bucket.windowMinutes}:${occurrence}` }
|
||||
})
|
||||
}
|
||||
|
||||
/** Model-group pools as "Gemini 0% 5h · 4% wk │ Claude/GPT …"; children are flex items of the segment row. */
|
||||
export function GroupedPoolUsage({
|
||||
visibleBuckets,
|
||||
allBuckets,
|
||||
display,
|
||||
now
|
||||
}: {
|
||||
visibleBuckets: readonly GroupedUsagePool[]
|
||||
allBuckets: readonly RateLimitBucket[]
|
||||
display: UsagePercentageDisplay
|
||||
now: number
|
||||
}): React.JSX.Element {
|
||||
const groups = new Map<string, RateLimitBucket[]>()
|
||||
for (const bucket of visibleBuckets) {
|
||||
const buckets = groups.get(bucket.group) ?? []
|
||||
buckets.push(bucket)
|
||||
groups.set(bucket.group, buckets)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{[...groups].map(([group, buckets], groupIndex) => {
|
||||
const windows = keyedWindows(buckets)
|
||||
return (
|
||||
<React.Fragment key={group}>
|
||||
{groupIndex > 0 ? (
|
||||
<span aria-hidden="true" className="h-3 w-px shrink-0 bg-border" />
|
||||
) : null}
|
||||
<span className="max-w-[120px] truncate" title={group}>
|
||||
{formatPoolGroupLabel(group, allBuckets)}
|
||||
</span>
|
||||
{windows.map(({ bucket, key }, index) => {
|
||||
const percentage = formatBareUsagePercentage(bucket.usedPercent, display)
|
||||
const window = formatPoolWindowLabel(bucket, now)
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{index > 0 ? <span className="text-muted-foreground">·</span> : null}
|
||||
<span className="tabular-nums">{`${percentage} ${window}`.trim()}</span>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits, RateLimitBucket } from '../../../../shared/rate-limit-types'
|
||||
import {
|
||||
formatPoolShortLabel,
|
||||
formatStatusBarSectionLabel,
|
||||
formatPoolGroupLabel,
|
||||
formatPoolWindowLabel
|
||||
} from './usage-pool-label'
|
||||
import { getTightestUsageSection } from './UsageRosterPanel'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values: Record<string, string> = {}) =>
|
||||
Object.entries(values).reduce(
|
||||
(text, [key, value]) => text.replace(`{{${key}}}`, value),
|
||||
fallback
|
||||
)
|
||||
}))
|
||||
vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null }))
|
||||
vi.mock('@/hooks/useResetCountdownClock', () => ({ useResetCountdownClock: () => 0 }))
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: () => null }))
|
||||
vi.mock('@/components/settings/SettingsFormControls', () => ({
|
||||
SettingsSegmentedControl: () => null
|
||||
}))
|
||||
|
||||
const FIVE_HOURS = 300
|
||||
const WEEK = 10_080
|
||||
|
||||
function pool(
|
||||
group: string | undefined,
|
||||
windowMinutes: number,
|
||||
overrides: Partial<RateLimitBucket> = {}
|
||||
): RateLimitBucket {
|
||||
const windowName = windowMinutes === FIVE_HOURS ? '5h Limit Remaining' : 'Weekly Limit Remaining'
|
||||
return {
|
||||
name: group ? `${group} · ${windowName}` : 'Pro',
|
||||
...(group ? { group, windowName } : {}),
|
||||
usedPercent: 0,
|
||||
windowMinutes,
|
||||
resetsAt: null,
|
||||
resetDescription: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const TWO_GROUPS = [
|
||||
pool('Gemini Models', FIVE_HOURS),
|
||||
pool('Gemini Models', WEEK),
|
||||
pool('Claude and GPT models', FIVE_HOURS),
|
||||
pool('Claude and GPT models', WEEK)
|
||||
]
|
||||
|
||||
describe('pool labels', () => {
|
||||
it('shortens two groups with two windows each', () => {
|
||||
expect(TWO_GROUPS.map((b) => formatPoolShortLabel(b, TWO_GROUPS))).toEqual([
|
||||
'Gemini 5h',
|
||||
'Gemini wk',
|
||||
'Claude/GPT 5h',
|
||||
'Claude/GPT wk'
|
||||
])
|
||||
expect(formatPoolGroupLabel('Claude and GPT models', TWO_GROUPS)).toBe('Claude/GPT')
|
||||
})
|
||||
|
||||
it('labels a single-window group by its window length', () => {
|
||||
const buckets = [pool('Gemini Models', WEEK, { name: 'Gemini Models' })]
|
||||
expect(formatPoolShortLabel(buckets[0], buckets)).toBe('Gemini wk')
|
||||
})
|
||||
|
||||
it('uses agy’s window name without "Limit Remaining" for an unknown window', () => {
|
||||
const daily = pool('Gemini Models', 0, { windowName: 'Daily Limit Remaining' })
|
||||
expect(formatPoolShortLabel(daily, [daily])).toBe('Gemini Daily')
|
||||
})
|
||||
|
||||
it('shows the group alone for an unknown window with no window name', () => {
|
||||
const { windowName: _windowName, ...unnamed } = pool('Gemini Models', 0)
|
||||
expect(formatPoolShortLabel(unnamed, [unnamed])).toBe('Gemini')
|
||||
expect(formatPoolWindowLabel(unnamed)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps full group names when two groups shorten to the same text', () => {
|
||||
const buckets = [pool('Gemini Models', WEEK), pool('Gemini models', WEEK)]
|
||||
expect(buckets.map((b) => formatPoolShortLabel(b, buckets))).toEqual([
|
||||
'Gemini Models wk',
|
||||
'Gemini models wk'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not shorten another group to a name retained after a collision', () => {
|
||||
const buckets = [
|
||||
pool('Gemini Models', WEEK),
|
||||
pool('Gemini models', WEEK),
|
||||
pool('Gemini Models Models', WEEK)
|
||||
]
|
||||
expect(buckets.map((bucket) => formatPoolShortLabel(bucket, buckets))).toEqual([
|
||||
'Gemini Models wk',
|
||||
'Gemini models wk',
|
||||
'Gemini Models Models wk'
|
||||
])
|
||||
})
|
||||
|
||||
it('passes a group name the rule does not match through', () => {
|
||||
const buckets = [pool('Gemini Models', WEEK), pool('Gemini models (Preview)', WEEK)]
|
||||
expect(buckets.map((b) => formatPoolShortLabel(b, buckets))).toEqual([
|
||||
'Gemini wk',
|
||||
'Gemini models (Preview) wk'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the full name for a pool with no group (an old host)', () => {
|
||||
const legacy = pool(undefined, WEEK, { name: 'Gemini Models · Weekly Limit Remaining' })
|
||||
expect(formatPoolShortLabel(legacy, [legacy])).toBe('Gemini Models · Weekly Limit Remaining')
|
||||
})
|
||||
|
||||
it('leaves Gemini CLI’s 60-minute "Pro" pool unchanged', () => {
|
||||
const pro = pool(undefined, 60, { name: 'Pro' })
|
||||
expect(formatPoolShortLabel(pro, [pro])).toBe('Pro')
|
||||
})
|
||||
|
||||
it('adds a reset countdown only once a pool is urgent and its reset is known', () => {
|
||||
const now = Date.UTC(2026, 9, 1, 12)
|
||||
const resetsAt = now + 130 * 60_000
|
||||
const label = (overrides: Partial<RateLimitBucket>): string => {
|
||||
const bucket = pool('Gemini Models', FIVE_HOURS, overrides)
|
||||
return formatPoolWindowLabel(bucket, now)
|
||||
}
|
||||
|
||||
expect(label({ usedPercent: 80, resetsAt })).toBe('5h (2h 10m)')
|
||||
expect(label({ usedPercent: 79, resetsAt })).toBe('5h')
|
||||
expect(label({ usedPercent: 80, resetsAt: null })).toBe('5h')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[WEEK, 'Weekly Limit Remaining', 'wk (2h 10m)'],
|
||||
[0, 'Daily Limit Remaining', 'Daily (2h 10m)'],
|
||||
[0, undefined, '2h 10m']
|
||||
] as const)(
|
||||
'keeps the window identity for an urgent %s-minute pool',
|
||||
(minutes, windowName, expected) => {
|
||||
const now = Date.UTC(2026, 9, 1, 12)
|
||||
const bucket = pool('Gemini Models', minutes, {
|
||||
windowName,
|
||||
usedPercent: 90,
|
||||
resetsAt: now + 130 * 60_000
|
||||
})
|
||||
|
||||
expect(formatPoolWindowLabel(bucket, now)).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('formatStatusBarSectionLabel', () => {
|
||||
it('labels a tie with the pool, not the derived weekly section', () => {
|
||||
const buckets = [
|
||||
pool('Gemini Models', FIVE_HOURS, { usedPercent: 30 }),
|
||||
pool('Gemini Models', WEEK, { usedPercent: 50 }),
|
||||
pool('Claude and GPT models', WEEK, { usedPercent: 10 })
|
||||
]
|
||||
const p: ProviderRateLimits = {
|
||||
provider: 'antigravity',
|
||||
session: null,
|
||||
// Derived from the pools, so it ties with the Gemini weekly pool.
|
||||
weekly: { usedPercent: 50, windowMinutes: WEEK, resetsAt: null, resetDescription: null },
|
||||
buckets,
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
|
||||
const tightest = getTightestUsageSection(p)
|
||||
expect(tightest?.window).toBe(buckets[1])
|
||||
expect(formatStatusBarSectionLabel(p, tightest!)).toBe('Gemini wk')
|
||||
})
|
||||
|
||||
it('returns the section label when the window is not a grouped bucket', () => {
|
||||
const p: ProviderRateLimits = {
|
||||
provider: 'gemini',
|
||||
session: null,
|
||||
weekly: null,
|
||||
buckets: [pool(undefined, 60, { name: 'Pro', usedPercent: 40 })],
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
expect(formatStatusBarSectionLabel(p, getTightestUsageSection(p)!)).toBe('Pro')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatRateLimitWindowChipLabel, formatWindowLabel } from '@/lib/window-label-formatter'
|
||||
import type {
|
||||
ProviderRateLimits,
|
||||
RateLimitBucket,
|
||||
RateLimitWindow
|
||||
} from '../../../../shared/rate-limit-types'
|
||||
import { clampUsedPercent } from '../../../../shared/usage-percentage-display'
|
||||
import { USAGE_URGENT_PERCENT } from './provider-extra-usage-section'
|
||||
|
||||
export type GroupedUsagePool = RateLimitBucket & { group: string }
|
||||
|
||||
const WEEKLY_WINDOW_MINUTES = 10_080
|
||||
|
||||
export function isGroupedUsagePool(window: RateLimitWindow): window is GroupedUsagePool {
|
||||
return (
|
||||
'name' in window &&
|
||||
typeof window.name === 'string' &&
|
||||
'group' in window &&
|
||||
typeof window.group === 'string' &&
|
||||
window.group.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function shortenGroup(group: string): string {
|
||||
const short = group
|
||||
.replace(/\s+models$/i, '')
|
||||
.replace(/\s+and\s+/gi, '/')
|
||||
.trim()
|
||||
return short || group
|
||||
}
|
||||
|
||||
export function formatPoolGroupLabel(group: string, buckets: readonly RateLimitBucket[]): string {
|
||||
const short = shortenGroup(group)
|
||||
const collides = buckets.some(
|
||||
(other) =>
|
||||
other.group &&
|
||||
other.group !== group &&
|
||||
(other.group === short || shortenGroup(other.group) === short)
|
||||
)
|
||||
return collides ? group : short
|
||||
}
|
||||
|
||||
function formatPoolWindowName(bucket: RateLimitBucket): string {
|
||||
// An unmapped agy window has no length; formatWindowLabel(0) would invent "0m".
|
||||
if (!(bucket.windowMinutes > 0)) {
|
||||
return (bucket.windowName ?? '').replace(/\s*limit remaining$/i, '').trim()
|
||||
}
|
||||
if (bucket.windowMinutes === WEEKLY_WINDOW_MINUTES) {
|
||||
return translate('auto.components.status.bar.StatusBar.5c938d39ac', 'wk')
|
||||
}
|
||||
return formatWindowLabel(bucket.windowMinutes)
|
||||
}
|
||||
|
||||
export function formatPoolWindowLabel(bucket: RateLimitBucket, now?: number): string {
|
||||
const label = formatPoolWindowName(bucket)
|
||||
// Urgency follows consumption even when the user displays the percentage remaining.
|
||||
if (clampUsedPercent(bucket.usedPercent) >= USAGE_URGENT_PERCENT && bucket.resetsAt != null) {
|
||||
const countdown = formatRateLimitWindowChipLabel(bucket, now)
|
||||
return label ? `${label} (${countdown})` : countdown
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
export function formatPoolShortLabel(
|
||||
bucket: RateLimitBucket,
|
||||
buckets: readonly RateLimitBucket[],
|
||||
now?: number
|
||||
): string {
|
||||
if (!bucket.group) {
|
||||
return bucket.name
|
||||
}
|
||||
return `${formatPoolGroupLabel(bucket.group, buckets)} ${formatPoolWindowLabel(bucket, now)}`.trim()
|
||||
}
|
||||
|
||||
export function formatStatusBarSectionLabel(
|
||||
p: ProviderRateLimits,
|
||||
section: { label: string; window: RateLimitWindow },
|
||||
now?: number
|
||||
): string {
|
||||
return isGroupedUsagePool(section.window)
|
||||
? formatPoolShortLabel(section.window, p.buckets ?? [], now)
|
||||
: section.label
|
||||
}
|
||||
@@ -39,6 +39,10 @@ export type ExtraUsageBalance = CurrencyExtraUsageBalance | CreditExtraUsageBala
|
||||
|
||||
export type RateLimitBucket = RateLimitWindow & {
|
||||
name: string
|
||||
/** Model-group name the pool belongs to (Antigravity), when the provider groups pools. */
|
||||
group?: string
|
||||
/** Provider's own window name for the pool, e.g. agy's "Weekly Limit Remaining". */
|
||||
windowName?: string
|
||||
}
|
||||
|
||||
export type UsageRateLimitSource = 'oauth' | 'cli' | 'web' | 'live-session'
|
||||
|
||||
Reference in New Issue
Block a user