Skip to content
53 changes: 53 additions & 0 deletions apps/ui/src/hooks/use-ai-credits-meter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';
import { useAiCreditsMeter } from './use-ai-credits-meter';

vi.mock( '@/data/queries/use-assistant-quota', () => ( {
useStudioAssistantQuota: vi.fn(),
} ) );

const useQuotaMock = vi.mocked( useStudioAssistantQuota );

function mockQuota( data: unknown ) {
useQuotaMock.mockReturnValue( { data } as never );
}

describe( 'useAiCreditsMeter', () => {
beforeEach( () => vi.clearAllMocks() );

it( 'measures the free allowance against the monthly cap', () => {
mockQuota( { costUsage: 0, costCap: 1000000, allowanceRemaining: 100000 } );

expect( renderHook( () => useAiCreditsMeter() ).result.current ).toMatchObject( {
usedCredits: 900000,
totalCredits: 1000000,
remainingCredits: 100000,
fraction: 0.9,
} );
} );

it( 'is null while the quota is unknown', () => {
mockQuota( undefined );

expect( renderHook( () => useAiCreditsMeter() ).result.current ).toBeNull();
} );

it( 'is null for accounts the server reports no credit pools for', () => {
mockQuota( { costUsage: 25, costCap: 100 } );

expect( renderHook( () => useAiCreditsMeter() ).result.current ).toBeNull();
} );

it( 'is null for an account held by another access gate', () => {
mockQuota( {
costUsage: 0,
costCap: 1000000,
allowanceRemaining: 100000,
studioCodeAiHasAccess: false,
studioCodeAiAccess: 'blocked',
} );

expect( renderHook( () => useAiCreditsMeter() ).result.current ).toBeNull();
} );
} );
21 changes: 21 additions & 0 deletions apps/ui/src/hooks/use-ai-credits-meter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {
getAiCreditsMeter,
getStudioCodeAiAccessState,
type AiCreditsMeter,
} from '@studio/common/lib/studio-assistant-quota';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';

