Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ describe( 'AiCreditsControl', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 0,
costCap: 1_000_000,
allowanceRemaining: 960000,
purchasedRemaining: 150000,
purchasedAtTopUp: 200_000,
},
} as never );
} );
Expand Down Expand Up @@ -107,6 +108,128 @@ describe( 'AiCreditsControl', () => {
expect( screen.getByText( '1,110,000 remaining' ) ).toBeInTheDocument();
} );

it( 'resets the ring to the purchased pool once the allowance is spent', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 100,
costCap: 1_000_000,
allowanceRemaining: 0,
purchasedRemaining: 150000,
purchasedAtTopUp: 200_000,
},
} as never );

const { container } = renderControl();
const ring = container.querySelector( 'svg' );

expect( ring ).toBeInTheDocument();
expect( ring ).not.toHaveAttribute( 'data-intent' );
// The spent allowance drops out of the meter: 50,000 of 200,000
// purchased credits used, same as the Settings → Usage bar.
expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute(
'stroke-dashoffset',
'75'
);
} );

it( 'escalates to the warning intent at 80% of the metered credits used', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 1_000_000,
allowanceRemaining: 150000,
purchasedRemaining: 30000,
purchasedAtTopUp: 100_000,
},
} as never );

const { container } = renderControl();

// 920,000 of 1,100,000 used → ~84%.
expect( container.querySelector( 'svg' ) ).toHaveAttribute( 'data-intent', 'warning' );
} );

it( 'escalates to the critical intent at 90% of the metered credits used', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 1_000_000,
allowanceRemaining: 70000,
purchasedRemaining: 30000,
purchasedAtTopUp: 100_000,
},
} as never );

const { container } = renderControl();

expect( container.querySelector( 'svg' ) ).toHaveAttribute( 'data-intent', 'critical' );
// 1,000,000 of 1,100,000 used → 91% fill.
expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute(
'stroke-dashoffset',
'9'
);
} );

it( 'keeps the plain ring below 80% usage', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 1_000_000,
allowanceRemaining: 200001,
purchasedRemaining: 0,
},
} as never );

const { container } = renderControl();

expect( container.querySelector( 'svg' ) ).not.toHaveAttribute( 'data-intent' );
} );

it.each( [
[ 1_000_000, '100' ],
[ 750_000, '75' ],
[ 500_000, '50' ],
[ 250_000, '25' ],
[ 100_000, '10' ],
[ 0, '0' ],
] )( 'draws the used fraction of the ring at %i remaining credits', ( remaining, dashOffset ) => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 1_000_000,
allowanceRemaining: remaining,
purchasedRemaining: 0,
},
} as never );

const { container } = renderControl();

expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute(
'stroke-dashoffset',
dashOffset
);
} );

it( 'renders a plain empty ring when no denominator is measurable', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 0,
costCap: 0,
allowanceRemaining: 50000,
},
} as never );

const { container } = renderControl();

// No fraction to escalate on — no intent color, and the arc stays
// empty; the tooltip still carries the remaining figure.
expect( container.querySelector( 'svg' ) ).not.toHaveAttribute( 'data-intent' );
expect( container.querySelector( 'circle:last-child' ) ).toHaveAttribute(
'stroke-dashoffset',
'100'
);
} );

it( 'shows the summed balance even when both pools are exhausted', async () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: { costUsage: 0, costCap: 0, allowanceRemaining: 0, purchasedRemaining: 0 },
Expand All @@ -116,7 +239,50 @@ describe( 'AiCreditsControl', () => {

await openMenu();

expect( screen.getByText( '0 remaining' ) ).toBeInTheDocument();
expect( screen.getByText( 'No AI credits remaining' ) ).toBeInTheDocument();
} );

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 );
const { container } = renderControl();

await openMenu();

expect( screen.getByText( 'No AI credits remaining' ) ).toBeInTheDocument();
expect( container.querySelector( 'svg' ) ).toHaveAttribute( 'data-intent', 'exhausted' );
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 when the combined balance is empty', 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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { getStudioCodeAiAccessState } from '@studio/common/lib/studio-assistant-quota';
import {
getAiCreditsMeter,
getAiCreditsMeterIntent,
getStudioCodeAiAccessState,
type AiCreditsMeterIntent,
} from '@studio/common/lib/studio-assistant-quota';
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';
Expand All @@ -18,6 +22,46 @@ import { useUserLocale } from '@/data/queries/use-user-locale';
import { useAddAiCreditsUrl } from '@/hooks/use-add-ai-credits-url';
import styles from './style.module.css';

