From d4292119774c20bda2357fa488df4d2c137b13ce Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 25 Aug 2026 10:52:29 -0400 Subject: [PATCH 1/5] Improve AI credit balance meter --- .../composer/ai-credits-control.test.tsx | 64 ++++++++++++++- .../composer/ai-credits-control.tsx | 80 +++++++++++++++---- .../session-view/composer/style.module.css | 31 +++++++ 3 files changed, 158 insertions(+), 17 deletions(-) diff --git a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx index b20f572938..5d735ae5b1 100644 --- a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx +++ b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx @@ -107,6 +107,25 @@ describe( 'AiCreditsControl', () => { expect( screen.getByText( '1,110,000 remaining' ) ).toBeInTheDocument(); } ); + it( 'renders the circular usage chart from the current quota', () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 25, + costCap: 100, + allowanceRemaining: 960000, + purchasedRemaining: 150000, + }, + } as never ); + + const { container } = renderControl(); + + expect( container.querySelector( 'svg' ) ).toBeInTheDocument(); + expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute( + 'stroke-dashoffset', + '75' + ); + } ); + it( 'shows the summed balance even when both pools are exhausted', async () => { useStudioAssistantQuotaMock.mockReturnValue( { data: { costUsage: 0, costCap: 0, allowanceRemaining: 0, purchasedRemaining: 0 }, @@ -116,7 +135,50 @@ describe( 'AiCreditsControl', () => { await openMenu(); - expect( screen.getByText( '0 remaining' ) ).toBeInTheDocument(); + expect( screen.getByText( 'No AI credits remaining' ) ).toBeInTheDocument(); + } ); + + it( 'shows a zero-state message at 100% usage', async () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { costUsage: 100, costCap: 100, allowanceRemaining: 0, purchasedRemaining: 0 }, + } as never ); + const { container } = renderControl(); + + await openMenu(); + + expect( screen.getByText( 'No AI credits remaining' ) ).toBeInTheDocument(); + expect( container.querySelector( 'svg' ) ).toHaveAttribute( 'data-exhausted', 'true' ); + expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute( + 'stroke-dashoffset', + '0' + ); + } ); + + it( 'shows a compact balance in the tooltip', async () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 25, + costCap: 100, + allowanceRemaining: 800000, + purchasedRemaining: 32500, + }, + } as never ); + renderControl(); + + fireEvent.mouseEnter( screen.getByRole( 'button', { name: 'AI credits' } ) ); + + expect( await screen.findByText( 'AI credits · 832.5K remaining' ) ).toBeInTheDocument(); + } ); + + it( 'shows an out-of-credits tooltip at 100% usage', async () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { costUsage: 100, costCap: 100, allowanceRemaining: 0, purchasedRemaining: 0 }, + } as never ); + renderControl(); + + fireEvent.mouseEnter( screen.getByRole( 'button', { name: 'AI credits' } ) ); + + expect( await screen.findByText( 'AI credits · Out of credits' ) ).toBeInTheDocument(); } ); it( 'opens the WordPress.com checkout from the add-credits item', async () => { diff --git a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx index fbd733cb7c..bde27862a6 100644 --- a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx +++ b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx @@ -2,8 +2,7 @@ import { getStudioCodeAiAccessState } from '@studio/common/lib/studio-assistant- import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { __, sprintf } from '@wordpress/i18n'; -import { chartBar } from '@wordpress/icons'; -import { Icon, Tooltip } from '@wordpress/ui'; +import { Tooltip } from '@wordpress/ui'; import { useState } from 'react'; import { AiCreditsDetailsDialog } from '@/components/ai-credits-details-dialog'; import { AiCreditsPurchaseDialog } from '@/components/ai-credits-purchase-dialog'; @@ -18,6 +17,41 @@ import { useUserLocale } from '@/data/queries/use-user-locale'; import { useAddAiCreditsUrl } from '@/hooks/use-add-ai-credits-url'; import styles from './style.module.css'; +function AiCreditsRing( { usedFraction }: { usedFraction: number } ) { + const usedPercentage = Math.max( 0, Math.min( 1, usedFraction ) ) * 100; + const isCaution = usedPercentage >= 80 && usedPercentage < 90; + const isWarning = usedPercentage >= 90; + const isExhausted = usedPercentage >= 100; + + return ( + + ); +} + export function AiCreditsControl() { const connector = useConnector(); const addAiCreditsUrl = useAddAiCreditsUrl(); @@ -45,7 +79,28 @@ export function AiCreditsControl() { const hasTopUpOptions = ( pricing?.options.length ?? 0 ) > 0; const remaining = ( quota.allowanceRemaining ?? 0 ) + ( quota.purchasedRemaining ?? 0 ); + const usedFraction = quota.costCap > 0 ? quota.costUsage / quota.costCap : 0; const formattedRemaining = new Intl.NumberFormat( locale ).format( remaining ); + const compactRemaining = new Intl.NumberFormat( locale, { + notation: 'compact', + maximumFractionDigits: 1, + } ).format( remaining ); + const remainingLabel = + remaining === 0 + ? __( 'No AI credits remaining' ) + : sprintf( + /* translators: %s: total number of AI credits remaining (e.g. 1,110,000). */ + __( '%s remaining' ), + formattedRemaining + ); + const tooltipLabel = + remaining === 0 + ? __( 'AI credits · Out of credits' ) + : sprintf( + /* translators: %s: compact number of AI credits remaining (e.g. 200K or 1.5M). */ + __( 'AI credits · %s remaining' ), + compactRemaining + ); return ( <> @@ -73,28 +128,21 @@ export function AiCreditsControl() { /> } > - + } /> }> - { sprintf( - /* translators: %s: total number of AI credits remaining (e.g. 1,110,000). */ - __( 'AI credits · %s remaining' ), - formattedRemaining - ) } + { tooltipLabel } -
+
{ __( 'AI credits' ) } - - { sprintf( - /* translators: %s: total number of AI credits remaining (e.g. 1,110,000). */ - __( '%s remaining' ), - formattedRemaining - ) } - + { remainingLabel }
Date: Tue, 25 Aug 2026 16:15:26 -0400 Subject: [PATCH 2/5] Make AI credit meter reflect remaining balance --- .../composer/ai-credits-control.test.tsx | 83 +++++++++++++++++-- .../composer/ai-credits-control.tsx | 51 +++++++++--- .../session-view/composer/style.module.css | 4 - 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx index 5d735ae5b1..97f2396c8a 100644 --- a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx +++ b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.test.tsx @@ -107,22 +107,91 @@ describe( 'AiCreditsControl', () => { expect( screen.getByText( '1,110,000 remaining' ) ).toBeInTheDocument(); } ); - it( 'renders the circular usage chart from the current quota', () => { + it( 'shows the plenty state from the combined balance, regardless of the legacy quota usage', () => { useStudioAssistantQuotaMock.mockReturnValue( { data: { - costUsage: 25, + costUsage: 100, costCap: 100, - allowanceRemaining: 960000, + allowanceRemaining: 0, purchasedRemaining: 150000, }, } as never ); + const { container } = renderControl(); + const ring = container.querySelector( 'svg' ); + + expect( ring ).toBeInTheDocument(); + expect( ring ).not.toHaveAttribute( 'data-caution' ); + expect( ring ).not.toHaveAttribute( 'data-exhausted' ); + expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute( + 'stroke-dashoffset', + '40' + ); + } ); + + it( 'shows the low state when the combined balance reaches 100,000 credits', () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 0, + costCap: 0, + allowanceRemaining: 70000, + purchasedRemaining: 30000, + }, + } as never ); + + const { container } = renderControl(); + const ring = container.querySelector( 'svg' ); + + expect( ring ).toHaveAttribute( 'data-caution', 'true' ); + expect( ring ).not.toHaveAttribute( 'data-exhausted' ); + expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute( + 'stroke-dashoffset', + '30' + ); + } ); + + it( 'keeps the plenty state above the low-credit boundary', () => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 0, + costCap: 0, + allowanceRemaining: 100001, + purchasedRemaining: 0, + }, + } as never ); + + const { container } = renderControl(); + + expect( container.querySelector( 'svg' ) ).not.toHaveAttribute( 'data-caution' ); + } ); + + it.each( [ + [ 1_000_001, '100' ], + [ 1_000_000, '90' ], + [ 750_000, '80' ], + [ 500_000, '70' ], + [ 375_000, '60' ], + [ 250_000, '50' ], + [ 175_000, '40' ], + [ 100_000, '30' ], + [ 75_000, '20' ], + [ 50_000, '10' ], + [ 0, '0' ], + ] )( 'shows the expected ring step at %i remaining credits', ( remaining, dashOffset ) => { + useStudioAssistantQuotaMock.mockReturnValue( { + data: { + costUsage: 0, + costCap: 0, + allowanceRemaining: remaining, + purchasedRemaining: 0, + }, + } as never ); + const { container } = renderControl(); - expect( container.querySelector( 'svg' ) ).toBeInTheDocument(); expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute( 'stroke-dashoffset', - '75' + dashOffset ); } ); @@ -138,7 +207,7 @@ describe( 'AiCreditsControl', () => { expect( screen.getByText( 'No AI credits remaining' ) ).toBeInTheDocument(); } ); - it( 'shows a zero-state message at 100% usage', async () => { + it( 'shows the out state when the combined balance is empty', async () => { useStudioAssistantQuotaMock.mockReturnValue( { data: { costUsage: 100, costCap: 100, allowanceRemaining: 0, purchasedRemaining: 0 }, } as never ); @@ -170,7 +239,7 @@ describe( 'AiCreditsControl', () => { expect( await screen.findByText( 'AI credits · 832.5K remaining' ) ).toBeInTheDocument(); } ); - it( 'shows an out-of-credits tooltip at 100% usage', async () => { + it( 'shows an out-of-credits tooltip when the combined balance is empty', async () => { useStudioAssistantQuotaMock.mockReturnValue( { data: { costUsage: 100, costCap: 100, allowanceRemaining: 0, purchasedRemaining: 0 }, } as never ); diff --git a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx index bde27862a6..b9ad04eab2 100644 --- a/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx +++ b/apps/ui/src/ui-classic/components/session-view/composer/ai-credits-control.tsx @@ -17,11 +17,41 @@ import { useUserLocale } from '@/data/queries/use-user-locale'; import { useAddAiCreditsUrl } from '@/hooks/use-add-ai-credits-url'; import styles from './style.module.css'; -function AiCreditsRing( { usedFraction }: { usedFraction: number } ) { - const usedPercentage = Math.max( 0, Math.min( 1, usedFraction ) ) * 100; - const isCaution = usedPercentage >= 80 && usedPercentage < 90; - const isWarning = usedPercentage >= 90; - const isExhausted = usedPercentage >= 100; +const LOW_AI_CREDITS_THRESHOLD = 100_000; + +type AiCreditsStatus = 'plenty' | 'low' | 'out'; + +// The API exposes the combined remaining balance, but not the original total +// of purchased credits. The ring therefore uses discrete low-balance states +// rather than presenting its arc as an exact usage percentage. +const AI_CREDITS_RING_STEPS = [ + { maximum: 50_000, fillPercentage: 90 }, + { maximum: 75_000, fillPercentage: 80 }, + { maximum: 100_000, fillPercentage: 70 }, + { maximum: 175_000, fillPercentage: 60 }, + { maximum: 250_000, fillPercentage: 50 }, + { maximum: 375_000, fillPercentage: 40 }, + { maximum: 500_000, fillPercentage: 30 }, + { maximum: 750_000, fillPercentage: 20 }, + { maximum: 1_000_000, fillPercentage: 10 }, +] as const; + +function getAiCreditsStatus( remaining: number ): AiCreditsStatus { + if ( remaining === 0 ) { + return 'out'; + } + return remaining <= LOW_AI_CREDITS_THRESHOLD ? 'low' : 'plenty'; +} + +function getAiCreditsRingPercentage( remaining: number ): number { + if ( remaining === 0 ) { + return 100; + } + return AI_CREDITS_RING_STEPS.find( ( step ) => remaining <= step.maximum )?.fillPercentage ?? 0; +} + +function AiCreditsRing( { remaining, status }: { remaining: number; status: AiCreditsStatus } ) { + const fillPercentage = getAiCreditsRingPercentage( remaining ); return (