diff --git a/static/app/components/events/autofix/autofixCredits.tsx b/static/app/components/events/autofix/autofixCredits.tsx index 40aa3c827596..1fe60f123b1e 100644 --- a/static/app/components/events/autofix/autofixCredits.tsx +++ b/static/app/components/events/autofix/autofixCredits.tsx @@ -119,11 +119,11 @@ export function AutofixOutOfCreditsAlert({ action, reason, }: AutofixOutOfCreditsAlertProps) { - const {hasEnoughCredits} = useAutofixCreditCheck(action); + const {hasEnoughCredits, denialReason} = useAutofixCreditCheck(action); if (hasEnoughCredits) { return null; } - return ; + return ; } diff --git a/static/app/components/events/autofix/v3/autofixStartCard.tsx b/static/app/components/events/autofix/v3/autofixStartCard.tsx index 9450e5573223..0e438ece20e9 100644 --- a/static/app/components/events/autofix/v3/autofixStartCard.tsx +++ b/static/app/components/events/autofix/v3/autofixStartCard.tsx @@ -70,7 +70,11 @@ export function AutofixStartCard({ {/* The Billing Gates design gives the sidebar one card: the notice takes the explainer's place. */} {showOutOfCreditsNotice && !creditCheck.hasEnoughCredits ? ( - + ) : ( )} diff --git a/static/app/components/seer/autofixChatContext.tsx b/static/app/components/seer/autofixChatContext.tsx index 280372123b04..cd21e3cee332 100644 --- a/static/app/components/seer/autofixChatContext.tsx +++ b/static/app/components/seer/autofixChatContext.tsx @@ -9,12 +9,18 @@ import { type ReactNode, } from 'react'; +import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck'; + export type SendMessageOptions = { /** Start a fresh conversation instead of adding to the one already open. */ newChat?: boolean; }; -type ReportOutOfCredits = (embedId: string, isOutOfCredits: boolean) => void; +type ReportOutOfCredits = ( + embedId: string, + isOutOfCredits: boolean, + denialReason: SeerCreditDenialReason | null +) => void; type AutofixChatContextValue = { /** @@ -76,21 +82,26 @@ export function useAutofixChat(): AutofixChatContextValue { /** * For the chat: whether any of its embeds can't afford the step it offers - * next, so the chat shows one notice for all of them. Pass + * next, and why, so the chat shows one notice for all of them. Pass * `reportOutOfCredits` to `AutofixChatProvider`. */ export function useAutofixOutOfCreditsEmbeds() { - const [embedIds, setEmbedIds] = useState>(() => new Set()); + // The denial reason each embed that can't afford its next step reported. + const [denials, setDenials] = useState< + ReadonlyMap + >(() => new Map()); const reportOutOfCredits = useCallback( - (embedId, isOutOfCredits) => { - setEmbedIds(current => { - if (current.has(embedId) === isOutOfCredits) { + (embedId, isOutOfCredits, denialReason) => { + setDenials(current => { + // Every embed reports on mount, and most can afford their step. Skip + // those so the chat doesn't re-render for nothing. + if (!isOutOfCredits && !current.has(embedId)) { return current; } - const next = new Set(current); + const next = new Map(current); if (isOutOfCredits) { - next.add(embedId); + next.set(embedId, denialReason); } else { next.delete(embedId); } @@ -100,15 +111,26 @@ export function useAutofixOutOfCreditsEmbeds() { [] ); - return {isAutofixOutOfCredits: embedIds.size > 0, reportOutOfCredits}; + const reasons = [...denials.values()]; + return { + isAutofixOutOfCredits: denials.size > 0, + // The daily usage limit is the org's, so one embed hitting it means they + // all have. + autofixDenialReason: + reasons.find(reason => reason === 'daily_cap_exceeded') ?? reasons[0] ?? null, + reportOutOfCredits, + }; } /** * For an embed: tells the chat around it whether it can't afford the step it - * offers next. Returns whether the embed has to show the notice itself, which - * it does only outside the chat, where nothing else would. + * offers next, and why. Returns whether the embed has to show the notice + * itself, which it does only outside the chat, where nothing else would. */ -export function useReportAutofixOutOfCredits(isOutOfCredits: boolean): boolean { +export function useReportAutofixOutOfCredits( + isOutOfCredits: boolean, + denialReason: SeerCreditDenialReason | null +): boolean { const {reportOutOfCredits} = useAutofixChat(); const embedId = useId(); @@ -116,9 +138,9 @@ export function useReportAutofixOutOfCredits(isOutOfCredits: boolean): boolean { if (!reportOutOfCredits) { return; } - reportOutOfCredits(embedId, isOutOfCredits); - return () => reportOutOfCredits(embedId, false); - }, [embedId, isOutOfCredits, reportOutOfCredits]); + reportOutOfCredits(embedId, isOutOfCredits, denialReason); + return () => reportOutOfCredits(embedId, false, null); + }, [embedId, isOutOfCredits, denialReason, reportOutOfCredits]); return isOutOfCredits && !reportOutOfCredits; } diff --git a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx index d8ad4a381c4f..0094c22754ed 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.spec.tsx @@ -631,7 +631,11 @@ describe('AutofixRef embed', () => { await expandAll(1); await waitFor(() => - expect(reportOutOfCredits).toHaveBeenLastCalledWith(expect.any(String), true) + expect(reportOutOfCredits).toHaveBeenLastCalledWith( + expect.any(String), + true, + null + ) ); expect(getButtons('Continue: Plan')[0]).toBeDisabled(); expect(screen.queryByTestId('seer-out-of-credits-cta')).not.toBeInTheDocument(); @@ -639,7 +643,7 @@ describe('AutofixRef embed', () => { // A card that leaves the chat takes its report with it. const [embedId] = reportOutOfCredits.mock.lastCall!; unmount(); - expect(reportOutOfCredits).toHaveBeenLastCalledWith(embedId, false); + expect(reportOutOfCredits).toHaveBeenLastCalledWith(embedId, false, null); }); it('reports nothing to the Explorer chat when the org can afford the step', async () => { @@ -662,7 +666,7 @@ describe('AutofixRef embed', () => { await expandAll(1); await waitFor(() => expect(getButtons('Continue: Plan')[0]).toBeEnabled()); - expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true); + expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true, null); }); it('offers no next step for a single run, so checks nothing', async () => { @@ -706,7 +710,7 @@ describe('AutofixRef embed', () => { expect(await screen.findByText('Divides by zero')).toBeInTheDocument(); expect(getButtons('Continue: Plan')).toHaveLength(0); expect(creditCheck).not.toHaveBeenCalled(); - expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true); + expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true, null); }); it('does not check credits in a read-only conversation', async () => { diff --git a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx index 3585822df1cb..60acfdca6c11 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.stories.tsx @@ -569,7 +569,8 @@ function CreditsChat({ }) { const organization = useOrganization(); const [queryClient] = useState(() => new CreditsQueryClient(run, hasEnoughCredits)); - const {isAutofixOutOfCredits, reportOutOfCredits} = useAutofixOutOfCreditsEmbeds(); + const {isAutofixOutOfCredits, autofixDenialReason, reportOutOfCredits} = + useAutofixOutOfCreditsEmbeds(); return ( @@ -597,7 +598,11 @@ function CreditsChat({ + ) : null } > diff --git a/static/app/components/seer/markdown/embeds/components/autofix.tsx b/static/app/components/seer/markdown/embeds/components/autofix.tsx index 35612a0c9138..8a0cfe226257 100644 --- a/static/app/components/seer/markdown/embeds/components/autofix.tsx +++ b/static/app/components/seer/markdown/embeds/components/autofix.tsx @@ -319,7 +319,8 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { // In the Explorer chat the notice sits above the composer, where it stays in // view as the chat scrolls. Anywhere else the card shows its own. const showsOutOfCreditsNotice = useReportAutofixOutOfCredits( - showsNextAction && !creditCheck.hasEnoughCredits + showsNextAction && !creditCheck.hasEnoughCredits, + creditCheck.denialReason ); return ( @@ -335,7 +336,11 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) { )} {/* Muted: the buttons it explains stay right below it. */} {showsOutOfCreditsNotice && ( - + )} {showsNextAction && ( diff --git a/static/app/components/seer/outOfCredits/seerOutOfCreditsCta.tsx b/static/app/components/seer/outOfCredits/seerOutOfCreditsCta.tsx index 74763423b38c..1679d860b5db 100644 --- a/static/app/components/seer/outOfCredits/seerOutOfCreditsCta.tsx +++ b/static/app/components/seer/outOfCredits/seerOutOfCreditsCta.tsx @@ -1,4 +1,5 @@ import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; +import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck'; /** * What the organization can't afford, which picks the copy: @@ -11,6 +12,11 @@ import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; export type SeerOutOfCreditsReason = 'agent-chat' | 'autofix-start' | 'autofix-continue'; export interface SeerOutOfCreditsCtaProps { + /** + * Why the surface's credit check denied credit, which also picks the copy: + * hitting the daily usage limit reads differently from running out. + */ + denialReason: SeerCreditDenialReason | null; reason: SeerOutOfCreditsReason; /** * The action button's size. Defaults to `md` for `agent-chat`, which sits diff --git a/static/app/utils/seer/useSeerCreditCheck.tsx b/static/app/utils/seer/useSeerCreditCheck.tsx index d9b5d41f81aa..033f0d943310 100644 --- a/static/app/utils/seer/useSeerCreditCheck.tsx +++ b/static/app/utils/seer/useSeerCreditCheck.tsx @@ -17,7 +17,20 @@ export type SeerCreditOperation = | 'autofix_open_pr' | 'autofix_pr_iteration'; +/** + * Why the check denied credit: + * + * - `not_enough_credits`: the wallet can't cover the operation. + * - `daily_cap_exceeded`: the operation would take today's spend over the + * org's daily usage limit. + */ +export type SeerCreditDenialReason = 'not_enough_credits' | 'daily_cap_exceeded'; + interface SeerCreditCheckResponse { + /** + * Null when the check allows the operation, or doesn't say why it doesn't. + */ + denialReason: SeerCreditDenialReason | null; hasEnoughCredits: boolean; } @@ -93,6 +106,7 @@ export function useSeerCreditCheck( return { hasEnoughCredits: isChecking ? (query.data?.hasEnoughCredits ?? true) : true, + denialReason: isChecking ? (query.data?.denialReason ?? null) : null, /** * Whether the check has answered, or failed, since the caller mounted. A * cached answer from an earlier mount doesn't count, since credits can diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx index 69d2daea4a93..d658886a4497 100644 --- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx +++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx @@ -317,6 +317,7 @@ function IssuePreviewContent() { ? 'autofix-start' : 'autofix-continue' } + denialReason={previewSeer.creditCheck.denialReason} /> )} {summary && } diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx index fee2aff7b96b..58a467019eb5 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx @@ -23,7 +23,10 @@ import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits import {registerOverride} from 'sentry/overrideRegistry'; import {ConfigStore} from 'sentry/stores/configStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; -import {seerCreditCheckApiOptions} from 'sentry/utils/seer/useSeerCreditCheck'; +import { + seerCreditCheckApiOptions, + type SeerCreditDenialReason, +} from 'sentry/utils/seer/useSeerCreditCheck'; import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext'; import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext'; import * as useSeerExplorerModule from 'sentry/views/seerExplorer/hooks/useSeerExplorer'; @@ -2136,11 +2139,14 @@ describe('SeerExplorerContent', () => { hideAiFeatures: false, }); - function mockCreditCheck(hasEnoughCredits: boolean) { + function mockCreditCheck( + hasEnoughCredits: boolean, + denialReason: SeerCreditDenialReason | null = null + ) { return MockApiClient.addMockResponse({ url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, match: [MockApiClient.matchQuery({operation: 'agent_chat_message'})], - body: {hasEnoughCredits}, + body: {hasEnoughCredits, denialReason}, }); } @@ -2188,6 +2194,26 @@ describe('SeerExplorerContent', () => { expect(textarea).toHaveTextContent('Test message'); }); + it('tells the CTA why the chat was denied credit', async () => { + // getsentry fills in the CTA; this stand-in shows what it was asked for. + registerOverride( + 'component:seer-out-of-credits-cta', + () => + function StubOutOfCreditsCta({reason, denialReason}: SeerOutOfCreditsCtaProps) { + return ( +
{`${reason} ${denialReason}`}
+ ); + } + ); + mockCreditCheck(false, 'daily_cap_exceeded'); + + renderContent(); + + expect(await screen.findByTestId('seer-out-of-credits-cta')).toHaveTextContent( + 'agent-chat daily_cap_exceeded' + ); + }); + it('hides the suggested questions', async () => { mockCreditCheck(false); @@ -2479,6 +2505,29 @@ describe('SeerExplorerContent', () => { ).not.toBeInTheDocument(); }); + it('tells the autofix CTA why the card was denied credit', async () => { + registerOverride( + 'component:seer-out-of-credits-cta', + () => + function DenialReasonCta({reason, denialReason}: SeerOutOfCreditsCtaProps) { + return ( +
{`${reason} ${denialReason}`}
+ ); + } + ); + mockCreditCheck(true); + MockApiClient.addMockResponse({ + url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'autofix_solution'})], + body: {hasEnoughCredits: false, denialReason: 'daily_cap_exceeded'}, + }); + + renderContent(); + + const cta = await screen.findByTestId('seer-explorer-autofix-out-of-credits'); + expect(cta).toHaveTextContent('autofix-continue daily_cap_exceeded'); + }); + it('shows only the chat CTA when the chat is out of credits too', async () => { mockCreditCheck(false); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 729d2f576b83..c2f94d2b7d0a 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -364,7 +364,7 @@ export function SeerExplorerContent({ // waits on an answer to a message it already has. const isComposerShown = !readOnly && !showLoadError && !showFileApprovalBar && !showQuestionBar; - const {hasEnoughCredits, hasCheckedSinceMount} = useSeerCreditCheck( + const {hasEnoughCredits, denialReason, hasCheckedSinceMount} = useSeerCreditCheck( 'agent_chat_message', {enabled: isComposerShown} ); @@ -399,18 +399,23 @@ export function SeerExplorerContent({ // own notice wins, so the card's waits for the chat's check to answer. While // the approval or question bar stands in for the composer, there's nowhere to // put it, so cards show their own. - const {isAutofixOutOfCredits, reportOutOfCredits} = useAutofixOutOfCreditsEmbeds(); + const {isAutofixOutOfCredits, autofixDenialReason, reportOutOfCredits} = + useAutofixOutOfCreditsEmbeds(); let outOfCreditsNotice: ReactNode = null; if (isOutOfCredits) { outOfCreditsNotice = ( - + ); } else if (isAutofixOutOfCredits && !isCheckingCredits) { outOfCreditsNotice = ( - + ); } diff --git a/static/gsApp/components/ai/seerOutOfCreditsCta.spec.tsx b/static/gsApp/components/ai/seerOutOfCreditsCta.spec.tsx index be18fe45fb55..935e2a8fde47 100644 --- a/static/gsApp/components/ai/seerOutOfCreditsCta.spec.tsx +++ b/static/gsApp/components/ai/seerOutOfCreditsCta.spec.tsx @@ -50,7 +50,9 @@ describe('SeerOutOfCreditsCta', () => { }, }); - render(, {organization}); + render(, { + organization, + }); renderGlobalModal({organization}); expect( @@ -66,35 +68,115 @@ describe('SeerOutOfCreditsCta', () => { expect(screen.queryByRole('button', {name: 'Contact Admin'})).not.toBeInTheDocument(); }); - it('tells billing members when auto-reload has no top-ups left', async () => { + it('tells billing members with auto-reload on that the org hit its daily usage limit', async () => { const organization = OrganizationFixture({access: ['org:read', 'org:billing']}); act(() => SubscriptionStore.set(organization.slug, SubscriptionFixture({organization})) ); mockAutoTopUp(organization, true); - render(, {organization}); + render(, { + organization, + }); // Nothing shows until the setting loads, so the "off" copy never flashes. expect(screen.queryByTestId('seer-out-of-credits')).not.toBeInTheDocument(); + // Auto-reload tops the org up until the daily usage limit stops it. + expect( + await screen.findByText( + 'Your organization hit its daily usage limit, so you cannot continue messaging Seer.' + ) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', {name: 'Configure auto-reload'}) + ).toBeInTheDocument(); + }); + + it('tells billing members when the chat hit the daily usage limit', async () => { + const organization = OrganizationFixture({access: ['org:read', 'org:billing']}); + act(() => + SubscriptionStore.set(organization.slug, SubscriptionFixture({organization})) + ); + // Auto-reload being off doesn't matter: the limit stopped the chat, and + // it's set alongside auto-reload. + mockAutoTopUp(organization, false); + + render( + , + {organization} + ); + expect( await screen.findByText( - 'Your organization does not have enough credits left for you to message Seer, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up.' + 'Your organization hit its daily usage limit, so you cannot continue messaging Seer.' ) ).toBeInTheDocument(); + // Tests run in UTC with the clock frozen at 2017-10-17T02:41:20Z, so the + // limit resets at the next midnight UTC. + expect(screen.getByTestId('seer-out-of-credits')).toHaveTextContent( + 'This will reset at 12:00 AM UTC.' + ); expect( screen.getByRole('button', {name: 'Configure auto-reload'}) ).toBeInTheDocument(); }); + it('tells members to ask their admin about the daily usage limit', () => { + const organization = OrganizationFixture({access: ['org:read']}); + act(() => + SubscriptionStore.set( + organization.slug, + SubscriptionFixture({organization, canSelfServe: true}) + ) + ); + + render( + , + {organization} + ); + + expect( + screen.getByText( + 'Your organization hit its daily usage limit, so you cannot continue messaging Seer. If you want to increase your limit, reach out to your admin.' + ) + ).toBeInTheDocument(); + // "Contact Admin" asks for credits, which won't lift the limit. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('tells members when Autofix hit the daily usage limit', () => { + const organization = OrganizationFixture({access: ['org:read']}); + act(() => + SubscriptionStore.set( + organization.slug, + SubscriptionFixture({organization, canSelfServe: true}) + ) + ); + + render( + , + {organization} + ); + + expect( + screen.getByText( + 'Your organization hit its daily usage limit, so you cannot start an Autofix run. If you want to increase your limit, reach out to your admin.' + ) + ).toBeInTheDocument(); + }); + it.each([ [ 'autofix-start', - 'You do not have enough credits to start an Autofix run. Set up auto top up to keep your flow uninterrupted.', + 'You do not have enough credits to start an Autofix run. Set up auto-reload to keep your flow uninterrupted.', ], [ 'autofix-continue', - 'Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted.', + 'Your org does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted.', ], ] as const)('uses the %s copy', async (reason, copy) => { const organization = OrganizationFixture({access: ['org:read', 'org:billing']}); @@ -103,7 +185,7 @@ describe('SeerOutOfCreditsCta', () => { ); mockAutoTopUp(organization, false); - render(, {organization}); + render(, {organization}); expect(await screen.findByText(copy)).toBeInTheDocument(); expect( @@ -137,7 +219,9 @@ describe('SeerOutOfCreditsCta', () => { method: 'POST', }); - render(, {organization}); + render(, { + organization, + }); expect( await screen.findByText( @@ -165,11 +249,11 @@ describe('SeerOutOfCreditsCta', () => { it.each([ [ 'autofix-start', - 'Your organization does not have enough credits to start an Autofix run. Ask your admin to increase your daily token limit or set up auto top-up.', + 'Your organization does not have enough credits to start an Autofix run. Ask your admin to increase your daily usage limit or set up auto-reload.', ], [ 'autofix-continue', - 'Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily token limit or set up auto top-up.', + 'Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily usage limit or set up auto-reload.', ], ] as const)('uses the member %s copy', async (reason, copy) => { const organization = OrganizationFixture({access: ['org:read']}); @@ -184,7 +268,9 @@ describe('SeerOutOfCreditsCta', () => { body: {}, }); - render(, {organization}); + render(, { + organization, + }); expect(await screen.findByText(copy)).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Contact Admin'})).toBeEnabled(); @@ -211,7 +297,9 @@ describe('SeerOutOfCreditsCta', () => { }, }); - render(, {organization}); + render(, { + organization, + }); // "Contact Admin" can't be clicked before the earlier request has loaded. expect(screen.queryByRole('button', {name: 'Contact Admin'})).not.toBeInTheDocument(); @@ -240,7 +328,9 @@ describe('SeerOutOfCreditsCta', () => { }, }); - render(, {organization}); + render(, { + organization, + }); expect(await screen.findByRole('button', {name: 'Contact Admin'})).toBeEnabled(); expect(prompts).toHaveBeenCalled(); @@ -258,7 +348,9 @@ describe('SeerOutOfCreditsCta', () => { ) ); - render(, {organization}); + render(, { + organization, + }); expect(screen.getByText(/Ask your admin to set up auto-reload/)).toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); diff --git a/static/gsApp/components/ai/seerOutOfCreditsCta.tsx b/static/gsApp/components/ai/seerOutOfCreditsCta.tsx index ead78e08df83..f9801b165df0 100644 --- a/static/gsApp/components/ai/seerOutOfCreditsCta.tsx +++ b/static/gsApp/components/ai/seerOutOfCreditsCta.tsx @@ -4,6 +4,7 @@ import {useQuery} from '@tanstack/react-query'; import {usePrompts} from 'sentry/actionCreators/prompts'; import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import type {Organization} from 'sentry/types/organization'; +import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck'; import {useApi} from 'sentry/utils/useApi'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -20,11 +21,17 @@ import {seerWalletAutoTopUpOptions} from 'getsentry/views/subscriptionPage/seerW function getEscalation( organization: Organization, - subscription: Subscription | null + subscription: Subscription | null, + denialReason: SeerCreditDenialReason | null | undefined ): SeerOutOfCreditsEscalation { if (organization.access.includes('org:billing')) { return 'configure'; } + // "Contact Admin" asks the admins for more credits, which doesn't lift the + // daily usage limit, so members are only told to ask them about the limit. + if (denialReason === 'daily_cap_exceeded') { + return 'contact'; + } // Members of orgs that buy through an account team have nobody to send a // self-serve request to, same as the upgrade and trial CTAs. if (subscription?.canSelfServe) { @@ -35,12 +42,17 @@ function getEscalation( const TOP_UP_REQUESTED_PROMPT = 'seer_top_up_requested'; -export function SeerOutOfCreditsCta({reason, size, variant}: SeerOutOfCreditsCtaProps) { +export function SeerOutOfCreditsCta({ + reason, + denialReason, + size, + variant, +}: SeerOutOfCreditsCtaProps) { const api = useApi({persistInFlight: true}); const organization = useOrganization(); const subscription = useSubscription(); const [isRequestingCredits, setIsRequestingCredits] = useState(false); - const escalation = getEscalation(organization, subscription); + const escalation = getEscalation(organization, subscription, denialReason); const { isLoading: isPromptLoading, @@ -55,9 +67,8 @@ export function SeerOutOfCreditsCta({reason, size, variant}: SeerOutOfCreditsCta options: {enabled: escalation === 'request'}, }); - // The copy changes when auto-reload is already on: the org has used up the - // top-ups it allows. Only billing members see that copy, and only they can - // read the setting. + // With auto-reload on, running out means the org hit its daily usage limit, + // which gets its own copy. Only billing members can read the setting. const {data: autoTopUp, isPending: isAutoTopUpPending} = useQuery({ ...seerWalletAutoTopUpOptions(organization), enabled: escalation === 'configure', @@ -89,6 +100,7 @@ export function SeerOutOfCreditsCta({reason, size, variant}: SeerOutOfCreditsCta return ( - + + ``` ## Agent chat @@ -46,25 +47,50 @@ auto-reload modal. The chat uses full-size buttons; Autofix uses small ones. /> ``` -## Agent chat, auto-reload already on +## Daily usage limit -With auto-reload on, running out means the org used every top-up it allows, so -billing members configure it instead. +The org has credits but hit its daily usage limit: the credit check's +`denialReason` is `daily_cap_exceeded`, or a billing member has auto-reload on, +which tops the org up until the limit stops it. The notice says when the limit +resets, at the next midnight UTC in the viewer's timezone. Billing members +configure auto-reload, where the limit is set. Members can't raise the limit, +and more credits won't lift it, so they get no button and are told to ask their +admin. Autofix uses the same copy for its own steps. - {}} - /> + + {}} + /> + + {}} + /> + ```jsx + + ``` @@ -219,9 +245,9 @@ The auto-reload button and "Contact Admin" do the real thing: the modal reads an saves your org's Seer wallet, and "Contact Admin" emails your billing members. - + ```jsx - + ``` diff --git a/static/gsApp/components/ai/seerOutOfCreditsNotice.tsx b/static/gsApp/components/ai/seerOutOfCreditsNotice.tsx index 9cd93ade4524..1a48e5159035 100644 --- a/static/gsApp/components/ai/seerOutOfCreditsNotice.tsx +++ b/static/gsApp/components/ai/seerOutOfCreditsNotice.tsx @@ -6,11 +6,13 @@ import {Button, type ButtonProps} from '@sentry/scraps/button'; import {Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; +import {DateTime} from 'sentry/components/dateTime'; import type { SeerOutOfCreditsCtaProps, SeerOutOfCreditsReason, } from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; -import {t} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; +import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck'; /** * What the viewer can do about running out of credits: @@ -22,33 +24,17 @@ import {t} from 'sentry/locale'; */ export type SeerOutOfCreditsEscalation = 'configure' | 'request' | 'contact'; -// Billing members, from the Billing Gates designs. Auto-reload already on means -// the org ran through the top-ups it allows, so the copy says so. -const BILLING_MESSAGE: Record> = { - 'agent-chat': { - off: t( - 'Your organization does not have enough credits left for you to message Seer. Adjust your spend and turn on auto-reload.' - ), - on: t( - 'Your organization does not have enough credits left for you to message Seer, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up.' - ), - }, - 'autofix-start': { - off: t( - 'You do not have enough credits to start an Autofix run. Set up auto top up to keep your flow uninterrupted.' - ), - on: t( - 'You do not have enough credits to start an Autofix run, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up.' - ), - }, - 'autofix-continue': { - off: t( - 'Your org does not have enough credits to continue your Autofix run. Set up auto top up to keep your flow uninterrupted.' - ), - on: t( - 'Your org does not have enough credits to continue your Autofix run, and you have no auto top-ups remaining. Adjust your spend and turn on auto top-up.' - ), - }, +// Billing members, from the Billing Gates designs. +const BILLING_MESSAGE: Record = { + 'agent-chat': t( + 'Your organization does not have enough credits left for you to message Seer. Adjust your spend and turn on auto-reload.' + ), + 'autofix-start': t( + 'You do not have enough credits to start an Autofix run. Set up auto-reload to keep your flow uninterrupted.' + ), + 'autofix-continue': t( + 'Your org does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted.' + ), }; // Members, from the designs' member flow. "Contact Admin" says who to ask, so @@ -58,10 +44,10 @@ const MEMBER_MESSAGE: Record = { 'Your organization does not have enough credits left for you to message Seer.' ), 'autofix-start': t( - 'Your organization does not have enough credits to start an Autofix run. Ask your admin to increase your daily token limit or set up auto top-up.' + 'Your organization does not have enough credits to start an Autofix run. Ask your admin to increase your daily usage limit or set up auto-reload.' ), 'autofix-continue': t( - 'Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily token limit or set up auto top-up.' + 'Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily usage limit or set up auto-reload.' ), }; @@ -74,27 +60,81 @@ const CONTACT_MESSAGE: Record = { ), }; +// From the Agent Chat designs, for an org that hit its daily usage limit, and +// the same for Autofix. Members can't raise the limit, so their copy says who +// can. +const DAILY_LIMIT_MESSAGE: Record< + SeerOutOfCreditsReason, + Record<'configure' | 'member', string> +> = { + 'agent-chat': { + configure: t( + 'Your organization hit its daily usage limit, so you cannot continue messaging Seer.' + ), + member: t( + 'Your organization hit its daily usage limit, so you cannot continue messaging Seer. If you want to increase your limit, reach out to your admin.' + ), + }, + 'autofix-start': { + configure: t( + 'Your organization hit its daily usage limit, so you cannot start an Autofix run.' + ), + member: t( + 'Your organization hit its daily usage limit, so you cannot start an Autofix run. If you want to increase your limit, reach out to your admin.' + ), + }, + 'autofix-continue': { + configure: t( + 'Your organization hit its daily usage limit, so you cannot continue your Autofix run.' + ), + member: t( + 'Your organization hit its daily usage limit, so you cannot continue your Autofix run. If you want to increase your limit, reach out to your admin.' + ), + }, +}; + +/** + * When the daily usage limit resets. Billing counts a day's spend by its UTC + * date, so it resets at the next midnight UTC. + */ +function getDailyLimitResetsAt(): Date { + const now = new Date(); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1) + ); +} + function getSeerOutOfCreditsMessage({ reason, escalation, - autoReloadEnabled, + isDailyLimitReached, }: { escalation: SeerOutOfCreditsEscalation; + isDailyLimitReached: boolean; reason: SeerOutOfCreditsReason; - autoReloadEnabled?: boolean; }): string { + if (isDailyLimitReached) { + return DAILY_LIMIT_MESSAGE[reason][ + escalation === 'configure' ? 'configure' : 'member' + ]; + } if (escalation === 'configure') { - return BILLING_MESSAGE[reason][autoReloadEnabled ? 'on' : 'off']; + return BILLING_MESSAGE[reason]; } return escalation === 'request' ? MEMBER_MESSAGE[reason] : CONTACT_MESSAGE[reason]; } -export interface SeerOutOfCreditsNoticeProps extends SeerOutOfCreditsCtaProps { +export interface SeerOutOfCreditsNoticeProps extends Omit< + SeerOutOfCreditsCtaProps, + 'denialReason' +> { escalation: SeerOutOfCreditsEscalation; /** - * Auto-reload is on, so the org has used up the top-ups it allows. + * Auto-reload is on. It tops the org up as it runs low, so running out anyway + * means the org hit its daily usage limit. Only billing members can read it. */ autoReloadEnabled?: boolean; + denialReason?: SeerCreditDenialReason | null; /** * The viewer already emailed their admins, so "Contact Admin" shows * "Admins emailed" instead. @@ -118,6 +158,7 @@ export interface SeerOutOfCreditsNoticeProps extends SeerOutOfCreditsCtaProps { */ export function SeerOutOfCreditsNotice({ reason, + denialReason, size, variant = 'warning', escalation, @@ -127,17 +168,29 @@ export function SeerOutOfCreditsNotice({ const NoticeAlert = variant === 'muted' ? MutedAlert : Alert; // The chat has room for a full-size button; Autofix's spots are tighter. const buttonSize = size ?? (reason === 'agent-chat' ? 'md' : 'sm'); + const isDailyLimitReached = + denialReason === 'daily_cap_exceeded' || !!autoReloadEnabled; return ( - {getSeerOutOfCreditsMessage({reason, escalation, autoReloadEnabled})} + + {getSeerOutOfCreditsMessage({reason, escalation, isDailyLimitReached})} + + {isDailyLimitReached && ( + + {tct('This will reset at [time].', { + time: , + })} + + )}