// The ring arc draws the same used-over-total fraction as the Settings →
// Usage meter (`getAiCreditsMeter`, STU-2326), and its color escalates
// through the same intent steps. The meter resolves null when no denominator
// is measurable (e.g. billing unreachable) — there is no fraction to
// escalate on then, so the ring stays plain (or exhausted at zero balance)
// and the tooltip keeps carrying the figure.
function AiCreditsRing( {
fillPercentage,
intent,
}: {
fillPercentage: number;
intent: AiCreditsMeterIntent;
} ) {
return (
<svg
className={ styles.aiCreditsRing }
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
aria-hidden="true"
data-intent={ intent === 'ok' ? undefined : intent }
>
<circle className={ styles.aiCreditsRingTrack } cx="12" cy="12" r="8" strokeWidth="2" />
<circle
className={ styles.aiCreditsRingValue }
cx="12"
cy="12"
r="8"
pathLength="100"
strokeWidth="2"
strokeDasharray="100"
strokeDashoffset={ 100 - fillPercentage }
strokeLinecap="round"
transform="rotate(-90 12 12)"
/>
</svg>
);
}

export function AiCreditsControl() {
const connector = useConnector();
const addAiCreditsUrl = useAddAiCreditsUrl();
Expand Down Expand Up @@ -45,7 +89,35 @@ export function AiCreditsControl() {

const hasTopUpOptions = ( pricing?.options.length ?? 0 ) > 0;
const remaining = ( quota.allowanceRemaining ?? 0 ) + ( quota.purchasedRemaining ?? 0 );
const meter = getAiCreditsMeter( quota );
const intent: AiCreditsMeterIntent = meter
? getAiCreditsMeterIntent( meter.fraction )
: remaining === 0
? 'exhausted'
: 'ok';
const fillPercentage =
intent === 'exhausted' ? 100 : meter ? Math.round( meter.fraction * 100 ) : 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 (
<>
Expand Down Expand Up @@ -73,28 +145,21 @@ export function AiCreditsControl() {
/>
}
>
<Icon icon={ chartBar } size={ 16 } />
<AiCreditsRing fillPercentage={ fillPercentage } intent={ intent } />
</Tooltip.Trigger>
}
/>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="top" /> }>
{ sprintf(
/* translators: %s: total number of AI credits remaining (e.g. 1,110,000). */
__( 'AI credits · %s remaining' ),
formattedRemaining
) }
{ tooltipLabel }
</Tooltip.Popup>
</Tooltip.Root>
<Menu.Popup side="top" align="end">
<div className={ styles.aiCreditsSummary }>
<div
className={ styles.aiCreditsSummary }
data-exhausted={ remaining === 0 || undefined }
>
<span>{ __( 'AI credits' ) }</span>
<strong>
{ sprintf(
/* translators: %s: total number of AI credits remaining (e.g. 1,110,000). */
__( '%s remaining' ),
formattedRemaining
) }
</strong>
<strong>{ remainingLabel }</strong>
</div>
<Menu.Separator />
<Menu.Item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,10 +494,44 @@
.aiCreditsSummary strong {
color: var(--wpds-color-fg-content-neutral);
font-size: var(--wpds-typography-font-size-md);
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: var(--wpds-typography-line-height-sm);
}

.aiCreditsSummary[data-exhausted] strong {
color: var(--wpds-color-fg-content-error);
}

.aiCreditsRingTrack {
stroke: var(--wpds-color-stroke-surface-neutral);
}

.aiCreditsRingValue {
stroke: var(--wpds-color-bg-interactive-brand-strong);
}

/* Same escalation colors as the Settings → Usage meter. */
.aiCreditsRing[data-intent='warning'] .aiCreditsRingValue {
stroke: var(--wpds-color-fg-content-caution-weak);
filter: saturate(2) brightness(1.8);
}

.aiCreditsRing[data-intent='critical'] .aiCreditsRingValue {
stroke: var(--wpds-color-fg-content-warning-weak);
filter: saturate(1.6) brightness(1.35);
}

.aiCreditsRing[data-intent='exhausted'] .aiCreditsRingValue {
stroke: var(--wpds-color-bg-interactive-error-strong);
}

@media not (prefers-reduced-motion) {
.aiCreditsRingValue {
transition: stroke-dashoffset 180ms ease;
}
}

.pill {
display: inline-flex;
flex: 0 0 auto;
Expand Down
Loading