From 25e0ca33fa869d8c501755abfbf68fb74391e03f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 13 Nov 2026 10:55:00 -0500 Subject: [PATCH] feat(seer): Show the daily usage limit when Seer credit checks deny for it The Seer credit check now says why it denied credit. useSeerCreditCheck returns the denialReason, and every surface passes it to the out-of-credits CTA, which now requires it. When the reason is daily_cap_exceeded, or a billing member has auto-reload on, the notice says the org hit its daily usage limit and when the limit resets, at the next midnight UTC. Billing members configure auto-reload. Members get no button, since asking admins for credits doesn't lift the limit. This replaces the copy for orgs whose auto-reload ran out of top-ups. In the chat, Autofix cards send their denial reason with their out-of-credits report, so the chat's single notice shows the daily limit too. Autofix's out-of-credits copy now says auto-reload and daily usage limit, instead of auto top-up and daily token limit. Refs CW-3098 Co-Authored-By: Claude Opus 5.5 --- .../events/autofix/autofixCredits.tsx | 4 +- .../events/autofix/v3/autofixStartCard.tsx | 6 +- .../components/seer/autofixChatContext.tsx | 52 +++++-- .../embeds/components/autofix.spec.tsx | 12 +- .../embeds/components/autofix.stories.tsx | 9 +- .../markdown/embeds/components/autofix.tsx | 9 +- .../seer/outOfCredits/seerOutOfCreditsCta.tsx | 6 + static/app/utils/seer/useSeerCreditCheck.tsx | 14 ++ .../pages/inbox/issuePreview/issuePreview.tsx | 1 + .../components/seerExplorerContent.spec.tsx | 55 +++++++- .../components/seerExplorerContent.tsx | 13 +- .../ai/seerOutOfCreditsCta.spec.tsx | 120 ++++++++++++++-- .../components/ai/seerOutOfCreditsCta.tsx | 24 +++- .../ai/seerOutOfCreditsNotice.stories.mdx | 56 ++++++-- .../components/ai/seerOutOfCreditsNotice.tsx | 129 ++++++++++++------ 15 files changed, 404 insertions(+), 106 deletions(-) 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: , + })} + + )}