mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(status-bar): choose used or remaining usage (#7574)
Add a persisted Appearance setting that switches provider usage labels between percent used and percent remaining while keeping meter fill consumption-based. Cover desktop and web persistence, current providers, settings search, localized copy, and regression tests. Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
This commit is contained in:
@@ -104,6 +104,7 @@ import {
|
||||
ONBOARDING_FINAL_STEP
|
||||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostOrder,
|
||||
@@ -5333,6 +5334,9 @@ export class Store {
|
||||
this.state.ui?.workspaceBoardColumnWidth
|
||||
),
|
||||
syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
this.state.ui?.usagePercentageDisplay
|
||||
),
|
||||
// Why: strict boolean coercion so a missing/legacy value reads as false
|
||||
// (first-run notice still fires) rather than leaking a non-bool through.
|
||||
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
|
||||
@@ -5412,6 +5416,9 @@ export class Store {
|
||||
sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined
|
||||
? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true
|
||||
: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay
|
||||
),
|
||||
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
|
||||
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
|
||||
),
|
||||
|
||||
@@ -208,6 +208,7 @@ const UiUpdate = z
|
||||
_antigravityStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_grokStatusBarDefaultAdded: z.boolean().optional(),
|
||||
statusBarVisible: z.boolean().optional(),
|
||||
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
|
||||
dismissedUpdateVersion: NullableString.optional(),
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
pendingUpdateNudgeId: NullableString.optional(),
|
||||
|
||||
@@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({
|
||||
settingsSearchQuery: 'automations',
|
||||
statusBarItems: [],
|
||||
toggleStatusBarItem: vi.fn(),
|
||||
usagePercentageDisplay: 'used' as 'used' | 'remaining',
|
||||
setUsagePercentageDisplay: vi.fn(),
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
setWorktreeCardMode: vi.fn()
|
||||
}
|
||||
@@ -176,6 +178,7 @@ describe('AppearancePane', () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.state.availableStatusBarToggles = []
|
||||
mocks.state.settingsSearchQuery = 'automations'
|
||||
mocks.state.usagePercentageDisplay = 'used'
|
||||
// UIZoomControl reads window.api.ui on mount; the inline-expansion pane can
|
||||
// render the full Interface section, so provide a minimal renderer bridge
|
||||
// without clobbering happy-dom's window.location.
|
||||
@@ -387,6 +390,23 @@ describe('AppearancePane', () => {
|
||||
expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('updates the usage percentage display from the latest status bar settings section', async () => {
|
||||
mocks.state.settingsSearchQuery = 'remaining'
|
||||
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
|
||||
const remainingButton = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
|
||||
).find((button) => button.textContent === 'Remaining')
|
||||
|
||||
expect(container.textContent).toContain('Usage percentages')
|
||||
expect(remainingButton).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
remainingButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(mocks.state.setUsagePercentageDisplay).toHaveBeenCalledWith('remaining')
|
||||
})
|
||||
|
||||
it('records MiniMax status bar toggles as usage tracking interactions', async () => {
|
||||
mocks.state.availableStatusBarToggles = [
|
||||
{
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
SettingsSwitchRow
|
||||
} from './SettingsFormControls'
|
||||
import { useAvailableStatusBarToggles } from '../status-bar/use-available-status-bar-toggles'
|
||||
import { getLayoutEntries, getSidebarEntries, getStatusBarToggles } from './appearance-search'
|
||||
import {
|
||||
getLayoutEntries,
|
||||
getSidebarEntries,
|
||||
getStatusBarToggles,
|
||||
getUsagePercentageDisplayEntry
|
||||
} from './appearance-search'
|
||||
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
|
||||
import {
|
||||
getLeftSidebarAppearanceEntry,
|
||||
@@ -60,9 +65,12 @@ export function AppearanceWindowSidebarSection({
|
||||
const isSearching = normalizeSettingsSearchQuery(searchQuery).length > 0
|
||||
const statusBarItems = useAppStore((state) => state.statusBarItems)
|
||||
const toggleStatusBarItem = useAppStore((state) => state.toggleStatusBarItem)
|
||||
const usagePercentageDisplay = useAppStore((state) => state.usagePercentageDisplay)
|
||||
const setUsagePercentageDisplay = useAppStore((state) => state.setUsagePercentageDisplay)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
const setWorktreeCardMode = useAppStore((state) => state.setWorktreeCardMode)
|
||||
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
|
||||
const usagePercentageDisplayEntry = getUsagePercentageDisplayEntry()
|
||||
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
|
||||
const sidebarEntries = getSidebarEntries()
|
||||
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
|
||||
@@ -81,13 +89,15 @@ export function AppearanceWindowSidebarSection({
|
||||
description: statusBarDescription,
|
||||
keywords: statusBarKeywords
|
||||
})
|
||||
const statusBarControlMatches = visibleStatusBarToggles.some((toggle) =>
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: toggle.title,
|
||||
description: toggle.description,
|
||||
keywords: toggle.keywords
|
||||
})
|
||||
)
|
||||
const statusBarControlMatches =
|
||||
matchesSettingsSearch(searchQuery, usagePercentageDisplayEntry) ||
|
||||
visibleStatusBarToggles.some((toggle) =>
|
||||
matchesSettingsSearch(searchQuery, {
|
||||
title: toggle.title,
|
||||
description: toggle.description,
|
||||
keywords: toggle.keywords
|
||||
})
|
||||
)
|
||||
const sidebarAdvancedMatches = matchesSettingsSearch(searchQuery, [
|
||||
workspaceCardLayoutEntry,
|
||||
...sidebarEntries
|
||||
@@ -119,6 +129,40 @@ export function AppearanceWindowSidebarSection({
|
||||
<SettingsRow label={statusBarTitle} description={statusBarDescription} control={null} />
|
||||
{showStatusBarControls ? (
|
||||
<div className="ml-4 divide-y divide-border/40 border-t border-border/40">
|
||||
<SearchableSetting
|
||||
title={usagePercentageDisplayEntry.title}
|
||||
description={usagePercentageDisplayEntry.description}
|
||||
keywords={usagePercentageDisplayEntry.keywords}
|
||||
>
|
||||
<SettingsRow
|
||||
label={usagePercentageDisplayEntry.title}
|
||||
description={usagePercentageDisplayEntry.description}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={usagePercentageDisplayEntry.title}
|
||||
value={usagePercentageDisplay}
|
||||
onChange={setUsagePercentageDisplay}
|
||||
options={[
|
||||
{
|
||||
value: 'used',
|
||||
label: translate(
|
||||
'auto.components.settings.AppearanceWindowSidebarSection.usagePercentageDisplayUsed',
|
||||
'Used'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'remaining',
|
||||
label: translate(
|
||||
'auto.components.settings.AppearanceWindowSidebarSection.usagePercentageDisplayRemaining',
|
||||
'Remaining'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
{visibleStatusBarToggles.map((toggle) => {
|
||||
const enabled = statusBarItems.includes(toggle.id)
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,9 @@ import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
|
||||
import { getStatusBarToggles } from './appearance-status-bar-search'
|
||||
import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search'
|
||||
|
||||
export { getStatusBarToggles }
|
||||
export { getStatusBarToggles, getUsagePercentageDisplayEntry }
|
||||
|
||||
export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
|
||||
{
|
||||
@@ -148,13 +149,14 @@ export const getTitlebarEntries = createLocalizedCatalog((): SettingsSearchEntry
|
||||
}
|
||||
])
|
||||
|
||||
export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] =>
|
||||
getStatusBarToggles().map(({ title, description, keywords }) => ({
|
||||
export const getStatusBarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
|
||||
getUsagePercentageDisplayEntry(),
|
||||
...getStatusBarToggles().map(({ title, description, keywords }) => ({
|
||||
title,
|
||||
description,
|
||||
keywords
|
||||
}))
|
||||
)
|
||||
])
|
||||
|
||||
export { getLeftSidebarAppearanceEntry, getSidebarEntries }
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { translateSearchKeyword } from './settings-search-keywords'
|
||||
|
||||
export const getUsagePercentageDisplayEntry = createLocalizedCatalog(() => ({
|
||||
title: translate(
|
||||
'auto.components.settings.appearance.search.usagePercentageDisplayTitle',
|
||||
'Usage percentages'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.appearance.search.usagePercentageDisplayDescription',
|
||||
'Choose whether provider limits show the percentage used or remaining.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'),
|
||||
...translateSearchKeyword('auto.components.settings.appearance.search.896eb53fd4', 'status bar')
|
||||
]
|
||||
}))
|
||||
@@ -79,6 +79,11 @@ import {
|
||||
selectCodexProviderAccount
|
||||
} from '@/runtime/runtime-provider-accounts-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
normalizeUsagePercentageDisplay,
|
||||
type UsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
import { formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
|
||||
type StatusBarProps = {
|
||||
floatingTerminalOpen: boolean
|
||||
@@ -963,9 +968,11 @@ 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 display = normalizeUsagePercentageDisplay(
|
||||
useAppStore((state) => state.usagePercentageDisplay)
|
||||
)
|
||||
// Why: the preference changes copy, while bar fill stays consumption-based
|
||||
// so empty/green and full/red keep the meter semantics introduced in #8167.
|
||||
const usageWindows = [
|
||||
limits.session
|
||||
? {
|
||||
@@ -1006,8 +1013,7 @@ export function InlineUsageBars({
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{window.used}
|
||||
{usedSuffix} {window.label}
|
||||
{formatUsagePercentageLabel(window.used, display)} {window.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1080,16 +1086,21 @@ function InlineUsageSkeleton(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window label (shows percent used / consumption)
|
||||
// Window label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WindowLabel({ w, label }: { w: RateLimitWindow; label: string }): React.JSX.Element {
|
||||
const used = clampUsedPercent(w.usedPercent)
|
||||
// Why: "32% 5h" is ambiguous after the remaining→used flip; keep "used" explicit.
|
||||
function WindowLabel({
|
||||
w,
|
||||
label,
|
||||
display
|
||||
}: {
|
||||
w: RateLimitWindow
|
||||
label: string
|
||||
display: UsagePercentageDisplay
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{used}
|
||||
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')} {label}
|
||||
{formatUsagePercentageLabel(w.usedPercent, display)} {label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1104,10 +1115,12 @@ const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro'])
|
||||
|
||||
function ProviderSegment({
|
||||
p,
|
||||
compact
|
||||
compact,
|
||||
display
|
||||
}: {
|
||||
p: ProviderRateLimits | null
|
||||
compact: boolean
|
||||
display: UsagePercentageDisplay
|
||||
}): React.JSX.Element {
|
||||
const provider = p?.provider ?? 'claude'
|
||||
const statusLabel = p ? getProviderUsageStatusLabel(p) : ''
|
||||
@@ -1160,20 +1173,20 @@ function ProviderSegment({
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon provider={provider} />
|
||||
{visibleBuckets.map((bucket, i) => {
|
||||
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} {used}
|
||||
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
{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 && (
|
||||
<WindowLabel w={p.session} label={formatWindowLabel(p.session.windowMinutes)} />
|
||||
<WindowLabel
|
||||
w={p.session}
|
||||
label={formatWindowLabel(p.session.windowMinutes)}
|
||||
display={display}
|
||||
/>
|
||||
)}
|
||||
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
|
||||
</span>
|
||||
@@ -1211,7 +1224,7 @@ function ProviderSegment({
|
||||
{visibleWindows.map((window, index) => (
|
||||
<React.Fragment key={window.key}>
|
||||
{index > 0 && <span className="text-muted-foreground">·</span>}
|
||||
<WindowLabel w={window.window} label={window.label} />
|
||||
<WindowLabel w={window.window} label={window.label} display={display} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
|
||||
@@ -1745,6 +1758,9 @@ export function ProviderDetailsMenu({
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const usagePercentageDisplay = normalizeUsagePercentageDisplay(
|
||||
useAppStore((s) => s.usagePercentageDisplay)
|
||||
)
|
||||
const skipCloseAutoFocusRef = useRef(false)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
@@ -1787,7 +1803,7 @@ export function ProviderDetailsMenu({
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<ProviderSegment p={provider} compact={compact} />
|
||||
<ProviderSegment p={provider} compact={compact} display={usagePercentageDisplay} />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -1812,7 +1828,11 @@ export function ProviderDetailsMenu({
|
||||
{topContent}
|
||||
<div className="p-2">
|
||||
{/* Why: provider-specific action sections may render richer reset-credit UI. */}
|
||||
<ProviderPanel p={provider} showResetCredits={!hidePanelResetCredits} />
|
||||
<ProviderPanel
|
||||
p={provider}
|
||||
showResetCredits={!hidePanelResetCredits}
|
||||
usagePercentageDisplay={usagePercentageDisplay}
|
||||
/>
|
||||
</div>
|
||||
{children ? (
|
||||
<>
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
i18n: { language: 'en' },
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) => {
|
||||
let result = fallback
|
||||
for (const [key, value] of Object.entries(values ?? {})) {
|
||||
result = result.replace(`{{${key}}}`, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
AgentIcon: () => null
|
||||
}))
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
usagePercentageDisplay: 'used' as 'used' | 'remaining'
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) =>
|
||||
selector({ usagePercentageDisplay: mocks.usagePercentageDisplay })
|
||||
}))
|
||||
|
||||
function claudeLimits(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
@@ -39,6 +54,10 @@ function claudeLimits(): ProviderRateLimits {
|
||||
}
|
||||
|
||||
describe('InlineUsageBars', () => {
|
||||
beforeEach(() => {
|
||||
mocks.usagePercentageDisplay = 'used'
|
||||
})
|
||||
|
||||
it('renders Claude Fable usage in inactive account preview rows', async () => {
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
|
||||
@@ -51,4 +70,18 @@ describe('InlineUsageBars', () => {
|
||||
expect(markup).toContain('16% used wk')
|
||||
expect(markup).toContain('42% used Fable')
|
||||
})
|
||||
|
||||
it('shows remaining copy without reversing consumption meter fill', async () => {
|
||||
mocks.usagePercentageDisplay = 'remaining'
|
||||
const { InlineUsageBars } = await import('./StatusBar')
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<InlineUsageBars limits={claudeLimits()} isFetching={false} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('68% left 5h')
|
||||
expect(markup).toContain('84% left wk')
|
||||
expect(markup).toContain('58% left Fable')
|
||||
expect(markup).toContain('width:32%')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const recordFeatureInteractionMock = vi.fn()
|
||||
const usagePercentageDisplayMock = 'used'
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
@@ -17,8 +18,15 @@ vi.mock('react', async () => {
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (
|
||||
selector: (state: { recordFeatureInteraction: typeof recordFeatureInteractionMock }) => unknown
|
||||
) => selector({ recordFeatureInteraction: recordFeatureInteractionMock })
|
||||
selector: (state: {
|
||||
recordFeatureInteraction: typeof recordFeatureInteractionMock
|
||||
usagePercentageDisplay: 'used'
|
||||
}) => unknown
|
||||
) =>
|
||||
selector({
|
||||
recordFeatureInteraction: recordFeatureInteractionMock,
|
||||
usagePercentageDisplay: usagePercentageDisplayMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tooltip', () => ({
|
||||
@@ -108,8 +116,10 @@ describe('ProviderDetailsMenu focus handoff', () => {
|
||||
const element = await renderProviderDetailsMenu()
|
||||
const dropdown = findChildByType(element, 'DropdownMenu')
|
||||
const content = findChildByType(element, 'DropdownMenuContent')
|
||||
const providerPanel = findChildByType(element, 'ProviderPanel')
|
||||
|
||||
expect(dropdown.props.modal).toBe(false)
|
||||
expect(providerPanel.props.usagePercentageDisplay).toBe('used')
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
;(content.props.onCloseAutoFocus as (event: { preventDefault: () => void }) => void)({
|
||||
|
||||
@@ -45,6 +45,17 @@ function provider(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLimi
|
||||
}
|
||||
}
|
||||
|
||||
const PROVIDER_IDS: ProviderRateLimits['provider'][] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'minimax',
|
||||
'grok'
|
||||
]
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
@@ -473,6 +484,27 @@ describe('ProviderPanel reset rendering', () => {
|
||||
expect(markup).toContain('width:100%')
|
||||
expect(markup).not.toContain('140%')
|
||||
})
|
||||
|
||||
it.each(PROVIDER_IDS)(
|
||||
'applies remaining copy to %s while retaining consumption bar direction',
|
||||
(providerId) => {
|
||||
const p = provider({
|
||||
provider: providerId,
|
||||
status: 'ok',
|
||||
session: {
|
||||
usedPercent: 25,
|
||||
windowMinutes: 300,
|
||||
resetsAt: null,
|
||||
resetDescription: null
|
||||
}
|
||||
})
|
||||
|
||||
const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' }))
|
||||
|
||||
expect(markup).toContain('75% left')
|
||||
expect(markup).toContain('width:25%')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('clampUsedPercent', () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getProviderUsageErrorMessage,
|
||||
getProviderUsageStatusLabel
|
||||
} from './usage-error-copy'
|
||||
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import { formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
|
||||
export {
|
||||
getProviderDisplayName,
|
||||
@@ -213,12 +215,14 @@ export function ProviderPanel({
|
||||
p,
|
||||
inverted = false,
|
||||
className,
|
||||
showResetCredits = true
|
||||
showResetCredits = true,
|
||||
usagePercentageDisplay = 'used'
|
||||
}: {
|
||||
p: ProviderRateLimits | null
|
||||
inverted?: boolean
|
||||
className?: string
|
||||
showResetCredits?: boolean
|
||||
usagePercentageDisplay?: UsagePercentageDisplay
|
||||
}): React.JSX.Element {
|
||||
const textClass = inverted ? 'text-background' : 'text-foreground'
|
||||
const mutedClass = inverted ? 'text-background/60' : 'text-muted-foreground'
|
||||
@@ -288,8 +292,8 @@ export function ProviderPanel({
|
||||
if (!w) {
|
||||
return null
|
||||
}
|
||||
// Why: show % used (consumption), not remaining — matches Claude/Codex
|
||||
// harness meters and avoids the "full green bar = exhausted" misread (#7551).
|
||||
// Why: preference changes the copy only; consumption-based bar direction
|
||||
// preserves the empty/green to full/red meter convention from #8167.
|
||||
const usedPct = clampUsedPercent(w.usedPercent)
|
||||
const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null
|
||||
|
||||
@@ -303,10 +307,7 @@ export function ProviderPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex justify-between ${mutedClass}`}>
|
||||
<span>
|
||||
{usedPct}
|
||||
{translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used')}
|
||||
</span>
|
||||
<span>{formatUsagePercentageLabel(usedPct, usagePercentageDisplay)}</span>
|
||||
{resetLabel && <span>{resetLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', values?.value0 ?? '')
|
||||
}))
|
||||
|
||||
import { formatUsagePercentageLabel } from './usage-percentage-label'
|
||||
|
||||
describe('formatUsagePercentageLabel', () => {
|
||||
it('formats used and remaining percentages without changing source semantics', () => {
|
||||
expect(formatUsagePercentageLabel(8, 'used')).toBe('8% used')
|
||||
expect(formatUsagePercentageLabel(8, 'remaining')).toBe('92% left')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getDisplayedUsagePercentage,
|
||||
type UsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
|
||||
export function formatUsagePercentageLabel(
|
||||
usedPercent: number,
|
||||
display: UsagePercentageDisplay
|
||||
): string {
|
||||
const percentage = getDisplayedUsagePercentage(usedPercent, display)
|
||||
return display === 'used'
|
||||
? translate('auto.components.status.bar.usagePercentageLabel.used', '{{value0}}% used', {
|
||||
value0: String(percentage)
|
||||
})
|
||||
: translate('auto.components.status.bar.usagePercentageLabel.remaining', '{{value0}}% left', {
|
||||
value0: String(percentage)
|
||||
})
|
||||
}
|
||||
@@ -3205,6 +3205,10 @@
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "SSH Host"
|
||||
},
|
||||
"usagePercentageLabel": {
|
||||
"used": "{{value0}}% used",
|
||||
"remaining": "{{value0}}% left"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7238,7 +7242,9 @@
|
||||
"f8e2a1c4b6": "Grok Usage",
|
||||
"e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.",
|
||||
"d6c0a9e2f4": "grok",
|
||||
"c5b9f8d1e3": "xai"
|
||||
"c5b9f8d1e3": "xai",
|
||||
"usagePercentageDisplayTitle": "Usage percentages",
|
||||
"usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining."
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
@@ -8994,6 +9000,10 @@
|
||||
"a8f3e2c1b4": "Weekly credits",
|
||||
"b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.",
|
||||
"c6d1a8f4e2": "Resets {{when}}"
|
||||
},
|
||||
"AppearanceWindowSidebarSection": {
|
||||
"usagePercentageDisplayUsed": "Used",
|
||||
"usagePercentageDisplayRemaining": "Remaining"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -3205,6 +3205,10 @@
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "Host SSH"
|
||||
},
|
||||
"usagePercentageLabel": {
|
||||
"used": "{{value0}}% usado",
|
||||
"remaining": "{{value0}}% restante"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7201,7 +7205,9 @@
|
||||
"f8e2a1c4b6": "Uso de Grok",
|
||||
"e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.",
|
||||
"d6c0a9e2f4": "grok",
|
||||
"c5b9f8d1e3": "xai"
|
||||
"c5b9f8d1e3": "xai",
|
||||
"usagePercentageDisplayTitle": "Porcentajes de uso",
|
||||
"usagePercentageDisplayDescription": "Elige si los límites del proveedor muestran el porcentaje usado o restante."
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
@@ -8994,6 +9000,10 @@
|
||||
"a8f3e2c1b4": "Créditos semanales",
|
||||
"b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.",
|
||||
"c6d1a8f4e2": "Se restablece {{when}}"
|
||||
},
|
||||
"AppearanceWindowSidebarSection": {
|
||||
"usagePercentageDisplayUsed": "Usado",
|
||||
"usagePercentageDisplayRemaining": "Restante"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -3205,6 +3205,10 @@
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "SSH ホスト"
|
||||
},
|
||||
"usagePercentageLabel": {
|
||||
"used": "{{value0}}% 使用済み",
|
||||
"remaining": "残り {{value0}}%"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7223,7 +7227,9 @@
|
||||
"f8e2a1c4b6": "Grok の使用状況",
|
||||
"e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。",
|
||||
"d6c0a9e2f4": "grok",
|
||||
"c5b9f8d1e3": "xai"
|
||||
"c5b9f8d1e3": "xai",
|
||||
"usagePercentageDisplayTitle": "使用率",
|
||||
"usagePercentageDisplayDescription": "プロバイダーの制限を使用済みまたは残りの割合で表示するかを選択します。"
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
@@ -8994,6 +9000,10 @@
|
||||
"a8f3e2c1b4": "週次クレジット",
|
||||
"b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。",
|
||||
"c6d1a8f4e2": "{{when}} にリセット"
|
||||
},
|
||||
"AppearanceWindowSidebarSection": {
|
||||
"usagePercentageDisplayUsed": "使用済み",
|
||||
"usagePercentageDisplayRemaining": "残り"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -3205,6 +3205,10 @@
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "SSH 호스트"
|
||||
},
|
||||
"usagePercentageLabel": {
|
||||
"used": "{{value0}}% 사용",
|
||||
"remaining": "{{value0}}% 남음"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7186,7 +7190,9 @@
|
||||
"f8e2a1c4b6": "Grok 사용량",
|
||||
"e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.",
|
||||
"d6c0a9e2f4": "grok",
|
||||
"c5b9f8d1e3": "xai"
|
||||
"c5b9f8d1e3": "xai",
|
||||
"usagePercentageDisplayTitle": "사용량 백분율",
|
||||
"usagePercentageDisplayDescription": "공급자 한도를 사용한 비율 또는 남은 비율로 표시할지 선택합니다."
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
@@ -8994,6 +9000,10 @@
|
||||
"a8f3e2c1b4": "주간 크레딧",
|
||||
"b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.",
|
||||
"c6d1a8f4e2": "{{when}}에 재설정"
|
||||
},
|
||||
"AppearanceWindowSidebarSection": {
|
||||
"usagePercentageDisplayUsed": "사용",
|
||||
"usagePercentageDisplayRemaining": "남음"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -3205,6 +3205,10 @@
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "SSH 主机"
|
||||
},
|
||||
"usagePercentageLabel": {
|
||||
"used": "已用 {{value0}}%",
|
||||
"remaining": "剩余 {{value0}}%"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7186,7 +7190,9 @@
|
||||
"f8e2a1c4b6": "Grok 使用量",
|
||||
"e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。",
|
||||
"d6c0a9e2f4": "grok",
|
||||
"c5b9f8d1e3": "xai"
|
||||
"c5b9f8d1e3": "xai",
|
||||
"usagePercentageDisplayTitle": "用量百分比",
|
||||
"usagePercentageDisplayDescription": "选择以已用或剩余百分比显示提供商限额。"
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
@@ -8994,6 +9000,10 @@
|
||||
"a8f3e2c1b4": "每周额度",
|
||||
"b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。",
|
||||
"c6d1a8f4e2": "{{when}} 重置"
|
||||
},
|
||||
"AppearanceWindowSidebarSection": {
|
||||
"usagePercentageDisplayUsed": "已用",
|
||||
"usagePercentageDisplayRemaining": "剩余"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -1260,6 +1260,32 @@ describe('createUISlice hydratePersistedUI', () => {
|
||||
expect(setUI).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists and hydrates the usage percentage display preference', () => {
|
||||
const setUI = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().setUsagePercentageDisplay('used')
|
||||
|
||||
expect(store.getState().usagePercentageDisplay).toBe('used')
|
||||
expect(setUI).toHaveBeenCalledWith({ usagePercentageDisplay: 'used' })
|
||||
|
||||
store.getState().hydratePersistedUI(makePersistedUI({ usagePercentageDisplay: 'remaining' }))
|
||||
expect(store.getState().usagePercentageDisplay).toBe('remaining')
|
||||
})
|
||||
|
||||
it('defaults invalid usage percentage display values to used', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().hydratePersistedUI(
|
||||
makePersistedUI({
|
||||
usagePercentageDisplay: 'left' as PersistedUIState['usagePercentageDisplay']
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getState().usagePercentageDisplay).toBe('used')
|
||||
})
|
||||
|
||||
it('clamps persisted workspace board column width', () => {
|
||||
const store = createUIStore()
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ import type {
|
||||
WorkspaceHostScope,
|
||||
VisibleWorkspaceHostIds
|
||||
} from '../../../../shared/types'
|
||||
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
|
||||
import {
|
||||
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
normalizeUsagePercentageDisplay
|
||||
} from '../../../../shared/usage-percentage-display'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
@@ -869,6 +874,8 @@ export type UISlice = {
|
||||
toggleStatusBarItem: (item: StatusBarItem) => void
|
||||
statusBarVisible: boolean
|
||||
setStatusBarVisible: (v: boolean) => void
|
||||
usagePercentageDisplay: UsagePercentageDisplay
|
||||
setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void
|
||||
workspacePortScan: { key: string; result: WorkspacePortScanResult } | null
|
||||
workspacePortScansByKey: Record<string, WorkspacePortScanResult>
|
||||
workspacePortScanRefreshing: boolean
|
||||
@@ -2112,6 +2119,12 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
window.api.ui.set({ statusBarVisible: v }).catch(console.error)
|
||||
set({ statusBarVisible: v })
|
||||
},
|
||||
usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
setUsagePercentageDisplay: (display) => {
|
||||
const normalized = normalizeUsagePercentageDisplay(display)
|
||||
window.api.ui.set({ usagePercentageDisplay: normalized }).catch(console.error)
|
||||
set({ usagePercentageDisplay: normalized })
|
||||
},
|
||||
workspacePortScan: null,
|
||||
workspacePortScansByKey: {},
|
||||
workspacePortScanRefreshing: false,
|
||||
@@ -2400,6 +2413,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true,
|
||||
statusBarItems: statusBarItemsWithGrok,
|
||||
statusBarVisible: ui.statusBarVisible ?? true,
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay),
|
||||
// Why: absent → true so existing users see the pet the first time
|
||||
// they enable the experimental flag. Only an explicit Hide pet
|
||||
// dismissal persists a `false` value.
|
||||
|
||||
@@ -71,6 +71,7 @@ import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto
|
||||
import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings'
|
||||
import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes'
|
||||
import { normalizeUiLanguage } from '../../../shared/ui-language'
|
||||
import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display'
|
||||
import type { RateLimitState } from '../../../shared/rate-limit-types'
|
||||
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
|
||||
import {
|
||||
@@ -3251,6 +3252,9 @@ function mergeWebUIState(
|
||||
safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted,
|
||||
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(
|
||||
safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode
|
||||
),
|
||||
usagePercentageDisplay: normalizeUsagePercentageDisplay(
|
||||
safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
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'
|
||||
|
||||
export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
|
||||
export {
|
||||
@@ -495,6 +496,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
_workspaceStatusesDefaultVisualsMigrated: true,
|
||||
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
|
||||
statusBarVisible: true,
|
||||
usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
dismissedUpdateVersion: null,
|
||||
lastUpdateCheckAt: null,
|
||||
trustedOrcaHooks: {},
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
GlobalWindowsRuntimeDefault,
|
||||
LocalWindowsRuntimePreference
|
||||
} from './project-execution-runtime'
|
||||
import type { UsagePercentageDisplay } from './usage-percentage-display'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
@@ -3311,6 +3312,8 @@ export type PersistedUIState = {
|
||||
_grokStatusBarDefaultAdded?: boolean
|
||||
statusBarItems: StatusBarItem[]
|
||||
statusBarVisible: boolean
|
||||
/** Why: this is client-side presentation, not a provider/account or execution-host setting. */
|
||||
usagePercentageDisplay?: UsagePercentageDisplay
|
||||
dismissedUpdateVersion: string | null
|
||||
lastUpdateCheckAt: number | null
|
||||
pendingUpdateNudgeId?: string | null
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getDisplayedUsagePercentage,
|
||||
normalizeUsagePercentageDisplay
|
||||
} from './usage-percentage-display'
|
||||
|
||||
describe('usage percentage display', () => {
|
||||
it('defaults unknown persisted values to the current used-capacity behavior', () => {
|
||||
expect(normalizeUsagePercentageDisplay(undefined)).toBe('used')
|
||||
expect(normalizeUsagePercentageDisplay('left')).toBe('used')
|
||||
})
|
||||
|
||||
it('shows either the provider value or its complement', () => {
|
||||
expect(getDisplayedUsagePercentage(6, 'used')).toBe(6)
|
||||
expect(getDisplayedUsagePercentage(6, 'remaining')).toBe(94)
|
||||
})
|
||||
|
||||
it('rounds and bounds percentages for display', () => {
|
||||
expect(getDisplayedUsagePercentage(20.5, 'used')).toBe(21)
|
||||
expect(getDisplayedUsagePercentage(20.5, 'remaining')).toBe(80)
|
||||
expect(getDisplayedUsagePercentage(120, 'remaining')).toBe(0)
|
||||
expect(getDisplayedUsagePercentage(-20, 'used')).toBe(0)
|
||||
expect(getDisplayedUsagePercentage(Number.NaN, 'remaining')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
export type UsagePercentageDisplay = 'used' | 'remaining'
|
||||
|
||||
// Why: missing settings preserve the consumption-meter behavior introduced in #8167.
|
||||
export const DEFAULT_USAGE_PERCENTAGE_DISPLAY: UsagePercentageDisplay = 'used'
|
||||
|
||||
export function normalizeUsagePercentageDisplay(value: unknown): UsagePercentageDisplay {
|
||||
return value === 'used' || value === 'remaining' ? value : DEFAULT_USAGE_PERCENTAGE_DISPLAY
|
||||
}
|
||||
|
||||
export function getDisplayedUsagePercentage(
|
||||
usedPercent: number,
|
||||
display: UsagePercentageDisplay
|
||||
): number {
|
||||
if (!Number.isFinite(usedPercent)) {
|
||||
// Why: invalid provider data must not be presented as 100% remaining capacity.
|
||||
return 0
|
||||
}
|
||||
const boundedUsedPercent = Math.min(100, Math.max(0, usedPercent))
|
||||
const percentage = display === 'used' ? boundedUsedPercent : 100 - boundedUsedPercent
|
||||
return Math.round(percentage)
|
||||
}
|
||||
Reference in New Issue
Block a user