/**
* The combined AI credits meter for the signed-in account, or null when there
* is nothing to measure — no quota yet, an account another gate already holds,
* or a server that reports no denominator. Null means "no figure", never an
* empty balance: the exhausted state has its own signal in
* `useIsOutOfAiCredits`.
*/
export function useAiCreditsMeter(): AiCreditsMeter | null {
const { data: quota } = useStudioAssistantQuota();
if ( ! quota || getStudioCodeAiAccessState( quota ) !== 'available' ) {
return null;
}
return getAiCreditsMeter( quota );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';
import { AiCreditsWarningStrip } from './ai-credits-warning-strip';

vi.mock( '@/data/queries/use-assistant-quota', () => ( {
useStudioAssistantQuota: vi.fn(),
} ) );

vi.mock( '@/data/queries/use-user-locale', () => ( {
useUserLocale: () => 'en',
} ) );

vi.mock( '@/components/add-ai-credits-button', () => ( {
AddAiCreditsButton: () => <button type="button">Add AI credits</button>,
} ) );

const useQuotaMock = vi.mocked( useStudioAssistantQuota );

function mockQuota( data: unknown ) {
useQuotaMock.mockReturnValue( { data } as never );
}

describe( 'AiCreditsWarningStrip', () => {
beforeEach( () => vi.clearAllMocks() );

it( 'interrupts the composer once the balance enters its last tenth', () => {
mockQuota( { costUsage: 0, costCap: 1000000, allowanceRemaining: 100000 } );

render( <AiCreditsWarningStrip /> );

expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'At 90% usage' );
expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument();
} );

it( 'stays out of the way below the threshold', () => {
mockQuota( { costUsage: 0, costCap: 1000000, allowanceRemaining: 500000 } );

const { container } = render( <AiCreditsWarningStrip /> );

expect( container ).toBeEmptyDOMElement();
} );

it( 'yields to the lockout once the balance is spent', () => {
mockQuota( {
costUsage: 0,
costCap: 1000000,
allowanceRemaining: 0,
purchasedRemaining: 0,
} );

const { container } = render( <AiCreditsWarningStrip /> );

expect( container ).toBeEmptyDOMElement();
} );

it( 'renders nothing when there is no denominator to measure against', () => {
mockQuota( { costUsage: 25, costCap: 100 } );

const { container } = render( <AiCreditsWarningStrip /> );

expect( container ).toBeEmptyDOMElement();
} );

it( 'still warns when the meter rounds to full but credits remain', () => {
// 999,999 of 1,000,000 spent: the meter reads exhausted, the server
// balance does not, so neither the lockout nor a silent strip is right.
mockQuota( { costUsage: 0, costCap: 1000000, allowanceRemaining: 1 } );

render( <AiCreditsWarningStrip /> );

expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'At 100% usage' );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getAiCreditsMeterIntent } from '@studio/common/lib/studio-assistant-quota';
import { __, sprintf } from '@wordpress/i18n';
import { AddAiCreditsButton } from '@/components/add-ai-credits-button';
import { useUserLocale } from '@/data/queries/use-user-locale';
import { useAiCreditsMeter } from '@/hooks/use-ai-credits-meter';
import { useIsOutOfAiCredits } from '@/hooks/use-is-out-of-ai-credits';
import styles from './style.module.css';

/**
* Interrupts the composer once the balance enters its last tenth (STU-2337).
* Deliberately silent about being blocked — that is the lockout's job, and the
* two never render together. The 80% step belongs to the sidebar notice
* (STU-2338), so this is the only threshold the composer itself announces.
*/
export function AiCreditsWarningStrip() {
const meter = useAiCreditsMeter();
const isOutOfCredits = useIsOutOfAiCredits();
const locale = useUserLocale();
// 'exhausted' is included on purpose: it covers the sliver where the meter
// rounds up to full while the server balance is still above zero, which
// would otherwise show neither the strip nor the lockout.
const intent = meter ? getAiCreditsMeterIntent( meter.fraction ) : 'ok';
if ( ! meter || isOutOfCredits || ( intent !== 'critical' && intent !== 'exhausted' ) ) {
return null;
}

// The percent sign comes from the formatter, not the source string: its
// position and character vary by locale (90 % in French, %90 in Turkish).
const percentage = new Intl.NumberFormat( locale, {
style: 'percent',
maximumFractionDigits: 0,
} ).format( meter.fraction );

return (
<section className={ styles.aiCreditsWarningStrip } role="status">
<span>
{ sprintf(
/* translators: %s: share of the AI credit balance already used, formatted as a percentage (e.g. 90%). */
__( 'At %s usage' ),
percentage
) }
</span>
<AddAiCreditsButton className={ styles.aiCreditsWarningStripButton } />
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
SESSIONS_QUERY_KEY,
} from '@/data/queries/use-sessions';
import { AiCreditsControl } from './ai-credits-control';
import { AiCreditsWarningStrip } from './ai-credits-warning-strip';
import { FamilySwitchConfirmDialog } from './family-switch-confirm-dialog';
import styles from './style.module.css';
import {
Expand Down Expand Up @@ -775,6 +776,7 @@ export const Composer = forwardRef< ComposerHandle, ComposerProps >( function Co
onDragLeave={ dragHandlers.onDragLeave }
onDrop={ dragHandlers.onDrop }
>
<AiCreditsWarningStrip />
<div
className={ styles.resizeHandle }
role="separator"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
transparent
);
--composer-focus-ring: var(--wpds-color-fg-interactive-brand, #2563eb);
--composer-border-radius: 8px 8px 20px 8px;
--composer-corner-radius: 8px;
--composer-border-radius: var(--composer-corner-radius) var(--composer-corner-radius) 20px
var(--composer-corner-radius);
--composer-block-padding: var(--wpds-dimension-padding-md);
--composer-inline-padding: var(--wpds-dimension-padding-lg);

Expand Down Expand Up @@ -663,3 +665,34 @@
.dialogDescription {
text-wrap: pretty;
}

.aiCreditsWarningStrip {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
gap: var(--wpds-dimension-padding-md);
border-block-end: 1px solid var(--wpds-color-stroke-surface-neutral);
border-start-start-radius: var(--composer-corner-radius);
border-start-end-radius: var(--composer-corner-radius);
background: color-mix(
in srgb,
var(--wpds-color-bg-surface-caution) 38%,
var(--wpds-color-bg-surface-neutral-strong)
);
padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-xs)
var(--wpds-dimension-padding-xs) var(--composer-inline-padding);
color: var(--wpds-color-fg-content-neutral);
font-size: var(--wpds-typography-font-size-xs);
line-height: 1.25;
}

.aiCreditsWarningStripButton {
--wp-ui-button-font-size: 11px;
--wp-ui-button-padding-inline: 6px;

flex-shrink: 0;
min-block-size: 24px;
padding-block: 2px;
white-space: nowrap;
}
Loading
Loading