diff --git a/apps/ui/src/app/index.tsx b/apps/ui/src/app/index.tsx
index a03a589e99..508e96cdd2 100644
--- a/apps/ui/src/app/index.tsx
+++ b/apps/ui/src/app/index.tsx
@@ -1,4 +1,5 @@
import { AppProviders } from '@/app/app-providers';
+import { UsageExplorationControls } from '@/components/usage-exploration-controls';
import { ClassicUiApp } from '@/ui-classic/app';
import '@wordpress/components/build-style/style.css';
import '@wordpress/dataviews/build-style/style.css';
@@ -15,6 +16,7 @@ export function App( { connector }: AppProps ) {
return (
+
);
}
diff --git a/apps/ui/src/components/ai-credits-details-dialog/index.tsx b/apps/ui/src/components/ai-credits-details-dialog/index.tsx
new file mode 100644
index 0000000000..cab434c9a0
--- /dev/null
+++ b/apps/ui/src/components/ai-credits-details-dialog/index.tsx
@@ -0,0 +1,45 @@
+import { __ } from '@wordpress/i18n';
+import { Dialog } from '@wordpress/ui';
+import styles from './style.module.css';
+
+export function AiCreditsDetailsDialog( {
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+} ) {
+ return (
+
+
+
+ { __( 'How AI credits work' ) }
+
+
+
+
+ { __(
+ 'AI credits are Studio’s way of measuring AI-powered work. They are not cash, and they are different from the tokens an AI provider uses to count pieces of text.'
+ ) }
+
+
+ { __(
+ 'Your monthly allowance resets each month, and unused monthly AI credits do not carry over. Purchased AI credits do not expire and are used after your monthly allowance.'
+ ) }
+
+
+ { __(
+ 'Different models use AI credits at different rates. More capable models generally use more credits, and longer or more complex tasks can cost more because they require more model work.'
+ ) }
+
+
+
+
+
+ { __( 'Close' ) }
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/ai-credits-details-dialog/style.module.css b/apps/ui/src/components/ai-credits-details-dialog/style.module.css
new file mode 100644
index 0000000000..ad1382dd4d
--- /dev/null
+++ b/apps/ui/src/components/ai-credits-details-dialog/style.module.css
@@ -0,0 +1,7 @@
+.details {
+ display: flex;
+ flex-direction: column;
+ gap: var( --wpds-dimension-padding-sm );
+ color: var( --wpds-color-fg-content-neutral );
+ line-height: var( --wpds-typography-line-height-sm );
+}
diff --git a/apps/ui/src/components/app-message-cards/index.tsx b/apps/ui/src/components/app-message-cards/index.tsx
index 48ad895b8d..9eaa0885be 100644
--- a/apps/ui/src/components/app-message-cards/index.tsx
+++ b/apps/ui/src/components/app-message-cards/index.tsx
@@ -1,48 +1,79 @@
import { __ } from '@wordpress/i18n';
+import { external, Icon } from '@wordpress/icons';
import { Button, Notice } from '@wordpress/ui';
import { clsx } from 'clsx';
+import { useEffect, useState } from 'react';
import toastStyles from '@/components/app-toasts/style.module.css';
+import { PurchaseCreditsDialog } from '@/components/purchase-credits-dialog';
+import {
+ OPEN_PURCHASE_CREDITS_EVENT,
+ PURCHASE_CREDITS_PROTOTYPE_URL,
+} from '@/components/purchase-credits-dialog/events';
+import { useConnector } from '@/data/core';
import { useActivePersistentMessages } from '@/data/queries/use-app-messages';
+import { useUsageExploration } from '@/data/usage-exploration';
import styles from './style.module.css';
export function AppMessageCards( { className }: { className?: string } ) {
const { messages, dismiss } = useActivePersistentMessages();
+ const [ purchaseOpen, setPurchaseOpen ] = useState( false );
+ const connector = useConnector();
+ const { purchaseCreditsFlow } = useUsageExploration();
+ const opensExternalCheckout = purchaseCreditsFlow === 'external';
- if ( ! messages.length ) {
- return null;
- }
+ useEffect( () => {
+ const openPurchase = () => {
+ if ( opensExternalCheckout ) {
+ void connector.openExternalUrl( PURCHASE_CREDITS_PROTOTYPE_URL );
+ return;
+ }
+ setPurchaseOpen( true );
+ };
+ window.addEventListener( OPEN_PURCHASE_CREDITS_EVENT, openPurchase );
+ return () => window.removeEventListener( OPEN_PURCHASE_CREDITS_EVENT, openPurchase );
+ }, [ connector, opensExternalCheckout ] );
return (
-
- { messages.map( ( message ) => (
-
-
- { message.title }
- { message.description ? (
- { message.description }
- ) : null }
- { message.cta ? (
-
-
- { message.cta.label }
-
-
- ) : null }
- dismiss( message ) } />
-
+ <>
+ { messages.length ? (
+
+ { messages.map( ( message ) => (
+
+
+ { message.title }
+ { message.description ? (
+ { message.description }
+ ) : null }
+ { message.cta ? (
+
+
+ { opensExternalCheckout && message.cta.label === __( 'Add AI credits' )
+ ? __( 'Purchase AI credits' )
+ : message.cta.label }
+ { opensExternalCheckout && message.cta.label === __( 'Add AI credits' ) ? (
+
+ ) : null }
+
+
+ ) : null }
+ dismiss( message ) } />
+
+
+ ) ) }
- ) ) }
-
+ ) : null }
+
+ >
);
}
diff --git a/apps/ui/src/components/purchase-credits-dialog/events.ts b/apps/ui/src/components/purchase-credits-dialog/events.ts
new file mode 100644
index 0000000000..e89a0866d0
--- /dev/null
+++ b/apps/ui/src/components/purchase-credits-dialog/events.ts
@@ -0,0 +1,7 @@
+export const OPEN_PURCHASE_CREDITS_EVENT = 'studio:open-purchase-credits';
+export const PURCHASE_CREDITS_PROTOTYPE_URL =
+ 'https://wordpress.com/checkout/studio-ai-credits?prototype=1';
+
+export function openPurchaseCreditsDialog() {
+ window.dispatchEvent( new Event( OPEN_PURCHASE_CREDITS_EVENT ) );
+}
diff --git a/apps/ui/src/components/purchase-credits-dialog/index.tsx b/apps/ui/src/components/purchase-credits-dialog/index.tsx
new file mode 100644
index 0000000000..83e2044d96
--- /dev/null
+++ b/apps/ui/src/components/purchase-credits-dialog/index.tsx
@@ -0,0 +1,335 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { privateApis } from '@wordpress/theme';
+import { Button, Dialog, Tooltip } from '@wordpress/ui';
+import { useMemo, useState } from 'react';
+import { toast } from '@/data/app-messages';
+import {
+ addExplorationCredits,
+ CREDITS_PER_DOLLAR,
+ creditsFromDollars,
+ dollarsFromCredits,
+ useUsageExploration,
+} from '@/data/usage-exploration';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { unlock } from '@/lock-unlock';
+import styles from './style.module.css';
+import type { CSSProperties } from 'react';
+
+const MIN_CREDIT_AMOUNT = creditsFromDollars( 10 );
+const MAX_CREDIT_AMOUNT = creditsFromDollars( 200 );
+const MAX_TYPED_CREDIT_AMOUNT = creditsFromDollars( 99_999 );
+const DEFAULT_CREDIT_AMOUNT = creditsFromDollars( 50 );
+const CARD_AMOUNTS = [ 10, 20, 50, 100 ].map( creditsFromDollars );
+const PRESET_AMOUNTS = [ 25, 50, 100 ].map( creditsFromDollars );
+const CONFETTI_COLORS = [
+ 'var(--wpds-color-fg-interactive-brand)',
+ 'var(--wpds-color-fg-content-success)',
+ 'var(--wpds-color-fg-content-warning)',
+ 'var(--wpds-color-fg-content-error)',
+];
+const creditAmountFormatter = new Intl.NumberFormat();
+const priceFormatter = new Intl.NumberFormat( undefined, {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+} );
+const { ThemeProvider } = unlock( privateApis );
+function jitter( index: number, salt: number ): number {
+ const value = Math.sin( index * 127.1 + salt * 311.7 ) * 43758.5453;
+ return value - Math.floor( value );
+}
+
+function ConfettiBurst() {
+ const pieces = useMemo(
+ () =>
+ Array.from( { length: 22 }, ( _, index ) => ( {
+ angle: index * ( 360 / 22 ) + ( jitter( index, 1 ) * 24 - 12 ),
+ distance: 52 + jitter( index, 2 ) * 48,
+ spin: jitter( index, 3 ) * 540 - 270,
+ delay: jitter( index, 4 ) * 120,
+ color: CONFETTI_COLORS[ index % CONFETTI_COLORS.length ],
+ width: 4 + jitter( index, 5 ) * 3,
+ height: 6 + jitter( index, 6 ) * 4,
+ } ) ),
+ []
+ );
+
+ return (
+
+ { pieces.map( ( piece, index ) => (
+
+ ) ) }
+
+ );
+}
+
+export function PurchaseCreditsDialog( {
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+} ) {
+ const [ amount, setAmount ] = useState( String( DEFAULT_CREDIT_AMOUNT ) );
+ const [ confettiKey, setConfettiKey ] = useState( 0 );
+ const { purchaseCreditsVariant: variant } = useUsageExploration();
+ const colorScheme = useColorScheme();
+ const dialogBackground = colorScheme === 'dark' ? '#1e1e1e' : '#ffffff';
+ const checkoutAmount = Number( amount );
+ const checkoutPrice = dollarsFromCredits( checkoutAmount );
+ const hasValidAmount =
+ Number.isInteger( checkoutAmount ) &&
+ checkoutAmount >= MIN_CREDIT_AMOUNT &&
+ checkoutAmount <= MAX_TYPED_CREDIT_AMOUNT;
+ const sliderAmount = Math.min(
+ MAX_CREDIT_AMOUNT,
+ Math.max( MIN_CREDIT_AMOUNT, checkoutAmount || DEFAULT_CREDIT_AMOUNT )
+ );
+ const isOffScale = checkoutAmount > MAX_CREDIT_AMOUNT;
+ const formattedAmount = amount ? creditAmountFormatter.format( checkoutAmount ) : '';
+ const formattedCreditCount = creditAmountFormatter.format( checkoutAmount );
+ const formattedCheckoutPrice = priceFormatter.format( checkoutPrice );
+ const hasSelectedPreset = PRESET_AMOUNTS.includes( checkoutAmount );
+ const moveCaretToEnd = ( input: HTMLInputElement ) => {
+ const end = input.value.length;
+ input.setSelectionRange( end, end );
+ };
+ const updateTypedAmount = ( value: string ) => {
+ const digits = value.replace( /\D/g, '' );
+ const normalizedAmount = digits.replace( /^0+(?=\d)/, '' );
+ const nextAmount =
+ normalizedAmount.length > String( MAX_TYPED_CREDIT_AMOUNT ).length
+ ? String( MAX_TYPED_CREDIT_AMOUNT )
+ : normalizedAmount;
+
+ if (
+ variant === 'slider' &&
+ Number( nextAmount ) > MAX_CREDIT_AMOUNT &&
+ nextAmount !== amount
+ ) {
+ setConfettiKey( ( key ) => key + 1 );
+ }
+
+ setAmount( nextAmount );
+ };
+
+ const continueToCheckout = () => {
+ if ( ! hasValidAmount ) {
+ return;
+ }
+
+ addExplorationCredits( checkoutPrice );
+ onOpenChange( false );
+ toast.success(
+ sprintf(
+ /* translators: %s: number of AI credits purchased. */
+ __( '%s AI credits added' ),
+ formattedCreditCount
+ )
+ );
+ };
+
+ return (
+
+
+
+
+ { __( 'Add AI credits' ) }
+
+
+
+ { __(
+ 'Choose a one-time AI credit amount to check out securely on WordPress.com. Purchased AI credits do not expire and are used after your monthly allowance.'
+ ) }
+
+ { variant === 'cards' && (
+
+ { CARD_AMOUNTS.map( ( option ) => (
+ setAmount( String( option ) ) }
+ >
+
+ { sprintf(
+ /* translators: %s: number of AI credits. */
+ __( '%s AI credits' ),
+ creditAmountFormatter.format( option )
+ ) }
+
+
+ { sprintf(
+ /* translators: %s: one-time price for AI credits. */
+ __( '%s one time' ),
+ priceFormatter.format( dollarsFromCredits( option ) )
+ ) }
+
+
+ ) ) }
+
+ ) }
+ { variant === 'presets' && (
+
+
+ { PRESET_AMOUNTS.map( ( option ) => (
+ setAmount( String( option ) ) }
+ >
+
+ { sprintf(
+ /* translators: %s: number of AI credits. */
+ __( '%s AI credits' ),
+ creditAmountFormatter.format( option )
+ ) }
+
+
+ { sprintf(
+ /* translators: %s: one-time price for AI credits. */
+ __( '%s one time' ),
+ priceFormatter.format( dollarsFromCredits( option ) )
+ ) }
+
+
+ ) ) }
+
+
+ { __( 'Custom AI credits' ) }
+
+ updateTypedAmount( event.target.value ) }
+ onFocus={ ( event ) => moveCaretToEnd( event.currentTarget ) }
+ onMouseUp={ ( event ) => {
+ event.preventDefault();
+ moveCaretToEnd( event.currentTarget );
+ } }
+ />
+
+
+ { ! hasSelectedPreset && hasValidAmount
+ ? formattedCheckoutPrice
+ : __( 'Price' ) }
+
+ { __( 'one time' ) }
+
+
+
+
+ ) }
+ { variant === 'slider' && (
+
+
+ { __( 'AI credit amount' ) }
+
+
+ { confettiKey > 0 && }
+ updateTypedAmount( event.target.value ) }
+ onFocus={ ( event ) => moveCaretToEnd( event.currentTarget ) }
+ onMouseUp={ ( event ) => {
+ event.preventDefault();
+ moveCaretToEnd( event.currentTarget );
+ } }
+ />
+
+
+ { hasValidAmount
+ ? formattedCheckoutPrice
+ : sprintf(
+ /* translators: %s: minimum number of AI credits. */
+ __( '%s or more AI credits' ),
+ creditAmountFormatter.format( MIN_CREDIT_AMOUNT )
+ ) }
+
+ { __( 'one time' ) }
+
+
+
setAmount( event.target.value ) }
+ aria-label={ __( 'AI credit amount slider' ) }
+ data-overflow={ isOffScale ? '' : undefined }
+ />
+
+ { creditAmountFormatter.format( MIN_CREDIT_AMOUNT ) }
+
+ { isOffScale
+ ? __( 'Off the chart →' )
+ : creditAmountFormatter.format( MAX_CREDIT_AMOUNT ) }
+
+
+
+ ) }
+
+
+
+ { __( 'Cancel' ) }
+
+
+
+ }
+ >
+ { hasValidAmount
+ ? sprintf(
+ /* translators: %s: price for the selected AI credits. */
+ __( 'Continue for %s' ),
+ formattedCheckoutPrice
+ )
+ : __( 'Continue' ) }
+
+ }>
+ { __( 'Checkout on WordPress.com' ) }
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/purchase-credits-dialog/style.module.css b/apps/ui/src/components/purchase-credits-dialog/style.module.css
new file mode 100644
index 0000000000..648701af6c
--- /dev/null
+++ b/apps/ui/src/components/purchase-credits-dialog/style.module.css
@@ -0,0 +1,217 @@
+.amountCards {
+ display: grid;
+ gap: var( --wpds-dimension-gap-sm );
+ margin-block-start: var( --wpds-dimension-padding-lg );
+}
+
+.amountCards[data-layout='grid'] {
+ grid-template-columns: repeat( 2, minmax( 0, 1fr ) );
+}
+
+.amountCards[data-layout='row'] {
+ grid-template-columns: repeat( 3, minmax( 0, 1fr ) );
+}
+
+.amountOption {
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ gap: var( --wpds-dimension-gap-xs );
+ padding: var( --wpds-dimension-padding-md );
+ border: 1px solid var( --wpds-color-stroke-surface-neutral );
+ border-radius: 8px;
+ background: var( --wpds-color-bg-surface-neutral-weak );
+ color: var( --wpds-color-fg-content-neutral );
+ cursor: pointer;
+ text-align: start;
+}
+
+.amountOption:hover {
+ border-color: var( --wpds-color-stroke-interactive-neutral );
+}
+
+.amountOption[data-selected] {
+ border-color: transparent;
+ outline: 2px solid var( --wpds-color-stroke-interactive-brand );
+ outline-offset: -1px;
+}
+
+.amountOption:focus-visible {
+ outline: 2px solid var( --wpds-color-stroke-focus-brand );
+ outline-offset: -1px;
+}
+
+.optionValue {
+ font-size: var( --wpds-typography-font-size-lg );
+ font-weight: 600;
+}
+
+.amountCards[data-layout='row'] .optionValue {
+ font-size: var( --wpds-typography-font-size-sm );
+}
+
+.optionCreditCount,
+.optionFrequency {
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+}
+
+.optionFrequency {
+ font-size: 11px;
+}
+
+.presetPicker,
+.amountPicker {
+ display: flex;
+ flex-direction: column;
+}
+
+.amountPicker {
+ margin-block-start: var( --wpds-dimension-padding-lg );
+}
+
+.amountPicker .amountControl {
+ position: relative;
+}
+
+.amountPicker .amountDetails {
+ align-items: flex-end;
+ text-align: end;
+}
+
+.customAmount {
+ display: flex;
+ flex-direction: column;
+ margin-block-start: var( --wpds-dimension-padding-md );
+}
+
+.amountInputLabel {
+ margin-block-end: var( --wpds-dimension-gap-xs );
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-sm );
+ font-weight: 500;
+}
+
+.amountDetails {
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ line-height: 1.25;
+}
+
+.amountLabel,
+.amountFrequency {
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+}
+
+.amountFrequency {
+ font-size: 11px;
+}
+
+.amountControl {
+ display: flex;
+ align-items: center;
+ gap: var( --wpds-dimension-gap-sm );
+ box-sizing: border-box;
+ min-block-size: 64px;
+ padding: var( --wpds-dimension-padding-sm ) var( --wpds-dimension-padding-md );
+ border: 1px solid var( --wpds-color-stroke-surface-neutral );
+ border-radius: 8px;
+ background: var( --wpds-color-bg-surface-neutral-weak );
+}
+
+.amountControl:focus-within {
+ border-color: transparent;
+ outline: 2px solid var( --wpds-color-stroke-interactive-brand );
+ outline-offset: -1px;
+}
+
+.customAmountPrefix {
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-lg );
+ font-weight: 600;
+}
+
+.amountInput,
+.customAmountInput {
+ min-inline-size: 0;
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-xl );
+ font-weight: 600;
+}
+
+.customAmountInput::placeholder {
+ color: var( --wpds-color-fg-content-neutral-weak );
+}
+
+.amountRange {
+ inline-size: 100%;
+ margin: var( --wpds-dimension-padding-lg ) 0 0;
+ accent-color: var( --wpds-color-bg-interactive-brand-strong );
+ cursor: pointer;
+}
+
+.amountRange[data-overflow] {
+ filter: drop-shadow( 3px 0 0 var( --wpds-color-fg-interactive-brand ) );
+}
+
+.amountRange:focus-visible {
+ outline: 2px solid var( --wpds-color-stroke-focus-brand );
+ outline-offset: 3px;
+}
+
+.rangeLabels {
+ display: flex;
+ justify-content: space-between;
+ margin-block-start: var( --wpds-dimension-gap-xs );
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+}
+
+.rangeLabels[data-overflow] span:last-child {
+ color: var( --wpds-color-fg-interactive-brand );
+ font-weight: 600;
+}
+
+.confetti {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.confettiPiece {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ border-radius: 1px;
+ opacity: 0;
+ animation: confetti-burst 900ms cubic-bezier( 0.16, 1, 0.3, 1 ) forwards;
+}
+
+@keyframes confetti-burst {
+ 0% {
+ opacity: 1;
+ transform: rotate( var( --confetti-angle ) ) translateX( 8px ) rotate( 0deg );
+ }
+
+ 100% {
+ opacity: 0;
+ transform: rotate( var( --confetti-angle ) ) translateX( var( --confetti-distance ) )
+ rotate( var( --confetti-spin ) );
+ }
+}
+
+@media ( prefers-reduced-motion: reduce ) {
+ .confettiPiece {
+ animation: none;
+ }
+}
+
+.dialogPopup {
+ overflow: visible;
+}
diff --git a/apps/ui/src/components/settings-view/style.module.css b/apps/ui/src/components/settings-view/style.module.css
index 7344356b45..e060f13878 100644
--- a/apps/ui/src/components/settings-view/style.module.css
+++ b/apps/ui/src/components/settings-view/style.module.css
@@ -868,9 +868,138 @@
.progressValue {
block-size: 100%;
border-radius: inherit;
- background: var( --wpds-color-fg-interactive-brand );
+ background: var( --wpds-color-fg-content-neutral );
}
+.progressValueWarning {
+ background: var( --wpds-color-fg-content-caution-weak );
+ filter: saturate( 2 ) brightness( 1.8 );
+}
+
+.progressValueCritical {
+ background: var( --wpds-color-fg-content-warning-weak );
+ filter: saturate( 1.6 ) brightness( 1.35 );
+}
+
+.progressValueExhausted {
+ background: var( --wpds-color-bg-interactive-error-strong );
+}
+
+.creditMeter {
+ display: flex;
+ flex-direction: column;
+ gap: var( --wpds-dimension-padding-xs );
+}
+
+.creditMeterHeader {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var( --wpds-dimension-padding-md );
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-sm );
+ font-weight: 500;
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.creditMeterValue {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.creditMeterCredits {
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+ font-variant-numeric: tabular-nums;
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.extraCreditRow {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var( --wpds-dimension-padding-md );
+}
+
+.extraCreditRow .creditMeter,
+.extraCreditRow .creditTopUpText {
+ inline-size: 100%;
+}
+
+.extraCreditSummary {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.extraCreditSummary strong {
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-xs );
+ font-weight: 500;
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.extraCreditSummary span {
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+ font-variant-numeric: tabular-nums;
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.extraCreditBalanceLine {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: var( --wpds-dimension-gap-xs );
+}
+
+.extraCreditBalanceLine > strong {
+ font-size: var( --wpds-typography-font-size-sm );
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.creditTopUpText {
+ min-width: 0;
+}
+
+.creditTopUpText strong {
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-xs );
+ font-weight: 500;
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.creditTopUpText p {
+ margin-block-start: 2px;
+ font-size: var( --wpds-typography-font-size-xs );
+}
+
+.creditTopUpButton {
+ align-self: flex-start;
+}
+
+.creditTopUpAction {
+ display: flex;
+ align-items: center;
+ align-self: flex-start;
+ gap: var( --wpds-dimension-gap-sm );
+}
+
+.creditTopUpAction > span {
+ color: var( --wpds-color-fg-content-neutral-weak );
+ font-size: var( --wpds-typography-font-size-xs );
+ line-height: var( --wpds-typography-line-height-sm );
+}
+
+.aiCreditsDetailsButton {
+ flex: none;
+ padding-inline: 0;
+ font-weight: 400;
+}
+
+
.previewActionsButton {
flex-shrink: 0;
}
diff --git a/apps/ui/src/components/settings-view/usage-panel.test.tsx b/apps/ui/src/components/settings-view/usage-panel.test.tsx
index ccc61abad1..f2c47b9e18 100644
--- a/apps/ui/src/components/settings-view/usage-panel.test.tsx
+++ b/apps/ui/src/components/settings-view/usage-panel.test.tsx
@@ -10,6 +10,7 @@ import {
useSnapshots,
} from '@/data/queries/use-snapshots';
import { useUserLocale } from '@/data/queries/use-user-locale';
+import { setUsageExplorationScenario } from '@/data/usage-exploration';
import { useOffline } from '@/hooks/use-offline';
import { UsagePanel } from './usage-panel';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
@@ -69,6 +70,10 @@ vi.mock( '@/data/queries/use-auth-user', () => ( {
useLogin: vi.fn(),
} ) );
+vi.mock( '@/data/queries/use-assistant-quota', () => ( {
+ useStudioAssistantQuota: vi.fn(),
+} ) );
+
vi.mock( '@/data/queries/use-snapshots', () => ( {
useDeleteAllSnapshots: vi.fn(),
useSnapshotUsage: vi.fn(),
@@ -79,14 +84,25 @@ vi.mock( '@/hooks/use-offline', () => ( {
useOffline: vi.fn(),
} ) );
-vi.mock( '@/data/queries/use-assistant-quota', () => ( {
- useStudioAssistantQuota: vi.fn(),
-} ) );
-
vi.mock( '@/data/queries/use-user-locale', () => ( {
useUserLocale: vi.fn(),
} ) );
+vi.mock( '@/components/purchase-credits-dialog', () => ( {
+ PurchaseCreditsDialog: () => null,
+} ) );
+
+vi.mock( '@/components/ai-credits-details-dialog', () => ( {
+ AiCreditsDetailsDialog: ( { open }: { open: boolean } ) =>
+ open ? (
+
+ Purchased AI credits do not expire and are used after your monthly allowance. Different
+ models use AI credits at different rates. AI credits measure Studio usage; they are
+ different from the tokens an AI provider uses to count pieces of text.
+
+ ) : null,
+} ) );
+
// Reached through `useAgenticFeatures`, which reads the agentic-features
// preference; this panel has no QueryClientProvider.
vi.mock( '@/data/queries/use-user-preferences', () => ( {
@@ -95,13 +111,13 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( {
} ) );
const useConnectorMock = vi.mocked( useConnector );
+const useStudioAssistantQuotaMock = vi.mocked( useStudioAssistantQuota, { partial: true } );
const useAuthUserMock = vi.mocked( useAuthUser );
const useLoginMock = vi.mocked( useLogin );
const useDeleteAllSnapshotsMock = vi.mocked( useDeleteAllSnapshots );
const useSnapshotUsageMock = vi.mocked( useSnapshotUsage );
const useSnapshotsMock = vi.mocked( useSnapshots );
const useOfflineMock = vi.mocked( useOffline );
-const useStudioAssistantQuotaMock = vi.mocked( useStudioAssistantQuota );
const useUserLocaleMock = vi.mocked( useUserLocale );
describe( 'UsagePanel', () => {
@@ -111,6 +127,7 @@ describe( 'UsagePanel', () => {
beforeEach( () => {
vi.clearAllMocks();
+ setUsageExplorationScenario( 'warning' );
confirmDeleteAllPreviewSites.mockResolvedValue( true );
// `agenticRequiresAuth` lets the real useAgenticFeatures derive the
@@ -120,10 +137,6 @@ describe( 'UsagePanel', () => {
agenticRequiresAuth: true,
} as never );
useOfflineMock.mockReturnValue( false );
- useStudioAssistantQuotaMock.mockReturnValue( {
- data: undefined,
- isLoading: false,
- } as never );
useUserLocaleMock.mockReturnValue( 'en' );
useAuthUserMock.mockReturnValue( {
data: { id: 1, displayName: 'Ada Lovelace', email: 'ada@example.com' },
@@ -140,73 +153,92 @@ describe( 'UsagePanel', () => {
isPending: false,
error: null,
} as never );
+ useStudioAssistantQuotaMock.mockReturnValue( {
+ data: {
+ costUsage: 0,
+ costCap: 100,
+ costResetDate: '2026-08-01T12:00:00',
+ studioCodeAiHasAccess: true,
+ studioCodeAiAccess: 'granted',
+ },
+ isLoading: false,
+ } as never );
} );
it( 'renders AI credits and preview site usage for the signed-in user', () => {
render(
);
expect( screen.getByRole( 'heading', { name: 'Usage' } ) ).toBeInTheDocument();
- expect(
- screen.getByText(
- 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
- )
- ).toBeInTheDocument();
+ expect( screen.getByText( '100,000 available' ) ).toBeInTheDocument();
+ expect( screen.getByText( '400,000 of 500,000 AI credits used' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument();
expect( screen.getByText( '2 of 10 active preview sites' ) ).toBeInTheDocument();
expect( useSnapshotsMock ).toHaveBeenCalledWith( 1 );
expect( useSnapshotUsageMock ).toHaveBeenCalledWith( 1 );
expect( useDeleteAllSnapshotsMock ).toHaveBeenCalledWith( 1 );
} );
- it( 'renders AI usage when a quota with a cost cap is available', () => {
- useStudioAssistantQuotaMock.mockReturnValue( {
- data: { costUsage: 25, costCap: 100, costResetDate: '2026-08-01T12:00:00' },
- isLoading: false,
- } as never );
-
+ it( 'renders the exhausted exploration state', () => {
+ setUsageExplorationScenario( 'exhausted' );
render(
);
+ expect( screen.getByText( '0 available' ) ).toBeInTheDocument();
+ expect( screen.getByText( '500,000 of 500,000 AI credits used' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Keep chatting with AI credits' ) ).toBeInTheDocument();
expect(
- screen.getByText( '25% of monthly limit used (resets on August 1, 2026)' )
- ).toBeInTheDocument();
- expect(
- screen.queryByText(
- 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
+ screen.getByText(
+ 'Keep your work moving without waiting for your monthly allowance to reset.'
)
- ).not.toBeInTheDocument();
+ ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument();
} );
- it( 'shows an unavailable message when the quota fetch fails', () => {
- useStudioAssistantQuotaMock.mockReturnValue( {
- data: undefined,
- isLoading: false,
- isError: true,
- } as never );
+ it( 'shows purchased credits as a balance without an activity ledger', () => {
+ setUsageExplorationScenario( 'extra-healthy' );
render(
);
- expect(
- screen.getByText( 'Studio Code limits are temporarily unavailable.' )
- ).toBeInTheDocument();
- expect(
- screen.queryByText(
- 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
- )
- ).not.toBeInTheDocument();
+ expect( screen.getByText( '320,000 available' ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'Recent activity' ) ).not.toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument();
+ expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 );
} );
- it( 'falls back to the Alpha copy when the quota has no cost cap', () => {
- useStudioAssistantQuotaMock.mockReturnValue( {
- data: { costUsage: 0, costCap: 0 },
- isLoading: false,
- } as never );
+ it( 'explains how monthly and purchased AI credits work', () => {
+ render(
);
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'How AI credits work' } ) );
+ expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent(
+ 'Purchased AI credits do not expire and are used after your monthly allowance.'
+ );
+ expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent(
+ 'Different models use AI credits at different rates.'
+ );
+ expect( screen.getByRole( 'dialog', { name: 'How AI credits work' } ) ).toHaveTextContent(
+ 'they are different from the tokens an AI provider uses to count pieces of text.'
+ );
+ } );
+
+ it( 'offers prototype states for extra-credit usage', () => {
+ setUsageExplorationScenario( 'extra-exhausted' );
render(
);
- expect(
- screen.getByText(
- 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
- )
- ).toBeInTheDocument();
+ expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument();
+ expect( screen.getAllByText( '0 available' ) ).toHaveLength( 2 );
+ expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 );
+ } );
+
+ it( 'shows extra credits held in reserve before the monthly allowance is used', () => {
+ setUsageExplorationScenario( 'extra-reserve' );
+ render(
);
+
+ expect( screen.getByText( '320,000 available' ) ).toBeInTheDocument();
+ expect( screen.getByText( '180,000 of 500,000 AI credits used' ) ).toBeInTheDocument();
+ expect( screen.getByText( '500,000 available' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Purchased AI credits' ) ).toBeInTheDocument();
+ expect( screen.getAllByTestId( 'usage-progress-bar' ) ).toHaveLength( 3 );
} );
it( 'shows the suspension copy for an explicitly blocked account', () => {
@@ -279,7 +311,7 @@ describe( 'UsagePanel', () => {
).toBeInTheDocument();
} );
- it( 'shows normal usage when access is granted through a default-allow policy', () => {
+ it( 'shows the exploration usage when access is granted through a default-allow policy', () => {
useStudioAssistantQuotaMock.mockReturnValue( {
data: {
costUsage: 25,
@@ -293,9 +325,8 @@ describe( 'UsagePanel', () => {
render(
);
- expect(
- screen.getByText( '25% of monthly limit used (resets on August 1, 2026)' )
- ).toBeInTheDocument();
+ expect( screen.getByText( '100,000 available' ) ).toBeInTheDocument();
+ expect( screen.getByText( '400,000 of 500,000 AI credits used' ) ).toBeInTheDocument();
} );
it( 'confirms through the connector before deleting all preview sites', async () => {
@@ -318,8 +349,7 @@ describe( 'UsagePanel', () => {
expect( deleteSnapshotsMutate ).not.toHaveBeenCalled();
} );
- it( 'shows a loading row with an empty progress bar in both sections', () => {
- useStudioAssistantQuotaMock.mockReturnValue( { data: undefined, isLoading: true } as never );
+ it( 'keeps AI usage visible while preview usage loads', () => {
// Preview usage is still cached from before the delete, so the bar would
// otherwise keep its old fill next to a "Loading…" row.
useDeleteAllSnapshotsMock.mockReturnValue( {
@@ -330,21 +360,15 @@ describe( 'UsagePanel', () => {
render(
);
- expect( screen.getAllByText( 'Loading…' ) ).toHaveLength( 2 );
+ expect( screen.getAllByText( 'Loading…' ) ).toHaveLength( 1 );
const bars = screen.getAllByTestId( 'usage-progress-bar' );
expect( bars ).toHaveLength( 2 );
- for ( const bar of bars ) {
- expect( bar.firstElementChild ).toHaveStyle( { inlineSize: '0%' } );
- }
+ expect( bars[ 0 ].firstElementChild ).toHaveStyle( { inlineSize: '80%' } );
+ expect( bars[ 1 ].firstElementChild ).toHaveStyle( { inlineSize: '0%' } );
} );
it( 'replaces figures and actions with the offline notice while offline', () => {
useOfflineMock.mockReturnValue( true );
- useStudioAssistantQuotaMock.mockReturnValue( {
- data: { costUsage: 25, costCap: 100, costResetDate: '2026-08-01T12:00:00' },
- isLoading: false,
- } as never );
-
render(
);
expect( screen.getByRole( 'status' ) ).toHaveTextContent( "You're offline" );
diff --git a/apps/ui/src/components/settings-view/usage-panel.tsx b/apps/ui/src/components/settings-view/usage-panel.tsx
index beddb59ab3..f181c7d479 100644
--- a/apps/ui/src/components/settings-view/usage-panel.tsx
+++ b/apps/ui/src/components/settings-view/usage-panel.tsx
@@ -1,17 +1,19 @@
import {
clampQuotaFraction,
- formatQuotaPercentage,
- formatQuotaResetDate,
getStudioCodeAiAccessState,
} from '@studio/common/lib/studio-assistant-quota';
import { __, _n, sprintf } from '@wordpress/i18n';
-import { moreHorizontal } from '@wordpress/icons';
-import { IconButton } from '@wordpress/ui';
+import { external, Icon, moreHorizontal } from '@wordpress/icons';
+import { Button, IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
+import { useState } from 'react';
import { SigninNotice } from '@/components/agentic-signin-banner';
import { AiAccessRequiredNotice, AiBlockedNotice } from '@/components/ai-access-required-notice';
+import { AiCreditsDetailsDialog } from '@/components/ai-credits-details-dialog';
import * as Menu from '@/components/menu';
import { OfflineNotice } from '@/components/offline-banner';
+import { PurchaseCreditsDialog } from '@/components/purchase-credits-dialog';
+import { PURCHASE_CREDITS_PROTOTYPE_URL } from '@/components/purchase-credits-dialog/events';
import { useConnector } from '@/data/core';
import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';
@@ -22,6 +24,7 @@ import {
useSnapshots,
} from '@/data/queries/use-snapshots';
import { useUserLocale } from '@/data/queries/use-user-locale';
+import { creditsFromDollars, useUsageExploration } from '@/data/usage-exploration';
import styles from './style.module.css';
const DEFAULT_PREVIEW_SITE_LIMIT = 10;
@@ -39,79 +42,180 @@ function UnavailableSection( { title }: { title: string } ) {
);
}
-function UsageProgressBar( { fraction }: { fraction: number } ) {
+function UsageProgressBar( {
+ fraction,
+ valueClassName,
+}: {
+ fraction: number;
+ valueClassName?: string;
+} ) {
return (
);
}
-function AiCreditsSummary() {
+function getMeterIntent( fraction: number ): string | undefined {
+ if ( fraction >= 1 ) {
+ return styles.progressValueExhausted;
+ }
+ if ( fraction >= 0.9 ) {
+ return styles.progressValueCritical;
+ }
+ if ( fraction >= 0.8 ) {
+ return styles.progressValueWarning;
+ }
+ return undefined;
+}
+
+function CreditMeter( {
+ label,
+ remainingDollars,
+ usedDollars,
+ totalDollars,
+ fraction,
+ valueClassName,
+}: {
+ label: string;
+ remainingDollars: number;
+ usedDollars: number;
+ totalDollars: number;
+ fraction: number;
+ valueClassName?: string;
+} ) {
const locale = useUserLocale();
- const { data: quota, isLoading, isError } = useStudioAssistantQuota();
- const accessState = quota ? getStudioCodeAiAccessState( quota ) : 'available';
+ const credits = new Intl.NumberFormat( locale, { maximumFractionDigits: 0 } );
+ const remainingCredits = creditsFromDollars( remainingDollars );
- let content;
- if ( isLoading ) {
- content = (
- <>
-
{ __( 'Loading…' ) }
-
- >
- );
- } else if ( isError ) {
- content = (
-
- { __( 'Studio Code limits are temporarily unavailable.' ) }
-
- );
- } else if ( accessState !== 'available' ) {
- content = (
-
- { accessState === 'blocked' ? (
-
- ) : (
-
- ) }
-
- );
- } else if ( quota && quota.costCap > 0 ) {
- const fraction = clampQuotaFraction( quota.costUsage, quota.costCap );
- content = (
- <>
-
+ return (
+
+
+ { label }
+
{ sprintf(
- /* translators: %1$s: percentage of monthly limit used (e.g. 7.5%). %2$s: date the limit resets (e.g. July 1, 2026). */
- __( '%1$s of monthly limit used (resets on %2$s)' ),
- formatQuotaPercentage( fraction, locale ),
- formatQuotaResetDate( quota.costResetDate, locale )
- ) }
-
-
- >
- );
- } else {
- content = (
- <>
-
- { __(
- 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
+ /* translators: %s: number of AI credits still available. */
+ __( '%s available' ),
+ credits.format( remainingCredits )
) }
-
-
- >
- );
- }
+
+
+
+
+ { sprintf(
+ /* translators: 1: AI credits used, 2: total AI credits available. */
+ __( '%1$s of %2$s AI credits used' ),
+ credits.format( creditsFromDollars( usedDollars ) ),
+ credits.format( creditsFromDollars( totalDollars ) )
+ ) }
+
+
+ );
+}
+
+function AiCreditsSummary() {
+ const usage = useUsageExploration();
+ const connector = useConnector();
+ const [ purchaseOpen, setPurchaseOpen ] = useState( false );
+ const [ detailsOpen, setDetailsOpen ] = useState( false );
+ const { data: quota } = useStudioAssistantQuota();
+ const accessState = quota ? getStudioCodeAiAccessState( quota ) : 'available';
+ const monthlyRemaining = Math.max( 0, usage.monthlyLimit - usage.monthlyUsed );
+ const monthlyMeterIntent =
+ usage.purchasedTotal > 0 ? undefined : getMeterIntent( usage.monthlyFraction );
+ const purchasedUsed = Math.max( 0, usage.purchasedTotal - usage.purchasedBalance );
+ const opensExternalCheckout = usage.purchaseCreditsFlow === 'external';
+ const openPurchaseCredits = () => {
+ if ( opensExternalCheckout ) {
+ void connector.openExternalUrl( PURCHASE_CREDITS_PROTOTYPE_URL );
+ return;
+ }
+ setPurchaseOpen( true );
+ };
return (
{ __( 'AI credits' ) }
+ setDetailsOpen( true ) }
+ >
+ { __( 'How AI credits work' ) }
+
- { content }
+ { accessState !== 'available' ? (
+
+ { accessState === 'blocked' ? (
+
+ ) : (
+
+ ) }
+
+ ) : (
+ <>
+
+
+ { usage.purchasedTotal > 0 ? (
+
+ ) : (
+
+
+ { usage.isExhausted
+ ? __( 'Keep chatting with AI credits' )
+ : __( 'Purchased AI credits' ) }
+
+
+ { usage.isExhausted
+ ? __(
+ 'Keep your work moving without waiting for your monthly allowance to reset.'
+ )
+ : __(
+ 'Keep AI credits ready so your work can continue after your monthly allowance runs out.'
+ ) }
+
+
+ ) }
+
+
+ { opensExternalCheckout ? __( 'Purchase AI credits' ) : __( 'Add AI credits' ) }
+ { opensExternalCheckout ? (
+
+ ) : null }
+
+ { opensExternalCheckout ? { __( 'Checkout on WordPress.com' ) } : null }
+
+
+
+ >
+ ) }
+
);
}
diff --git a/apps/ui/src/components/usage-exploration-controls/index.tsx b/apps/ui/src/components/usage-exploration-controls/index.tsx
new file mode 100644
index 0000000000..db8193e1fa
--- /dev/null
+++ b/apps/ui/src/components/usage-exploration-controls/index.tsx
@@ -0,0 +1,440 @@
+import { __ } from '@wordpress/i18n';
+import { closeSmall } from '@wordpress/icons';
+import { IconButton } from '@wordpress/ui';
+import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
+import {
+ addExplorationCredits,
+ dollarsFromCredits,
+ setUsageExplorationMeterStyle,
+ setUsageExplorationPurchaseCreditsVariant,
+ setUsageExplorationPurchaseCreditsFlow,
+ setUsageExplorationMeterIconSize,
+ setUsageExplorationRingSize,
+ setUsageExplorationRingStrokeWidth,
+ setUsageExplorationScenario,
+ setUsageExplorationSignalAlignment,
+ setUsageExplorationSignalBarCount,
+ setUsageExplorationSignalBarThickness,
+ setUsageExplorationSignalOrientation,
+ setUsageExplorationSignalStackDirection,
+ spendExplorationPurchasedCredits,
+ useUsageExploration,
+ type UsageSignalAlignment,
+ type UsageSignalOrientation,
+ type UsageSignalStackDirection,
+ type UsageMeterStyle,
+ type PurchaseCreditsVariant,
+ type PurchaseCreditsFlow,
+ type UsageExplorationScenario,
+} from '@/data/usage-exploration';
+import styles from './style.module.css';
+
+const MONTHLY_SCENARIOS: Array< { value: UsageExplorationScenario; label: string } > = [
+ { value: 'fresh', label: '0%' },
+ { value: 'healthy', label: '36%' },
+ { value: 'warning', label: '80%' },
+ { value: 'critical', label: '90%' },
+ { value: 'exhausted', label: '100%' },
+];
+
+const PURCHASED_SCENARIOS: Array< { value: UsageExplorationScenario; label: string } > = [
+ { value: 'extra-reserve', label: __( '500K reserve' ) },
+ { value: 'extra-full', label: __( '500K available' ) },
+ { value: 'extra-healthy', label: __( '320K' ) },
+ { value: 'extra-warning', label: __( '100K' ) },
+ { value: 'extra-critical', label: __( '50K' ) },
+ { value: 'extra-exhausted', label: '0' },
+];
+
+function ScenarioRow( {
+ label,
+ options,
+ selected,
+}: {
+ label: string;
+ options: Array< { value: UsageExplorationScenario; label: string } >;
+ selected: UsageExplorationScenario;
+} ) {
+ return (
+
+
{ label }
+
+ { options.map( ( option ) => (
+ setUsageExplorationScenario( option.value ) }
+ >
+ { option.label }
+
+ ) ) }
+
+
+ );
+}
+
+export function UsageExplorationControls() {
+ const {
+ scenario,
+ meterStyle,
+ purchaseCreditsVariant,
+ purchaseCreditsFlow,
+ signalOrientation,
+ signalAlignment,
+ signalBarCount,
+ signalBarThickness,
+ signalStackDirection,
+ meterIconSize,
+ ringSize,
+ ringStrokeWidth,
+ } = useUsageExploration();
+ const [ visible, setVisible ] = useState( true );
+ const [ creditAdjustment, setCreditAdjustment ] = useState( 100_000 );
+ const [ position, setPosition ] = useState< { x: number; y: number } | null >( null );
+ const panelRef = useRef< HTMLElement | null >( null );
+ const dragRef = useRef< {
+ pointerId: number;
+ startPointerX: number;
+ startPointerY: number;
+ startPanelX: number;
+ startPanelY: number;
+ } | null >( null );
+
+ useEffect( () => {
+ const toggleControls = ( event: KeyboardEvent ) => {
+ if (
+ ( event.metaKey || event.ctrlKey ) &&
+ event.shiftKey &&
+ event.key.toLowerCase() === 'u'
+ ) {
+ event.preventDefault();
+ setVisible( ( current ) => ! current );
+ }
+ };
+ window.addEventListener( 'keydown', toggleControls );
+ return () => window.removeEventListener( 'keydown', toggleControls );
+ }, [] );
+
+ if ( ! visible ) {
+ return null;
+ }
+
+ const handlePointerDown = ( event: PointerEvent< HTMLDivElement > ) => {
+ if ( event.button !== 0 || ( event.target as Element ).closest( 'button' ) ) {
+ return;
+ }
+ const panel = panelRef.current;
+ if ( ! panel ) {
+ return;
+ }
+ const rect = panel.getBoundingClientRect();
+ dragRef.current = {
+ pointerId: event.pointerId,
+ startPointerX: event.clientX,
+ startPointerY: event.clientY,
+ startPanelX: rect.left,
+ startPanelY: rect.top,
+ };
+ event.currentTarget.setPointerCapture( event.pointerId );
+ };
+
+ const handlePointerMove = ( event: PointerEvent< HTMLDivElement > ) => {
+ const drag = dragRef.current;
+ const panel = panelRef.current;
+ if ( ! drag || drag.pointerId !== event.pointerId || ! panel ) {
+ return;
+ }
+ const maxX = Math.max( 0, window.innerWidth - panel.offsetWidth );
+ const maxY = Math.max( 0, window.innerHeight - panel.offsetHeight );
+ setPosition( {
+ x: Math.min( maxX, Math.max( 0, drag.startPanelX + event.clientX - drag.startPointerX ) ),
+ y: Math.min( maxY, Math.max( 0, drag.startPanelY + event.clientY - drag.startPointerY ) ),
+ } );
+ };
+
+ const handlePointerUp = ( event: PointerEvent< HTMLDivElement > ) => {
+ if ( dragRef.current?.pointerId === event.pointerId ) {
+ dragRef.current = null;
+ event.currentTarget.releasePointerCapture( event.pointerId );
+ }
+ };
+
+ const positionStyle: CSSProperties | undefined = position
+ ? { left: position.x, top: position.y, bottom: 'auto', transform: 'none' }
+ : undefined;
+
+ return (
+
+
+ { __( 'Usage prototype' ) }
+ setVisible( false ) }
+ />
+
+
+
+
{ __( 'Meter' ) }
+
+ { ( [ 'ring', 'signal' ] as UsageMeterStyle[] ).map( ( option ) => (
+ setUsageExplorationMeterStyle( option ) }
+ >
+ { option === 'ring' ? __( 'Ring' ) : __( 'Signal' ) }
+
+ ) ) }
+
+
+
+
{ __( 'Purchase UI' ) }
+
+ { (
+ [
+ [ 'cards', __( 'Cards' ) ],
+ [ 'presets', __( 'Presets + custom' ) ],
+ [ 'slider', __( 'Slider' ) ],
+ ] as Array< [ PurchaseCreditsVariant, string ] >
+ ).map( ( [ value, label ] ) => (
+ setUsageExplorationPurchaseCreditsVariant( value ) }
+ >
+ { label }
+
+ ) ) }
+
+
+
+
{ __( 'Checkout' ) }
+
+ { (
+ [
+ [ 'modal', __( 'Modal' ) ],
+ [ 'external', __( 'WordPress.com' ) ],
+ ] as Array< [ PurchaseCreditsFlow, string ] >
+ ).map( ( [ value, label ] ) => (
+ setUsageExplorationPurchaseCreditsFlow( value ) }
+ >
+ { label }
+
+ ) ) }
+
+
+ { meterStyle === 'signal' ? (
+ <>
+
+
+ { __( 'Icon size' ) }
+
+
+ setUsageExplorationMeterIconSize( Number( event.target.value ) )
+ }
+ />
+
+
+
{ __( 'Stack' ) }
+
+ { ( [ 'vertical', 'horizontal' ] as UsageSignalOrientation[] ).map( ( option ) => (
+ setUsageExplorationSignalOrientation( option ) }
+ >
+ { option === 'horizontal' ? __( 'Horizontal' ) : __( 'Vertical' ) }
+
+ ) ) }
+
+
+
+
+ { __( 'Thickness' ) }
+
+
+ setUsageExplorationSignalBarThickness( Number( event.target.value ) )
+ }
+ />
+
+
+
{ __( 'Align' ) }
+
+ { ( [ 'start', 'center', 'end' ] as UsageSignalAlignment[] ).map( ( option ) => (
+ setUsageExplorationSignalAlignment( option ) }
+ >
+ { option === 'center'
+ ? __( 'Center' )
+ : signalOrientation === 'horizontal'
+ ? option === 'start'
+ ? __( 'Top' )
+ : __( 'Bottom' )
+ : option === 'start'
+ ? __( 'Left' )
+ : __( 'Right' ) }
+
+ ) ) }
+
+
+
+
+ { __( 'Bars' ) }
+
+
+ setUsageExplorationSignalBarCount( Number( event.target.value ) )
+ }
+ />
+
+
+
{ __( 'Order' ) }
+
+ { ( [ 'ascending', 'descending' ] as UsageSignalStackDirection[] ).map(
+ ( option ) => (
+ setUsageExplorationSignalStackDirection( option ) }
+ >
+ { option === 'ascending' ? __( 'Small → large' ) : __( 'Large → small' ) }
+
+ )
+ ) }
+
+
+ >
+ ) : (
+ <>
+
+
+ { __( 'Ring size' ) }
+
+ setUsageExplorationRingSize( Number( event.target.value ) ) }
+ />
+
+
+
+ { __( 'Line thickness' ) }
+
+
+ setUsageExplorationRingStrokeWidth( Number( event.target.value ) )
+ }
+ />
+
+ >
+ ) }
+
+
+
+ { __( 'Adjust' ) }
+
+
+
+ setCreditAdjustment( Math.max( 0, Number( event.target.value ) ) )
+ }
+ />
+ addExplorationCredits( dollarsFromCredits( creditAdjustment ) ) }
+ >
+ { __( 'Add' ) }
+
+
+ spendExplorationPurchasedCredits( dollarsFromCredits( creditAdjustment ) )
+ }
+ >
+ { __( 'Use' ) }
+
+
+
+ { __( 'Toggle with ⌘⇧U' ) }
+
+ );
+}
diff --git a/apps/ui/src/components/usage-exploration-controls/style.module.css b/apps/ui/src/components/usage-exploration-controls/style.module.css
new file mode 100644
index 0000000000..c128ecab12
--- /dev/null
+++ b/apps/ui/src/components/usage-exploration-controls/style.module.css
@@ -0,0 +1,120 @@
+.root {
+ position: fixed;
+ inset-inline-start: 50%;
+ inset-block-end: var( --wpds-dimension-padding-lg );
+ z-index: 100001;
+ display: flex;
+ box-sizing: border-box;
+ max-width: calc( 100vw - 32px );
+ flex-direction: column;
+ gap: var( --wpds-dimension-gap-xs );
+ padding: var( --wpds-dimension-padding-sm );
+ border: var( --wpds-border-width-xs ) solid var( --wpds-color-stroke-surface-neutral );
+ border-radius: var( --wpds-border-radius-lg );
+ background: var( --wpds-color-bg-surface-neutral-strong );
+ box-shadow: var( --wpds-elevation-md );
+ color: var( --wpds-color-fg-content-neutral );
+ font-size: var( --wpds-typography-font-size-xs );
+ transform: translateX( -50% );
+}
+
+.header,
+.row,
+.buttons {
+ display: flex;
+ align-items: center;
+}
+
+.header {
+ justify-content: space-between;
+ min-height: 28px;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+}
+
+.header:active {
+ cursor: grabbing;
+}
+
+.header strong {
+ font-weight: 600;
+}
+
+.row {
+ gap: var( --wpds-dimension-gap-sm );
+}
+
+.rowLabel {
+ width: 64px;
+ flex: none;
+ color: var( --wpds-color-fg-content-neutral-weak );
+}
+
+.buttons {
+ min-width: 0;
+ flex-wrap: wrap;
+ gap: var( --wpds-dimension-gap-xs );
+}
+
+.stateButton {
+ min-height: 26px;
+ padding: 3px 8px;
+ border: var( --wpds-border-width-xs ) solid var( --wpds-color-stroke-surface-neutral );
+ border-radius: 999px;
+ background: var( --wpds-color-bg-surface-neutral-strong );
+ color: var( --wpds-color-fg-content-neutral-weak );
+ cursor: var( --wpds-cursor-control );
+ font: inherit;
+}
+
+.stateButton:hover {
+ border-color: var( --wpds-color-stroke-interactive-neutral );
+ color: var( --wpds-color-fg-content-neutral );
+}
+
+.stateButton[data-selected] {
+ border-color: var( --wpds-color-stroke-interactive-brand );
+ background: var( --wpds-color-bg-interactive-brand-weak );
+ color: var( --wpds-color-fg-interactive-brand );
+}
+
+.stateButton:focus-visible {
+ outline: var( --wpds-border-width-focus ) solid var( --wpds-color-stroke-focus-brand );
+ outline-offset: 2px;
+}
+
+.numberInput {
+ box-sizing: border-box;
+ width: 56px;
+ height: 28px;
+ padding-inline: var( --wpds-dimension-padding-xs );
+ border: var( --wpds-border-width-xs ) solid var( --wpds-color-stroke-interactive-neutral );
+ border-radius: var( --wpds-border-radius-sm );
+ background: var( --wpds-color-bg-surface-neutral-strong );
+ color: var( --wpds-color-fg-content-neutral );
+ font: inherit;
+}
+
+.creditInput {
+ box-sizing: border-box;
+ width: 96px;
+ height: 28px;
+ padding-inline: var( --wpds-dimension-padding-xs );
+ border: var( --wpds-border-width-xs ) solid var( --wpds-color-stroke-interactive-neutral );
+ border-radius: var( --wpds-border-radius-sm );
+ background: var( --wpds-color-bg-surface-neutral-strong );
+ color: var( --wpds-color-fg-content-neutral );
+ font: inherit;
+}
+
+.numberInput:focus-visible,
+.creditInput:focus-visible {
+ outline: var( --wpds-border-width-focus ) solid var( --wpds-color-stroke-focus-brand );
+ outline-offset: 2px;
+}
+
+.shortcut {
+ align-self: flex-end;
+ color: var( --wpds-color-fg-content-neutral-weak );
+}
diff --git a/apps/ui/src/data/queries/use-app-messages.test.tsx b/apps/ui/src/data/queries/use-app-messages.test.tsx
new file mode 100644
index 0000000000..3d12be3f06
--- /dev/null
+++ b/apps/ui/src/data/queries/use-app-messages.test.tsx
@@ -0,0 +1,69 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { setUsageExplorationScenario } from '@/data/usage-exploration';
+import { useActivePersistentMessages } from './use-app-messages';
+import type { ReactNode } from 'react';
+
+vi.mock( '@/data/core', () => ( {
+ useConnector: () => ( { installAppUpdate: vi.fn() } ),
+} ) );
+
+vi.mock( '@/data/queries/use-app-update', () => ( {
+ useAppUpdateStatus: () => ( { data: undefined } ),
+} ) );
+
+describe( 'useActivePersistentMessages usage notices', () => {
+ let queryClient: QueryClient;
+
+ beforeEach( () => {
+ queryClient = new QueryClient( { defaultOptions: { queries: { retry: false } } } );
+ setUsageExplorationScenario( 'healthy' );
+ } );
+
+ function wrapper( { children }: { children: ReactNode } ) {
+ return
{ children } ;
+ }
+
+ it( 'adds a dismissible notice after 80% usage', () => {
+ setUsageExplorationScenario( 'warning' );
+ const { result } = renderHook( () => useActivePersistentMessages(), { wrapper } );
+
+ expect( result.current.messages ).toEqual(
+ expect.arrayContaining( [
+ expect.objectContaining( {
+ id: 'ai-credits:warning',
+ title: 'At 80% usage',
+ } ),
+ ] )
+ );
+ } );
+
+ it( 'leaves the exhausted state to the chat surface', () => {
+ setUsageExplorationScenario( 'exhausted' );
+ const { result } = renderHook( () => useActivePersistentMessages(), { wrapper } );
+
+ expect( result.current.messages ).toEqual( [] );
+ } );
+
+ it( 'uses the purchased-credit pool for top-up warnings', () => {
+ setUsageExplorationScenario( 'extra-warning' );
+ const { result } = renderHook( () => useActivePersistentMessages(), { wrapper } );
+
+ expect( result.current.messages ).toEqual(
+ expect.arrayContaining( [
+ expect.objectContaining( {
+ id: 'ai-credits:warning',
+ title: 'At 80% usage',
+ } ),
+ ] )
+ );
+ } );
+
+ it( 'does not warn while purchased credits are still in reserve', () => {
+ setUsageExplorationScenario( 'extra-reserve' );
+ const { result } = renderHook( () => useActivePersistentMessages(), { wrapper } );
+
+ expect( result.current.messages ).toEqual( [] );
+ } );
+} );
diff --git a/apps/ui/src/data/queries/use-app-messages.ts b/apps/ui/src/data/queries/use-app-messages.ts
index 67c8b02757..7e19ab34f6 100644
--- a/apps/ui/src/data/queries/use-app-messages.ts
+++ b/apps/ui/src/data/queries/use-app-messages.ts
@@ -1,8 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { __, sprintf } from '@wordpress/i18n';
import { useMemo } from 'react';
+import { openPurchaseCreditsDialog } from '@/components/purchase-credits-dialog/events';
import { useConnector } from '@/data/core';
import { useAppUpdateStatus } from '@/data/queries/use-app-update';
+import { useUsageExploration } from '@/data/usage-exploration';
export interface PersistentMessage {
id: string;
@@ -26,6 +28,7 @@ export function useActivePersistentMessages(): {
const connector = useConnector();
const queryClient = useQueryClient();
const updateStatus = useAppUpdateStatus();
+ const usage = useUsageExploration();
const { data: dismissedIds = [] } = useQuery( {
queryKey: DISMISSED_MESSAGES_QUERY_KEY,
queryFn: () => [] as string[],
@@ -54,8 +57,35 @@ export function useActivePersistentMessages(): {
} );
}
+ const usingExtraCredits = usage.purchasedTotal > 0;
+ const activeUsageFraction = usingExtraCredits ? usage.purchasedFraction : usage.monthlyFraction;
+ const usagePercentage = Math.round( activeUsageFraction * 100 );
+ const usageTitle = sprintf(
+ /* translators: %s: percentage of the active AI credit pool used. */
+ __( 'At %s%% usage' ),
+ String( usagePercentage )
+ );
+
+ if ( ! usage.isExhausted && activeUsageFraction >= 0.9 ) {
+ messages.push( {
+ id: 'ai-credits:critical',
+ intent: 'warning',
+ title: usageTitle,
+ description: __( 'Add AI credits to keep chatting without interruption.' ),
+ cta: { label: __( 'Add AI credits' ), onClick: openPurchaseCreditsDialog },
+ } );
+ } else if ( ! usage.isExhausted && activeUsageFraction >= 0.8 ) {
+ messages.push( {
+ id: 'ai-credits:warning',
+ intent: 'warning',
+ title: usageTitle,
+ description: __( 'Add AI credits to keep chatting without interruption.' ),
+ cta: { label: __( 'Add AI credits' ), onClick: openPurchaseCreditsDialog },
+ } );
+ }
+
return messages;
- }, [ updateStatus.data, connector ] );
+ }, [ updateStatus.data, connector, usage ] );
const messages = useMemo(
() => sources.filter( ( message ) => ! dismissedIds.includes( message.id ) ),
diff --git a/apps/ui/src/data/usage-exploration.ts b/apps/ui/src/data/usage-exploration.ts
new file mode 100644
index 0000000000..e9e057de0b
--- /dev/null
+++ b/apps/ui/src/data/usage-exploration.ts
@@ -0,0 +1,387 @@
+import { useSyncExternalStore } from 'react';
+
+export type UsageExplorationScenario =
+ | 'fresh'
+ | 'healthy'
+ | 'warning'
+ | 'critical'
+ | 'exhausted'
+ | 'extra-reserve'
+ | 'extra-full'
+ | 'extra-healthy'
+ | 'extra-warning'
+ | 'extra-critical'
+ | 'extra-exhausted';
+
+export type UsageMeterStyle = 'ring' | 'signal';
+export type PurchaseCreditsVariant = 'cards' | 'presets' | 'slider';
+export type PurchaseCreditsFlow = 'modal' | 'external';
+export type UsageSignalOrientation = 'horizontal' | 'vertical';
+export type UsageSignalAlignment = 'start' | 'center' | 'end';
+export type UsageSignalStackDirection = 'ascending' | 'descending';
+
+export interface UsageExplorationState {
+ scenario: UsageExplorationScenario;
+ meterStyle: UsageMeterStyle;
+ purchaseCreditsVariant: PurchaseCreditsVariant;
+ purchaseCreditsFlow: PurchaseCreditsFlow;
+ signalOrientation: UsageSignalOrientation;
+ signalAlignment: UsageSignalAlignment;
+ signalBarCount: number;
+ signalBarThickness: number;
+ signalStackDirection: UsageSignalStackDirection;
+ meterIconSize: number;
+ ringSize: number;
+ ringStrokeWidth: number;
+ monthlyUsed: number;
+ monthlyLimit: number;
+ purchasedBalance: number;
+ purchasedTotal: number;
+}
+
+const STORAGE_KEY = 'studio-usage-exploration-state';
+const METER_STYLE_STORAGE_KEY = 'studio-usage-exploration-meter-style';
+const PURCHASE_CREDITS_VARIANT_STORAGE_KEY = 'studio-usage-exploration-purchase-variant';
+const PURCHASE_CREDITS_FLOW_STORAGE_KEY = 'studio-usage-exploration-purchase-flow';
+const SIGNAL_ORIENTATION_STORAGE_KEY = 'studio-usage-exploration-signal-orientation';
+const SIGNAL_ALIGNMENT_STORAGE_KEY = 'studio-usage-exploration-signal-alignment';
+const SIGNAL_BAR_COUNT_STORAGE_KEY = 'studio-usage-exploration-signal-bar-count';
+const SIGNAL_BAR_THICKNESS_STORAGE_KEY = 'studio-usage-exploration-signal-bar-thickness';
+const SIGNAL_STACK_DIRECTION_STORAGE_KEY = 'studio-usage-exploration-signal-stack-direction';
+const METER_ICON_SIZE_STORAGE_KEY = 'studio-usage-exploration-meter-icon-size';
+const RING_SIZE_STORAGE_KEY = 'studio-usage-exploration-ring-size';
+const RING_STROKE_WIDTH_STORAGE_KEY = 'studio-usage-exploration-ring-stroke-width';
+
+// Balances are held in dollars, matching the `cost_usage` / `cost_cap` figures
+// the quota endpoint returns. Credits are a display unit derived from them.
+export const CREDITS_PER_DOLLAR = 10_000;
+
+export function creditsFromDollars( dollars: number ): number {
+ return Math.round( dollars * CREDITS_PER_DOLLAR );
+}
+
+export function dollarsFromCredits( credits: number ): number {
+ return credits / CREDITS_PER_DOLLAR;
+}
+
+const SCENARIOS: Record<
+ UsageExplorationScenario,
+ Omit<
+ UsageExplorationState,
+ | 'scenario'
+ | 'meterStyle'
+ | 'purchaseCreditsVariant'
+ | 'purchaseCreditsFlow'
+ | 'signalOrientation'
+ | 'signalAlignment'
+ | 'signalBarCount'
+ | 'signalBarThickness'
+ | 'signalStackDirection'
+ | 'meterIconSize'
+ | 'ringSize'
+ | 'ringStrokeWidth'
+ >
+> = {
+ fresh: { monthlyUsed: 0, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 0 },
+ healthy: { monthlyUsed: 18, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 0 },
+ warning: { monthlyUsed: 40, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 0 },
+ critical: { monthlyUsed: 45, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 0 },
+ exhausted: { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 0 },
+ 'extra-reserve': { monthlyUsed: 18, monthlyLimit: 50, purchasedBalance: 50, purchasedTotal: 50 },
+ 'extra-full': { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 50, purchasedTotal: 50 },
+ 'extra-healthy': { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 32, purchasedTotal: 50 },
+ 'extra-warning': { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 10, purchasedTotal: 50 },
+ 'extra-critical': { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 5, purchasedTotal: 50 },
+ 'extra-exhausted': { monthlyUsed: 50, monthlyLimit: 50, purchasedBalance: 0, purchasedTotal: 50 },
+};
+
+function getInitialState(): UsageExplorationState {
+ let meterStyle: UsageMeterStyle = 'ring';
+ let purchaseCreditsVariant: PurchaseCreditsVariant = 'slider';
+ let purchaseCreditsFlow: PurchaseCreditsFlow = 'modal';
+ let signalOrientation: UsageSignalOrientation = 'vertical';
+ let signalAlignment: UsageSignalAlignment = 'center';
+ let signalBarCount = 3;
+ let signalBarThickness = 3;
+ let signalStackDirection: UsageSignalStackDirection = 'ascending';
+ let meterIconSize = 20;
+ let ringSize = 16;
+ let ringStrokeWidth = 2;
+ if ( typeof window !== 'undefined' ) {
+ const storedMeterStyle = window.localStorage.getItem( METER_STYLE_STORAGE_KEY );
+ if ( storedMeterStyle === 'ring' || storedMeterStyle === 'signal' ) {
+ meterStyle = storedMeterStyle;
+ } else if (
+ storedMeterStyle === 'signal-horizontal' ||
+ storedMeterStyle === 'signal-vertical'
+ ) {
+ meterStyle = 'signal';
+ signalOrientation = storedMeterStyle === 'signal-horizontal' ? 'horizontal' : 'vertical';
+ }
+ const storedPurchaseCreditsVariant = window.localStorage.getItem(
+ PURCHASE_CREDITS_VARIANT_STORAGE_KEY
+ );
+ if (
+ storedPurchaseCreditsVariant === 'cards' ||
+ storedPurchaseCreditsVariant === 'presets' ||
+ storedPurchaseCreditsVariant === 'slider'
+ ) {
+ purchaseCreditsVariant = storedPurchaseCreditsVariant;
+ }
+ const storedPurchaseCreditsFlow = window.localStorage.getItem(
+ PURCHASE_CREDITS_FLOW_STORAGE_KEY
+ );
+ if ( storedPurchaseCreditsFlow === 'modal' || storedPurchaseCreditsFlow === 'external' ) {
+ purchaseCreditsFlow = storedPurchaseCreditsFlow;
+ }
+ const storedOrientation = window.localStorage.getItem( SIGNAL_ORIENTATION_STORAGE_KEY );
+ if ( storedOrientation === 'horizontal' || storedOrientation === 'vertical' ) {
+ signalOrientation = storedOrientation;
+ }
+ const storedAlignment = window.localStorage.getItem( SIGNAL_ALIGNMENT_STORAGE_KEY );
+ if (
+ storedAlignment === 'start' ||
+ storedAlignment === 'center' ||
+ storedAlignment === 'end'
+ ) {
+ signalAlignment = storedAlignment;
+ }
+ const storedBarCount = Number( window.localStorage.getItem( SIGNAL_BAR_COUNT_STORAGE_KEY ) );
+ if ( Number.isInteger( storedBarCount ) && storedBarCount >= 2 && storedBarCount <= 8 ) {
+ signalBarCount = storedBarCount;
+ }
+ const storedBarThickness = Number(
+ window.localStorage.getItem( SIGNAL_BAR_THICKNESS_STORAGE_KEY )
+ );
+ if (
+ Number.isFinite( storedBarThickness ) &&
+ storedBarThickness >= 1 &&
+ storedBarThickness <= 5
+ ) {
+ signalBarThickness = storedBarThickness;
+ }
+ const storedStackDirection = window.localStorage.getItem( SIGNAL_STACK_DIRECTION_STORAGE_KEY );
+ if ( storedStackDirection === 'ascending' || storedStackDirection === 'descending' ) {
+ signalStackDirection = storedStackDirection;
+ }
+ const storedIconSize = Number( window.localStorage.getItem( METER_ICON_SIZE_STORAGE_KEY ) );
+ if ( Number.isInteger( storedIconSize ) && storedIconSize >= 14 && storedIconSize <= 24 ) {
+ meterIconSize = storedIconSize;
+ }
+ const storedRingSize = Number( window.localStorage.getItem( RING_SIZE_STORAGE_KEY ) );
+ if ( Number.isInteger( storedRingSize ) && storedRingSize >= 14 && storedRingSize <= 28 ) {
+ ringSize = storedRingSize;
+ }
+ const storedRingStrokeWidth = Number(
+ window.localStorage.getItem( RING_STROKE_WIDTH_STORAGE_KEY )
+ );
+ if (
+ Number.isFinite( storedRingStrokeWidth ) &&
+ storedRingStrokeWidth >= 1 &&
+ storedRingStrokeWidth <= 6
+ ) {
+ ringStrokeWidth = storedRingStrokeWidth;
+ }
+ const stored = window.localStorage.getItem( STORAGE_KEY ) as UsageExplorationScenario | null;
+ if ( stored && stored in SCENARIOS ) {
+ return {
+ scenario: stored,
+ meterStyle,
+ purchaseCreditsVariant,
+ purchaseCreditsFlow,
+ signalOrientation,
+ signalAlignment,
+ signalBarCount,
+ signalBarThickness,
+ signalStackDirection,
+ meterIconSize,
+ ringSize,
+ ringStrokeWidth,
+ ...SCENARIOS[ stored ],
+ };
+ }
+ }
+ return {
+ scenario: 'warning',
+ meterStyle,
+ purchaseCreditsVariant,
+ purchaseCreditsFlow,
+ signalOrientation,
+ signalAlignment,
+ signalBarCount,
+ signalBarThickness,
+ signalStackDirection,
+ meterIconSize,
+ ringSize,
+ ringStrokeWidth,
+ ...SCENARIOS.warning,
+ };
+}
+
+let state = getInitialState();
+const listeners = new Set< () => void >();
+
+function emit() {
+ for ( const listener of listeners ) {
+ listener();
+ }
+}
+
+function subscribe( listener: () => void ) {
+ listeners.add( listener );
+ return () => listeners.delete( listener );
+}
+
+export function setUsageExplorationScenario( scenario: UsageExplorationScenario ) {
+ state = {
+ scenario,
+ meterStyle: state.meterStyle,
+ purchaseCreditsVariant: state.purchaseCreditsVariant,
+ purchaseCreditsFlow: state.purchaseCreditsFlow,
+ signalOrientation: state.signalOrientation,
+ signalAlignment: state.signalAlignment,
+ signalBarCount: state.signalBarCount,
+ signalBarThickness: state.signalBarThickness,
+ signalStackDirection: state.signalStackDirection,
+ meterIconSize: state.meterIconSize,
+ ringSize: state.ringSize,
+ ringStrokeWidth: state.ringStrokeWidth,
+ ...SCENARIOS[ scenario ],
+ };
+ window.localStorage.setItem( STORAGE_KEY, scenario );
+ emit();
+}
+
+export function setUsageExplorationMeterStyle( meterStyle: UsageMeterStyle ) {
+ state = { ...state, meterStyle };
+ window.localStorage.setItem( METER_STYLE_STORAGE_KEY, meterStyle );
+ emit();
+}
+
+export function setUsageExplorationPurchaseCreditsVariant(
+ purchaseCreditsVariant: PurchaseCreditsVariant
+) {
+ state = { ...state, purchaseCreditsVariant };
+ window.localStorage.setItem( PURCHASE_CREDITS_VARIANT_STORAGE_KEY, purchaseCreditsVariant );
+ emit();
+}
+
+export function setUsageExplorationPurchaseCreditsFlow( purchaseCreditsFlow: PurchaseCreditsFlow ) {
+ state = { ...state, purchaseCreditsFlow };
+ window.localStorage.setItem( PURCHASE_CREDITS_FLOW_STORAGE_KEY, purchaseCreditsFlow );
+ emit();
+}
+
+export function setUsageExplorationSignalOrientation( signalOrientation: UsageSignalOrientation ) {
+ state = { ...state, signalOrientation };
+ window.localStorage.setItem( SIGNAL_ORIENTATION_STORAGE_KEY, signalOrientation );
+ emit();
+}
+
+export function setUsageExplorationSignalAlignment( signalAlignment: UsageSignalAlignment ) {
+ state = { ...state, signalAlignment };
+ window.localStorage.setItem( SIGNAL_ALIGNMENT_STORAGE_KEY, signalAlignment );
+ emit();
+}
+
+export function setUsageExplorationSignalBarCount( signalBarCount: number ) {
+ const nextCount = Math.max( 2, Math.min( 8, Math.round( signalBarCount ) ) );
+ state = { ...state, signalBarCount: nextCount };
+ window.localStorage.setItem( SIGNAL_BAR_COUNT_STORAGE_KEY, String( nextCount ) );
+ emit();
+}
+
+export function setUsageExplorationSignalBarThickness( signalBarThickness: number ) {
+ const nextThickness = Math.max( 1, Math.min( 5, signalBarThickness ) );
+ state = { ...state, signalBarThickness: nextThickness };
+ window.localStorage.setItem( SIGNAL_BAR_THICKNESS_STORAGE_KEY, String( nextThickness ) );
+ emit();
+}
+
+export function setUsageExplorationSignalStackDirection(
+ signalStackDirection: UsageSignalStackDirection
+) {
+ state = { ...state, signalStackDirection };
+ window.localStorage.setItem( SIGNAL_STACK_DIRECTION_STORAGE_KEY, signalStackDirection );
+ emit();
+}
+
+export function setUsageExplorationMeterIconSize( meterIconSize: number ) {
+ const nextSize = Math.max( 14, Math.min( 24, Math.round( meterIconSize ) ) );
+ state = { ...state, meterIconSize: nextSize };
+ window.localStorage.setItem( METER_ICON_SIZE_STORAGE_KEY, String( nextSize ) );
+ emit();
+}
+
+export function setUsageExplorationRingSize( ringSize: number ) {
+ const nextSize = Math.max( 14, Math.min( 28, Math.round( ringSize ) ) );
+ state = { ...state, ringSize: nextSize };
+ window.localStorage.setItem( RING_SIZE_STORAGE_KEY, String( nextSize ) );
+ emit();
+}
+
+export function setUsageExplorationRingStrokeWidth( ringStrokeWidth: number ) {
+ const nextStrokeWidth = Math.max( 1, Math.min( 6, ringStrokeWidth ) );
+ state = { ...state, ringStrokeWidth: nextStrokeWidth };
+ window.localStorage.setItem( RING_STROKE_WIDTH_STORAGE_KEY, String( nextStrokeWidth ) );
+ emit();
+}
+
+export function addExplorationCredits( amount: number ) {
+ const scenario = state.monthlyUsed < state.monthlyLimit ? 'extra-reserve' : 'extra-healthy';
+ const purchasedBalance = state.purchasedBalance + amount;
+ state = {
+ ...state,
+ scenario,
+ purchasedBalance,
+ // A top-up establishes a fresh gauge baseline at the new combined balance.
+ purchasedTotal: purchasedBalance,
+ };
+ window.localStorage.setItem( STORAGE_KEY, scenario );
+ emit();
+}
+
+export function spendExplorationPurchasedCredits( amount: number ) {
+ const purchasedBalance = Math.max( 0, state.purchasedBalance - amount );
+ const availableFraction = state.purchasedTotal > 0 ? purchasedBalance / state.purchasedTotal : 0;
+ let scenario: UsageExplorationScenario = 'extra-healthy';
+ if ( purchasedBalance <= 0 ) {
+ scenario = 'extra-exhausted';
+ } else if ( availableFraction <= 0.1 ) {
+ scenario = 'extra-critical';
+ } else if ( availableFraction <= 0.2 ) {
+ scenario = 'extra-warning';
+ }
+ state = {
+ ...state,
+ scenario,
+ monthlyUsed: state.monthlyLimit,
+ purchasedBalance,
+ };
+ window.localStorage.setItem( STORAGE_KEY, scenario );
+ emit();
+}
+
+export function useUsageExploration(): UsageExplorationState & {
+ monthlyFraction: number;
+ purchasedFraction: number;
+ availableBalance: number;
+ isExhausted: boolean;
+} {
+ const snapshot = useSyncExternalStore(
+ subscribe,
+ () => state,
+ () => state
+ );
+ const monthlyRemaining = Math.max( 0, snapshot.monthlyLimit - snapshot.monthlyUsed );
+ const availableBalance = monthlyRemaining + snapshot.purchasedBalance;
+ const purchasedUsed = snapshot.purchasedTotal - snapshot.purchasedBalance;
+ return {
+ ...snapshot,
+ monthlyFraction: Math.min( 1, snapshot.monthlyUsed / snapshot.monthlyLimit ),
+ purchasedFraction:
+ snapshot.purchasedTotal > 0 ? Math.min( 1, purchasedUsed / snapshot.purchasedTotal ) : 0,
+ availableBalance,
+ isExhausted: availableBalance <= 0,
+ };
+}
diff --git a/apps/ui/src/ui-classic/components/session-view/composer/index.tsx b/apps/ui/src/ui-classic/components/session-view/composer/index.tsx
index 4c9bbeeb20..fe207c2980 100644
--- a/apps/ui/src/ui-classic/components/session-view/composer/index.tsx
+++ b/apps/ui/src/ui-classic/components/session-view/composer/index.tsx
@@ -37,6 +37,7 @@ import {
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
+ type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import * as Menu from '@/components/menu';
@@ -48,6 +49,7 @@ import {
} from '@/data/queries/use-sessions';
import { FamilySwitchConfirmDialog } from './family-switch-confirm-dialog';
import styles from './style.module.css';
+import { UsageCreditsControl } from './usage-credits-control';
import {
toComposerSendAttachments,
useComposerAttachments,
@@ -211,6 +213,7 @@ interface ComposerProps {
ownerSiteId?: string;
onSwitchSession?: ( sessionId: string ) => void;
autoFocus?: boolean;
+ usageNotice?: ReactNode;
}
/**
@@ -293,6 +296,7 @@ export const Composer = forwardRef< ComposerHandle, ComposerProps >( function Co
ownerSiteId,
onSwitchSession,
autoFocus = false,
+ usageNotice,
},
ref
) {
@@ -686,6 +690,7 @@ export const Composer = forwardRef< ComposerHandle, ComposerProps >( function Co
onDragLeave={ dragHandlers.onDragLeave }
onDrop={ dragHandlers.onDrop }
>
+ { usageNotice }
( function Co
/>
+
+
+
+
+ );
+}
+
+function AiCreditsSignal( {
+ availableFraction,
+ orientation,
+ alignment,
+ barCount,
+ barThickness,
+ size,
+ stackDirection,
+}: {
+ availableFraction: number;
+ orientation: 'horizontal' | 'vertical';
+ alignment: 'start' | 'center' | 'end';
+ barCount: number;
+ barThickness: number;
+ size: number;
+ stackDirection: 'ascending' | 'descending';
+} ) {
+ const filledBars = Math.ceil( Math.max( 0, Math.min( 1, availableFraction ) ) * barCount );
+ const maxBarLength = size - 4;
+ const minBarLength = Math.max( 4, Math.round( maxBarLength * 0.375 ) );
+ const ascendingSizes = Array.from( { length: barCount }, ( _, index ) =>
+ Math.round(
+ minBarLength + ( ( maxBarLength - minBarLength ) * index ) / Math.max( 1, barCount - 1 )
+ )
+ );
+ const sizes = stackDirection === 'ascending' ? ascendingSizes : [ ...ascendingSizes ].reverse();
+ const thickness = Math.max(
+ 1,
+ Math.min( barThickness, ( size - ( barCount - 1 ) * 2 ) / barCount )
+ );
+
+ return (
+
+ { sizes.map( ( size, index ) => (
+
+ ) ) }
+
+ );
+}
+
+export function UsageCreditsControl() {
+ const usage = useUsageExploration();
+ const connector = useConnector();
+ const locale = useUserLocale();
+ const navigate = useNavigate();
+ const [ menuOpen, setMenuOpen ] = useState( false );
+ const [ purchaseOpen, setPurchaseOpen ] = useState( false );
+ const [ detailsOpen, setDetailsOpen ] = useState( false );
+ const credits = new Intl.NumberFormat( locale, {
+ notation: 'compact',
+ maximumFractionDigits: 0,
+ } );
+ const monthlyRemaining = Math.max( 0, usage.monthlyLimit - usage.monthlyUsed );
+ const monthlyAvailable = credits.format( creditsFromDollars( monthlyRemaining ) );
+ const monthlyTotal = credits.format( creditsFromDollars( usage.monthlyLimit ) );
+ const purchasedRemaining = credits.format( creditsFromDollars( usage.purchasedBalance ) );
+ const purchasedTotal = credits.format( creditsFromDollars( usage.purchasedTotal ) );
+ const isUsingPurchasedCredits = monthlyRemaining === 0 && usage.purchasedTotal > 0;
+ const activeAvailableFraction = isUsingPurchasedCredits
+ ? usage.purchasedBalance / usage.purchasedTotal
+ : monthlyRemaining / usage.monthlyLimit;
+ const activeUsedFraction = 1 - activeAvailableFraction;
+ const isCaution = activeUsedFraction >= 0.8 && activeUsedFraction < 0.9;
+ const isWarning = activeUsedFraction >= 0.9;
+ const opensExternalCheckout = usage.purchaseCreditsFlow === 'external';
+ const openPurchaseCredits = () => {
+ if ( opensExternalCheckout ) {
+ void connector.openExternalUrl( PURCHASE_CREDITS_PROTOTYPE_URL );
+ return;
+ }
+ setPurchaseOpen( true );
+ };
+
+ let tooltip: string = sprintf(
+ /* translators: 1: monthly AI credits available, 2: total monthly AI credit allowance. */
+ __( 'Monthly AI credits · %1$s / %2$s' ),
+ monthlyAvailable,
+ monthlyTotal
+ );
+ if ( isUsingPurchasedCredits ) {
+ tooltip = sprintf(
+ /* translators: 1: purchased AI credits available, 2: total purchased AI credits. */
+ __( 'Purchased AI credits · %1$s / %2$s' ),
+ purchasedRemaining,
+ purchasedTotal
+ );
+ }
+
+ return (
+ <>
+
+
+
+ }
+ >
+ { usage.meterStyle === 'ring' ? (
+
+ ) : (
+
+ ) }
+
+ }
+ />
+ }>
+ { tooltip }
+
+
+
+
+
+ { __( 'Monthly AI credit allowance' ) }
+
+ { sprintf(
+ /* translators: 1: monthly AI credits available, 2: total monthly AI credit allowance. */
+ __( '%1$s / %2$s available' ),
+ monthlyAvailable,
+ monthlyTotal
+ ) }
+
+
+
+ { __( 'Purchased AI credits' ) }
+
+ { sprintf(
+ /* translators: %s: number of purchased AI credits available. */
+ __( '%s available' ),
+ purchasedRemaining
+ ) }
+
+
+
+
+
+ { opensExternalCheckout ? __( 'Purchase AI credits' ) : __( 'Add AI credits' ) }
+ { opensExternalCheckout ? (
+
+ ) : null }
+
+ setDetailsOpen( true ) }>
+ { __( 'How AI credits work' ) }
+
+ void navigate( { to: '/settings', search: { tab: 'usage' } } ) }
+ >
+ { __( 'Usage settings' ) }
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/ui/src/ui-classic/components/session-view/index.test.tsx b/apps/ui/src/ui-classic/components/session-view/index.test.tsx
index 1ff659ebc1..9df34a197b 100644
--- a/apps/ui/src/ui-classic/components/session-view/index.test.tsx
+++ b/apps/ui/src/ui-classic/components/session-view/index.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';
import { useSession } from '@/data/queries/use-sessions';
+import { setUsageExplorationScenario } from '@/data/usage-exploration';
import { isScrolledAwayFromLatest, SessionView } from './index';
import type { LoadedAiSession } from '@/data/core';
@@ -60,7 +61,11 @@ vi.mock( '@/hooks/use-traffic-light-space', () => ( {
} ) );
vi.mock( './composer', () => ( {
- Composer: () =>
,
+ Composer: ( { usageNotice }: { usageNotice?: React.ReactNode } ) => (
+
+ { usageNotice }
+
+ ),
ComposerSkeleton: () =>
,
} ) );
@@ -68,6 +73,10 @@ vi.mock( './conversation', () => ( {
Conversation: () =>
,
} ) );
+vi.mock( '@/components/purchase-credits-dialog', () => ( {
+ PurchaseCreditsDialog: () => null,
+} ) );
+
const useSessionMock = vi.mocked( useSession, { partial: true } );
const useStudioAssistantQuotaMock = vi.mocked( useStudioAssistantQuota, { partial: true } );
@@ -105,6 +114,7 @@ function setScrollMetrics(
describe( 'SessionView', () => {
beforeEach( () => {
vi.clearAllMocks();
+ setUsageExplorationScenario( 'warning' );
// Entitled account by default; individual tests override.
useStudioAssistantQuotaMock.mockReturnValue( {
data: makeQuota( {} ),
@@ -135,6 +145,79 @@ describe( 'SessionView', () => {
expect( navigateMock ).not.toHaveBeenCalled();
} );
+ it( 'replaces the composer when the account is out of credits', () => {
+ setUsageExplorationScenario( 'exhausted' );
+ useSessionMock.mockReturnValue( {
+ data: makeLoadedSession(),
+ isLoading: false,
+ error: null,
+ } );
+
+ render( );
+
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent( 'Monthly AI credits used' );
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent(
+ "You've used your monthly AI credit allowance."
+ );
+ expect( screen.getByRole( 'button', { name: 'Add AI credits' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'explains when purchased credits are exhausted', () => {
+ setUsageExplorationScenario( 'extra-exhausted' );
+ useSessionMock.mockReturnValue( {
+ data: makeLoadedSession(),
+ isLoading: false,
+ error: null,
+ } );
+
+ render( );
+
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent( 'Purchased AI credits used' );
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent(
+ "You've used all of your purchased AI credits."
+ );
+ } );
+
+ it( 'shows a persistent composer strip at 90% usage', () => {
+ setUsageExplorationScenario( 'critical' );
+ useSessionMock.mockReturnValue( {
+ data: makeLoadedSession(),
+ isLoading: false,
+ error: null,
+ } );
+
+ render( );
+
+ expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'At 90% usage' );
+ expect( screen.getByRole( 'status' ).closest( '[data-session-composer]' ) ).not.toBeNull();
+ } );
+
+ it( 'does not show the composer strip at 80% usage', () => {
+ setUsageExplorationScenario( 'warning' );
+ useSessionMock.mockReturnValue( {
+ data: makeLoadedSession(),
+ isLoading: false,
+ error: null,
+ } );
+
+ render( );
+
+ expect( screen.queryByRole( 'status' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows the composer strip while extra credits reach 90% usage', () => {
+ setUsageExplorationScenario( 'extra-critical' );
+ useSessionMock.mockReturnValue( {
+ data: makeLoadedSession(),
+ isLoading: false,
+ error: null,
+ } );
+
+ render( );
+
+ expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'At 90% usage' );
+ } );
+
it( 'shows the scroll-to-latest button only while scrolled away and scrolls down on click', async () => {
useSessionMock.mockReturnValue( {
data: makeLoadedSession(),
diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx
index 952baa4aab..f85b75cb00 100644
--- a/apps/ui/src/ui-classic/components/session-view/index.tsx
+++ b/apps/ui/src/ui-classic/components/session-view/index.tsx
@@ -30,6 +30,7 @@ import {
useSessions,
} from '@/data/queries/use-sessions';
import { useSites } from '@/data/queries/use-sites';
+import { useUsageExploration } from '@/data/usage-exploration';
import { useSessionCommands } from '@/hooks/use-session-commands';
import { SessionUIProvider, useSessionPreviewAnnotations } from '@/hooks/use-session-ui';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
@@ -44,6 +45,8 @@ import { QueuedPrompts } from './queued-prompts';
import { getSiteSessionHistory, SessionChatActions } from './session-chat-actions';
import styles from './style.module.css';
import { SuggestedPrompts } from './suggested-prompts';
+import { UsageLimitLock } from './usage-limit-lock';
+import { UsageWarningStrip } from './usage-warning-strip';
import type { AiSessionSummary } from '@/data/core';
// Slack below the bottom edge that still counts as "at the latest message",
@@ -242,6 +245,12 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
[ pendingQuestions ]
);
const composerBusy = hasActiveRun || pendingQuestions.length > 0;
+ const usage = useUsageExploration();
+ const { isExhausted } = usage;
+ const usingExtraCredits = usage.purchasedTotal > 0;
+ const activeUsageFraction = usingExtraCredits ? usage.purchasedFraction : usage.monthlyFraction;
+ const showUsageWarning = ! usage.isExhausted && activeUsageFraction >= 0.9;
+ const activeUsagePercentage = Math.round( activeUsageFraction * 100 );
const isEmpty = useMemo(
() =>
! ( data?.entries ?? [] ).some(
@@ -474,19 +483,31 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
/>
) : null }
-
+ { isExhausted && ! composerBusy ? (
+
+ ) : (
+
+ ) : undefined
+ }
+ />
+ ) }
}
footer={
@@ -498,13 +519,14 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
onNewChat={ startNewChat }
onSwitchSession={ switchSession }
sessions={ siteSessionHistory }
+ showNewChat={ ! isExhausted }
/>
) : null
}
footerEnd={ canTogglePreview ?