diff --git a/static/app/components/seer/autofixChatContext.spec.tsx b/static/app/components/seer/autofixChatContext.spec.tsx index 531e276078a8..ca4fa28bbcca 100644 --- a/static/app/components/seer/autofixChatContext.spec.tsx +++ b/static/app/components/seer/autofixChatContext.spec.tsx @@ -218,6 +218,31 @@ describe('AutofixChatProvider', () => { }); }); + it('leaves the message in the composer when the organization is out of credits', async () => { + const creditsOrganization = OrganizationFixture({ + ...organization, + features: [ + ...organization.features, + 'seer-credit-checks', + 'token-billed-seer-enabled', + ], + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'agent_chat_message'})], + body: {hasEnoughCredits: false}, + }); + + render(tree('Why is this issue spiking?'), {organization: creditsOrganization}); + await userEvent.click(await screen.findByRole('button', {name: 'ask-seer'})); + + expect(await screen.findByTestId('seer-explorer-out-of-credits')).toBeInTheDocument(); + expect( + await screen.findByRole('combobox', {name: 'Ask Seer a question'}) + ).toHaveTextContent('Why is this issue spiking?'); + expect(postChat).not.toHaveBeenCalled(); + }); + it('leaves the entry point disabled with no provider above it', () => { render(, {organization}); diff --git a/static/app/components/seer/outOfCredits/seerCreditUsageMeter.tsx b/static/app/components/seer/outOfCredits/seerCreditUsageMeter.tsx new file mode 100644 index 000000000000..217468f3fb9a --- /dev/null +++ b/static/app/components/seer/outOfCredits/seerCreditUsageMeter.tsx @@ -0,0 +1,21 @@ +import {OverrideOrDefault} from 'sentry/components/overrideOrDefault'; + +export interface SeerCreditUsageMeterProps { + /** + * The surface's own credit check: whether the organization can afford the + * operation the meter sits next to. The meter warns when it can't. + */ + hasEnoughCredits: boolean; +} + +/** + * How much of its Seer credits the organization has used, shown next to a + * billable trigger such as the chat's send button. Hovering explains the + * balance and how credits reload. + * + * getsentry supplies the implementation, since it owns the credit wallet and + * the auto reload settings. Open source has no credits and renders nothing. + */ +export const SeerCreditUsageMeter = OverrideOrDefault({ + overrideName: 'component:seer-credit-usage-meter', +}); diff --git a/static/app/types/overrides.tsx b/static/app/types/overrides.tsx index 255ca334a36a..967273163b98 100644 --- a/static/app/types/overrides.tsx +++ b/static/app/types/overrides.tsx @@ -8,6 +8,7 @@ import type {Guide} from 'sentry/components/assistant/types'; import type {ProductSelectionProps} from 'sentry/components/onboarding/productSelection'; import type {UseScmFeatureMetaResult} from 'sentry/components/onboarding/scm/useScmFeatureMeta'; import type {InstallationInfo} from 'sentry/components/pipeline/integrationGitHub'; +import type {SeerCreditUsageMeterProps} from 'sentry/components/seer/outOfCredits/seerCreditUsageMeter'; import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import type {DateRange} from 'sentry/components/timeRangeSelector/dateRange'; import type {SelectorItems} from 'sentry/components/timeRangeSelector/selectorItems'; @@ -219,6 +220,7 @@ type ComponentOverrides = { 'component:replay-list-page-header': () => React.ComponentType | null; 'component:replay-settings-alert': () => React.ComponentType | null; 'component:scm-github-multi-org-install': () => React.ComponentType; + 'component:seer-credit-usage-meter': () => React.ComponentType; 'component:seer-out-of-credits-cta': () => React.ComponentType; 'component:seer-trial-cta': () => React.ComponentType | null; 'component:superuser-access-category': React.ComponentType; diff --git a/static/app/utils/seer/useSeerCreditCheck.spec.tsx b/static/app/utils/seer/useSeerCreditCheck.spec.tsx index 50357d6334e3..ef6e1bce465f 100644 --- a/static/app/utils/seer/useSeerCreditCheck.spec.tsx +++ b/static/app/utils/seer/useSeerCreditCheck.spec.tsx @@ -109,7 +109,11 @@ describe('useSeerCreditCheck', () => { ); expect(result.current).toEqual( - expect.objectContaining({hasEnoughCredits: true, isPending: false}) + expect.objectContaining({ + hasEnoughCredits: true, + hasCheckedSinceMount: true, + isPending: false, + }) ); expect(request).not.toHaveBeenCalled(); }); @@ -124,6 +128,19 @@ describe('useSeerCreditCheck', () => { await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.hasEnoughCredits).toBe(true); + expect(result.current.hasCheckedSinceMount).toBe(true); expect(result.current.isPending).toBe(false); }); + + it('says when the check has answered since the caller mounted', async () => { + MockApiClient.addMockResponse({url, body: {hasEnoughCredits: true}}); + + const {result} = renderHookWithProviders( + () => useSeerCreditCheck('agent_chat_message'), + {organization} + ); + + expect(result.current.hasCheckedSinceMount).toBe(false); + await waitFor(() => expect(result.current.hasCheckedSinceMount).toBe(true)); + }); }); diff --git a/static/app/utils/seer/useSeerCreditCheck.tsx b/static/app/utils/seer/useSeerCreditCheck.tsx index 421eaef7efd3..d05bbf38e565 100644 --- a/static/app/utils/seer/useSeerCreditCheck.tsx +++ b/static/app/utils/seer/useSeerCreditCheck.tsx @@ -71,6 +71,13 @@ export function useSeerCreditCheck( return { hasEnoughCredits: isChecking ? (query.data?.hasEnoughCredits ?? true) : true, + /** + * Whether the check has answered, or failed, since the caller mounted. A + * cached answer from an earlier mount doesn't count, since credits can + * change in between. Wait on this rather than `isPending` before acting on + * the answer automatically. + */ + hasCheckedSinceMount: !isChecking || query.isFetchedAfterMount, // A disabled query reports `isPending` forever, so only a running check // counts as pending. isPending: isChecking && query.isPending, diff --git a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx index 943fc330756e..0d529260303c 100644 --- a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx +++ b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx @@ -30,23 +30,57 @@ const APPROVAL_ID = '11111111-1111-4111-8111-111111111111'; type Session = NonNullable; +/** + * The organization's Seer credits, which getsentry reads for the usage meter beside the send + * button and for the out-of-credits copy. + */ +interface Wallet { + autoReloadEnabled: boolean; + remaining: number; + total: number; +} + /** * Answers every query the chat window makes from fixtures, so the story never reaches the API. * Per-query options outrank client defaults, which is why this replaces `queryFn` after * defaulting rather than setting a default. Sending a message is a mutation and is not stubbed. */ class FixtureQueryClient extends QueryClient { - session: Session; + session: Session | null; + hasEnoughCredits: boolean; + wallet: Wallet | undefined; - constructor(session: Session) { + constructor(session: Session | null, hasEnoughCredits: boolean, wallet?: Wallet) { super({defaultOptions: {queries: {retry: false}}}); this.session = session; + this.hasEnoughCredits = hasEnoughCredits; + this.wallet = wallet; } respond(url: string): unknown { if (url.includes(`/seer/explorer-chat/${RUN_ID}/`)) { return {session: this.session} satisfies SeerExplorerResponse; } + if (url.includes('/seer/credit-check/')) { + return {hasEnoughCredits: this.hasEnoughCredits}; + } + if (this.wallet && url.includes('/seer-wallet/auto-top-up/')) { + return { + enabled: this.wallet.autoReloadEnabled, + thresholdCredits: 100, + topUpCredits: 500, + dailySpendCapCents: 5000, + }; + } + if (this.wallet && url.includes('/seer-wallet/')) { + const {remaining, total} = this.wallet; + return { + balance: remaining, + percentUsed: 1 - remaining / total, + included: {remaining, resetsAt: null, total}, + purchased: {remaining: 0, expiresAt: null, total: 0}, + }; + } // Run history, integrations and the like: every other query in the window is a list. return []; } @@ -62,21 +96,46 @@ class FixtureQueryClient extends QueryClient { const noopDispatch = () => {}; +interface ChatWindowProps { + /** A new chat when null. */ + session: Session | null; + /** + * Turns on `seer-credit-checks` and `token-billed-seer-enabled`, and answers + * the chat's credit check with this. + */ + hasEnoughCredits?: boolean; + initialQuery?: string; + /** Viewed by a billing member of an organization with these credits. */ + wallet?: Wallet; +} + /** * The whole Explorer chat window — header, transcript, pending-input blocks, and composer — as * the sidebar renders it, for a fixed run. */ -function ChatWindow({session}: {session: Session}) { +function ChatWindow({session, hasEnoughCredits, initialQuery, wallet}: ChatWindowProps) { const organization = useOrganization(); - const [queryClient] = useState(() => new FixtureQueryClient(session)); + const [queryClient] = useState( + () => new FixtureQueryClient(session, hasEnoughCredits ?? true, wallet) + ); return ( feature !== 'seer-chat-suggestions'), + 'seer-explorer', + ...(hasEnoughCredits === undefined + ? [] + : ['seer-credit-checks', 'token-billed-seer-enabled']), + ], }} > @@ -84,7 +143,7 @@ function ChatWindow({session}: {session: Session}) { persist the story's run over the viewer's own Explorer session. */} @@ -92,6 +151,8 @@ function ChatWindow({session}: {session: Session}) { '/issues/'} onClose={() => {}} + initialQuery={initialQuery} + appendInitialQuery={!!session} /> @@ -428,6 +489,59 @@ export default Storybook.story('ChatWindow', story => { )); + story('New chat', () => ( + +

+ A new chat for an organization with credits, to compare with the out-of-credits + stories below: the suggested questions show and the composer sends. Billing + members see how much of the organization's Seer credits are used next to the send + button; hover it for the details. Open source renders no meter; getsentry fills it + in. +

+ +
+ )); + + story('Out of credits in a new chat', () => ( + +

+ The organization can't afford another message and auto reload is off. The + out-of-credits CTA sits right above the composer, the usage meter turns + warning-colored, the suggested questions are gone, and nothing sends: not the send + button, Enter, or the /investigate command. Typing still works so a draft isn't + lost. A question forwarded from elsewhere in Sentry (here, the one in the + composer) waits there instead of being sent. Open source renders no CTA; getsentry + fills it in. +

+ +
+ )); + + story('Out of credits in a conversation', () => ( + +

+ The same gate partway through a conversation, for an organization that has used + every auto reload it allows. The transcript stays readable, the CTA sits above the + composer, and the follow-up in the composer can't be sent until someone tops up. +

+ +
+ )); + story('Awaiting an answer', () => (

diff --git a/static/app/views/seerExplorer/components/inputSection.spec.tsx b/static/app/views/seerExplorer/components/inputSection.spec.tsx index b02c1282e6b7..96017e58fb98 100644 --- a/static/app/views/seerExplorer/components/inputSection.spec.tsx +++ b/static/app/views/seerExplorer/components/inputSection.spec.tsx @@ -75,6 +75,71 @@ describe('InputSection', () => { expect(placeholderRules.at(-1)?.style.color).toBe(expectedStyle.color); }); + it('shows a notice above the composer', () => { + render(); + + const notice = screen.getByText('Out of credits'); + expect( + notice.compareDocumentPosition(screen.getByTestId('seer-explorer-input')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('leaves the notice out while a question replaces the composer', () => { + render( + + ); + + expect(screen.getByText('Asking some questions...')).toBeInTheDocument(); + expect(screen.queryByText('Out of credits')).not.toBeInTheDocument(); + }); + + it('shows toolbar items between the composer and the send button', () => { + render(); + + const item = screen.getByText('Toolbar item'); + expect( + screen.getByTestId('seer-explorer-input').compareDocumentPosition(item) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + item.compareDocumentPosition(screen.getByRole('button', {name: 'Send message'})) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('keeps toolbar items while Seer is responding', () => { + render( + + ); + + expect(screen.getByText('Toolbar item')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Interrupt button'})).toBeInTheDocument(); + }); + + it('leaves toolbar items out of a read-only conversation', () => { + render( + + ); + + expect(screen.queryByText('Toolbar item')).not.toBeInTheDocument(); + }); + describe('with ask-seer-create-pr', () => { const organization = OrganizationFixture({features: ['ask-seer-create-pr']}); diff --git a/static/app/views/seerExplorer/components/inputSection.tsx b/static/app/views/seerExplorer/components/inputSection.tsx index 46d5129c720f..30f3cb098135 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -7,7 +7,7 @@ import {motion} from 'framer-motion'; import {Button} from '@sentry/scraps/button'; import {Composer, type ComposerValue} from '@sentry/scraps/composer'; import {InputGroup} from '@sentry/scraps/input'; -import {Container, Flex, Grid} from '@sentry/scraps/layout'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {t} from 'sentry/locale'; @@ -49,8 +49,15 @@ interface InputSectionProps { fileApprovalActions?: FileApprovalActions; interruptState?: 'can-interrupt' | 'requested' | 'completed' | 'disabled'; isSingleRun?: boolean; + /** Shown right above the composer, for example to say why sending is blocked. */ + notice?: React.ReactNode; questionActions?: QuestionActions; runId?: SeerExplorerRunId | null; + /** + * Shown in the toolbar under the editor, before the send or interrupt button. + * The chat puts the organization's credit usage here. + */ + toolbarItems?: React.ReactNode; } export function InputSection({ @@ -67,9 +74,11 @@ export function InputSection({ repoPRStates, composerRef, fileApprovalActions, + notice, questionActions, runId, isSingleRun, + toolbarItems, }: InputSectionProps) { const organization = useOrganization({allowNull: true}); // Autofix single runs and ask-seer-create-pr runs open PRs, so show links to them. @@ -264,58 +273,91 @@ export function InputSection({ return ( {prLinksBar} - - - {interruptState === 'can-interrupt' || interruptState === 'requested' ? ( - + {}} + initialQuery={forwarded} + /> + + + ); + } + + render(, {organization: creditsOrganization}); + await screen.findByTestId('seer-explorer-out-of-credits'); + + // A billing member tops up before the next question is forwarded. + let answerCreditCheck!: () => void; + MockApiClient.addMockResponse({ + url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'agent_chat_message'})], + body: {hasEnoughCredits: true}, + asyncDelay: new Promise(resolve => { + answerCreditCheck = resolve; + }), + }); + await userEvent.click(screen.getByRole('button', {name: 'Forward a question'})); + + // The cached "out of credits" doesn't park the question in the composer. + expect(await getSeerExplorerInput()).not.toHaveTextContent('why is this slow?'); + expect(sendMessage).not.toHaveBeenCalled(); + + answerCreditCheck(); + + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith('why is this slow?', 0); + }); + }); + + it('disables retrying a timed out message', async () => { + mockCreditCheck(false); + const sendMessage = jest.fn(); + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + isTimedOut: true, + runId: 123, + sendMessage, + sessionData: { + blocks: [ + { + id: 'user-1', + message: {role: 'user', content: 'Timed out question'}, + timestamp: '2024-01-01T00:00:00Z', + }, + ], + status: 'error', + updated_at: '2024-01-01T00:01:00Z', + failure_reason: 'timeout', + }, + }); + + renderContent(); + + await screen.findByTestId('seer-explorer-out-of-credits'); + expect(screen.getByRole('button', {name: 'Retry'})).toBeDisabled(); + }); + + it('does not start an investigation', async () => { + mockCreditCheck(false); + HTMLElement.prototype.scrollIntoView = jest.fn(); + ProjectsStore.loadInitialData([ + ProjectFixture({id: '1', slug: 'member', isMember: true}), + ]); + PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: []})); + const createMock = MockApiClient.addMockResponse({ + url: `/organizations/${creditsOrganization.slug}/notebooks/`, + method: 'POST', + body: {id: '4567'}, + }); + + renderContent(); + + await screen.findByTestId('seer-explorer-out-of-credits'); + await userEvent.type(await getSeerExplorerInput(), '/investigate why?{Enter}'); + + expect(createMock).not.toHaveBeenCalled(); + expect(await getSeerExplorerInput()).toHaveTextContent('/investigate why?'); + }); + + it('sends as before when the organization has credits', async () => { + const creditCheck = mockCreditCheck(true); + const sendMessage = jest.fn(); + jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, sendMessage}); + + renderContent(); + + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + await userEvent.type(await getSeerExplorerInput(), 'Test message{Enter}'); + + expect(sendMessage).toHaveBeenCalledWith('Test message', 0); + expect( + screen.queryByTestId('seer-explorer-out-of-credits') + ).not.toBeInTheDocument(); + }); + + it('does not check credits without seer-credit-checks', async () => { + const creditCheck = mockCreditCheck(false); + const sendMessage = jest.fn(); + jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, sendMessage}); + + renderContent({org: organization}); + + await userEvent.type(await getSeerExplorerInput(), 'Test message{Enter}'); + + expect(sendMessage).toHaveBeenCalledWith('Test message', 0); + expect(creditCheck).not.toHaveBeenCalled(); + }); + + it('does not check credits on a read-only conversation', async () => { + const creditCheck = mockCreditCheck(false); + ConfigStore.set('user', UserFixture({id: '1'})); + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + sessionData: { + blocks: [], + status: 'completed', + updated_at: '2024-01-01T00:00:00Z', + owner_user_id: 2, + }, + }); + + renderContent(); + + await waitFor(() => + expect(screen.getByTestId('seer-explorer-input')).toBeDisabled() + ); + expect(creditCheck).not.toHaveBeenCalled(); + }); + + it('does not check credits while Seer waits on an answer', async () => { + const creditCheck = mockCreditCheck(false); + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + runId: 123, + sessionData: { + blocks: [ + { + id: 'msg-1', + message: {role: 'user', content: 'Which project?'}, + timestamp: '2024-01-01T00:00:00Z', + loading: false, + }, + ], + status: 'awaiting_user_input', + pending_user_input: { + id: 'input-1', + input_type: 'ask_user_question', + data: { + questions: [ + { + question: 'Which project should we focus on?', + options: [{label: 'sentry-unreal', description: 'Unreal SDK'}], + }, + ], + }, + }, + updated_at: '2024-01-01T00:01:00Z', + }, + }); + + renderContent(); + + await screen.findByText('Which project should we focus on?'); + expect(creditCheck).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('seer-explorer-out-of-credits') + ).not.toBeInTheDocument(); + }); + + describe('usage meter', () => { + beforeEach(() => { + // getsentry fills the slot in; open source renders nothing. + registerOverride( + 'component:seer-credit-usage-meter', + () => + function UsageMeter({hasEnoughCredits}) { + return ( +

+ {hasEnoughCredits ? 'Enough credits' : 'Not enough credits'} +
+ ); + } + ); + }); + + it('passes the chat credit check to the meter next to the send button', async () => { + const creditCheck = mockCreditCheck(true); + + renderContent(); + + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + const meter = screen.getByTestId('usage-meter'); + expect(meter).toHaveTextContent('Enough credits'); + expect( + meter.compareDocumentPosition( + screen.getByRole('button', {name: 'Send message'}) + ) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('tells the meter when the organization is out of credits', async () => { + mockCreditCheck(false); + + renderContent(); + + await screen.findByTestId('seer-explorer-out-of-credits'); + expect(screen.getByTestId('usage-meter')).toHaveTextContent('Not enough credits'); + }); + + it('leaves the meter out of a read-only conversation', async () => { + mockCreditCheck(true); + ConfigStore.set('user', UserFixture({id: '1'})); + jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + ...defaultHookReturn, + sessionData: { + blocks: [], + status: 'completed', + updated_at: '2024-01-01T00:00:00Z', + owner_user_id: 2, + }, + }); + + renderContent(); + + await waitFor(() => + expect(screen.getByTestId('seer-explorer-input')).toBeDisabled() + ); + expect(screen.queryByTestId('usage-meter')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 0e014f8cbca9..38749fb1719f 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -26,6 +26,8 @@ import { AutofixChatProvider, type SendMessageOptions, } from 'sentry/components/seer/autofixChatContext'; +import {SeerCreditUsageMeter} from 'sentry/components/seer/outOfCredits/seerCreditUsageMeter'; +import {SeerOutOfCreditsCta} from 'sentry/components/seer/outOfCredits/seerOutOfCreditsCta'; import {SEER_AGENTS_PROJECT_ID} from 'sentry/constants'; import {t} from 'sentry/locale'; import type {OrganizationIntegration} from 'sentry/types/integrations'; @@ -35,6 +37,7 @@ import { canManageIntegrations, integrationRequiresUpgrade, } from 'sentry/utils/integrationUtil'; +import {useSeerCreditCheck} from 'sentry/utils/seer/useSeerCreditCheck'; import {useDeferredSessionStorage} from 'sentry/utils/useDeferredSessionStorage'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; @@ -316,6 +319,65 @@ export function SeerExplorerContent({ const showLoadError = isEmptyState && (isError || hasSessionLoadError); const showEmptyState = isEmptyState && (!chatPrompt || showLoadError); + // - Pending user input (file approval + questions) ------------------------- + // The prompt itself renders in `PendingUserInputPicker`; the composer carries the question and + // diff controls. + const pendingUserInputState = usePendingUserInput({ + isAwaitingUserInput, + pendingInput, + respondToUserInput, + scrollContainerRef, + userScrolledUpRef, + }); + const { + isFileApprovalPending, + fileApprovalIndex, + fileApprovalTotalPatches, + handleFileApprovalApprove, + handleFileApprovalReject, + isQuestionPending, + questionIndex, + totalQuestions, + currentQuestion, + canSubmitQuestion, + handleQuestionNext, + handleQuestionBack, + handleQuestionMoveUp, + handleQuestionMoveDown, + isReauthPending, + reauthData, + handleReauthComplete, + } = pendingUserInputState; + + const showFileApprovalBar = + isFileApprovalPending && fileApprovalIndex < fileApprovalTotalPatches; + const showQuestionBar = isQuestionPending && !!currentQuestion; + + // - Credits ---------------------------------------------------------------- + // Checked only while the composer is up. A read-only or failed chat takes no + // message, and the approval and question bars replace the composer while Seer + // waits on an answer to a message it already has. + const isComposerShown = + !readOnly && !showLoadError && !showFileApprovalBar && !showQuestionBar; + const {hasEnoughCredits, hasCheckedSinceMount} = useSeerCreditCheck( + 'agent_chat_message', + {enabled: isComposerShown} + ); + const isOutOfCredits = !hasEnoughCredits; + // Each forward remounts the panel, so an answer cached by an earlier panel can + // be out of date: a billing member may have topped up since. Only this + // panel's own check counts. + const isCheckingCredits = !hasCheckedSinceMount; + // A question forwarded from elsewhere waits for that check (see the + // auto-submit below) and goes first. Until it does, the user's own sends wait + // too, or one would land ahead of it and the forwarded question would be + // dropped, or sent after it. + const isForwardedQueryWaiting = + isCheckingCredits && !!initialQuery?.trim() && (isEmptyState || !!appendInitialQuery); + // Gates every way this panel starts a message: the composer, suggestions, retry, + // `/investigate`, and the autofix embeds. + const isSendBlocked = isOutOfCredits || isForwardedQueryWaiting; + const suggestions = useChatSuggestions({ enabled: isEmptyState && !chatPrompt && !showLoadError && !isPolling && !readOnly, allowStructuredContext: true, @@ -386,7 +448,20 @@ export function SeerExplorerContent({ if (!isEmptyState && !appendInitialQuery) { return; } + // The panel opens with the check in flight; sending before it answers would skip it. + if (isCheckingCredits) { + return; + } lastAutoSubmittedQueryRef.current = query; + if (isOutOfCredits) { + // Keep the question for after a top-up, unless the user has a draft there already. + setInputValue(current => + (typeof current === 'string' ? current : current.text).trim() + ? current + : {text: query, mentions: []} + ); + return; + } if (showLoadError) { // The open run failed to load, so appending to it would post into a dead run. // A forwarded query still deserves an answer: start it in a fresh one. @@ -398,8 +473,11 @@ export function SeerExplorerContent({ initialQuery, appendInitialQuery, isEmptyState, + isCheckingCredits, + isOutOfCredits, showLoadError, sendMessage, + setInputValue, blocks.length, ]); @@ -416,36 +494,6 @@ export function SeerExplorerContent({ [sendMessage] ); - // - Pending user input (file approval + questions) ------------------------- - // The prompt itself renders in `PendingUserInputPicker`; the composer carries the question and - // diff controls. - const pendingUserInputState = usePendingUserInput({ - isAwaitingUserInput, - pendingInput, - respondToUserInput, - scrollContainerRef, - userScrolledUpRef, - }); - const { - isFileApprovalPending, - fileApprovalIndex, - fileApprovalTotalPatches, - handleFileApprovalApprove, - handleFileApprovalReject, - isQuestionPending, - questionIndex, - totalQuestions, - currentQuestion, - canSubmitQuestion, - handleQuestionNext, - handleQuestionBack, - handleQuestionMoveUp, - handleQuestionMoveDown, - isReauthPending, - reauthData, - handleReauthComplete, - } = pendingUserInputState; - const showReauth = isReauthPending && isReauthEnabled(organization); const requestErrorAlert = requestError ? ( @@ -580,8 +628,16 @@ export function SeerExplorerContent({ // - Input section handlers ------------------------------------------------- const canSendMessage = - !readOnly && !showLoadError && !isPolling && !!inputValue.text.trim(); + !readOnly && + !showLoadError && + !isPolling && + !isSendBlocked && + !!inputValue.text.trim(); const handleSend = useCallback(() => { + // Checked before `/investigate`, which starts a run of its own. The draft stays. + if (isSendBlocked) { + return; + } const text = inputValue.text.trim(); // `/investigate` runs here whether or not a question follows it; the menu // only completes it. It doesn't wait for Seer to finish responding. @@ -603,6 +659,7 @@ export function SeerExplorerContent({ userScrolledUpRef.current = false; }, [ canSendMessage, + isSendBlocked, inputValue, startInvestigation, sendMessage, @@ -647,7 +704,7 @@ export function SeerExplorerContent({ }, [startNewSession, focusInput, readOnly, showLoadError]); const handleRetry = useCallback(() => { - if (!retryTarget || readOnly) { + if (!retryTarget || readOnly || isSendBlocked) { return; } // Seer rebuilds the retried message from this request, so resend the question it answered. @@ -658,7 +715,7 @@ export function SeerExplorerContent({ retryTarget.chatPrompt ); userScrolledUpRef.current = false; - }, [readOnly, retryTarget, sendMessage]); + }, [readOnly, isSendBlocked, retryTarget, sendMessage]); // - Scroll effects --------------------------------------------------------- @@ -791,7 +848,7 @@ export function SeerExplorerContent({ return ( ) : ( @@ -855,6 +914,7 @@ export function SeerExplorerContent({ variant="secondary" icon={} onClick={handleRetry} + disabled={isSendBlocked} > {t('Retry')} @@ -885,6 +945,14 @@ export function SeerExplorerContent({ } inputValue={inputValue} canSendMessage={canSendMessage} + notice={ + isOutOfCredits ? ( + + + + ) : undefined + } + toolbarItems={} interruptState={interruptState} onInputChange={handleInputChange} onInputClick={handleInputClick} @@ -896,7 +964,7 @@ export function SeerExplorerContent({ isSingleRun={featureId === SINGLE_RUN_FEATURE_ID} composerRef={composerRef} fileApprovalActions={ - isFileApprovalPending && fileApprovalIndex < fileApprovalTotalPatches + showFileApprovalBar ? { currentIndex: fileApprovalIndex, totalPatches: fileApprovalTotalPatches, @@ -906,7 +974,7 @@ export function SeerExplorerContent({ : undefined } questionActions={ - isQuestionPending && currentQuestion + showQuestionBar ? { currentIndex: questionIndex, totalQuestions, diff --git a/static/gsApp/components/ai/seerCreditUsageMeter.spec.tsx b/static/gsApp/components/ai/seerCreditUsageMeter.spec.tsx new file mode 100644 index 000000000000..9bd07390c01e --- /dev/null +++ b/static/gsApp/components/ai/seerCreditUsageMeter.spec.tsx @@ -0,0 +1,239 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ThemeFixture} from 'sentry-fixture/theme'; + +import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import type {Organization} from 'sentry/types/organization'; + +import {SeerCreditUsageMeter} from './seerCreditUsageMeter'; + +const billingOrganization = OrganizationFixture({ + access: ['org:read', 'org:billing'], + features: ['seer-credit-checks', 'token-billed-seer-enabled'], +}); + +function mockWallet( + organization: Organization, + { + autoReload = false, + balance = 250, + percentUsed = 0.75, + included = 1000, + purchased = 0, + }: { + autoReload?: boolean; + balance?: number; + included?: number; + percentUsed?: number; + purchased?: number; + } = {} +) { + const wallet = MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/seer-wallet/`, + body: { + balance, + percentUsed, + included: {remaining: balance, resetsAt: null, total: included}, + purchased: {remaining: 0, expiresAt: null, total: purchased}, + creditPriceCents: 1, + }, + }); + const autoTopUp = MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/seer-wallet/auto-top-up/`, + body: { + enabled: autoReload, + thresholdCredits: 100, + topUpCredits: 500, + dailySpendCapCents: 5000, + }, + }); + return {wallet, autoTopUp}; +} + +// The bar is a `ProgressBar`, which draws its fill in `:before`. +function barFillColor() { + const bar = screen.getByRole('progressbar'); + const fill = Array.from(document.styleSheets) + .flatMap(sheet => Array.from(sheet.cssRules)) + .findLast( + (rule): rule is CSSStyleRule => + rule instanceof CSSStyleRule && + /::?before$/.test(rule.selectorText) && + bar.matches(rule.selectorText.replace(/::?before$/, '')) && + !!rule.style.backgroundColor + ); + return fill?.style.backgroundColor; +} + +function cssColor(color: string) { + const style = document.createElement('div').style; + style.backgroundColor = color; + return style.backgroundColor; +} + +describe('SeerCreditUsageMeter', () => { + const settingsUrl = `/settings/${billingOrganization.slug}/billing/overview/`; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('counts the month’s credits when auto reload is off', async () => { + mockWallet(billingOrganization); + + render(, { + organization: billingOrganization, + }); + + await userEvent.hover(await screen.findByText('75%')); + + expect( + await screen.findByText( + 'Your organization has used 75% of your available monthly credits.' + ) + ).toBeInTheDocument(); + expect(screen.getByText('250/1,000 credits remaining.')).toBeInTheDocument(); + // The out-of-credits alert, not the meter, is where auto reload gets turned on. + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('says credits reload automatically when auto reload is on', async () => { + mockWallet(billingOrganization, {autoReload: true}); + + render(, { + organization: billingOrganization, + }); + + await userEvent.hover(await screen.findByText('75%')); + + expect( + await screen.findByText('Your organization has used 75% of your available credits.') + ).toBeInTheDocument(); + expect(screen.getByText('Credits will reload automatically.')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('links to auto reload when it has no top-ups left', async () => { + mockWallet(billingOrganization, {autoReload: true, balance: 50, percentUsed: 0.95}); + + render(, { + organization: billingOrganization, + }); + + await userEvent.hover(await screen.findByText('95%')); + + expect( + await screen.findByText(/You do not have any more reloads available\./) + ).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'Configure auto-reload.'})).toHaveAttribute( + 'href', + settingsUrl + ); + expect( + screen.queryByText('Credits will reload automatically.') + ).not.toBeInTheDocument(); + }); + + it('reloads the wallet when the window regains focus', async () => { + const {wallet, autoTopUp} = mockWallet(billingOrganization); + + render(, { + organization: billingOrganization, + }); + + expect(await screen.findByText('75%')).toBeInTheDocument(); + expect(wallet).toHaveBeenCalledTimes(1); + expect(autoTopUp).toHaveBeenCalledTimes(1); + + // A top-up in another tab. + mockWallet(billingOrganization, {balance: 500, percentUsed: 0.5}); + act(() => { + window.dispatchEvent(new Event('visibilitychange')); + }); + + expect(await screen.findByText('50%')).toBeInTheDocument(); + }); + + it('rounds the percent and counts purchased credits in the total', async () => { + mockWallet(billingOrganization, { + balance: 1250, + percentUsed: 0.8754, + included: 8000, + purchased: 2000, + }); + + render(, { + organization: billingOrganization, + }); + + await userEvent.hover(await screen.findByText('88%')); + + expect( + await screen.findByText('1,250/10,000 credits remaining.') + ).toBeInTheDocument(); + }); + + it.each([ + ['below 90% used', 0.89, true, 'neutral'], + ['at 90% used', 0.9, true, 'warning'], + ['when the organization cannot afford a message', 0.5, false, 'warning'], + ] as const)('colors the bar %s', async (_, percentUsed, hasEnoughCredits, color) => { + mockWallet(billingOrganization, {percentUsed}); + + render(, { + organization: billingOrganization, + }); + + await screen.findByRole('progressbar'); + const {graphics} = ThemeFixture().tokens; + expect(barFillColor()).toBe( + cssColor(color === 'warning' ? graphics.warning.vibrant : graphics.neutral.moderate) + ); + }); + + it('shows nothing to members without billing access', () => { + const organization = OrganizationFixture({ + access: ['org:read'], + features: ['seer-credit-checks', 'token-billed-seer-enabled'], + }); + const {wallet, autoTopUp} = mockWallet(organization); + + render(, {organization}); + + expect(screen.queryByTestId('seer-credit-usage-meter')).not.toBeInTheDocument(); + expect(wallet).not.toHaveBeenCalled(); + expect(autoTopUp).not.toHaveBeenCalled(); + }); + + it('shows nothing without seer-credit-checks', () => { + const organization = OrganizationFixture({access: ['org:read', 'org:billing']}); + const {wallet, autoTopUp} = mockWallet(organization); + + render(, {organization}); + + expect(screen.queryByTestId('seer-credit-usage-meter')).not.toBeInTheDocument(); + expect(wallet).not.toHaveBeenCalled(); + expect(autoTopUp).not.toHaveBeenCalled(); + }); + + it('shows nothing when the organization has no Seer wallet', async () => { + const wallet = MockApiClient.addMockResponse({ + url: `/customers/${billingOrganization.slug}/seer-wallet/`, + statusCode: 404, + body: {detail: 'Not found'}, + }); + const autoTopUp = MockApiClient.addMockResponse({ + url: `/customers/${billingOrganization.slug}/seer-wallet/auto-top-up/`, + statusCode: 404, + body: {detail: 'Not found'}, + }); + + render(, { + organization: billingOrganization, + }); + + await waitFor(() => expect(wallet).toHaveBeenCalledTimes(1)); + expect(autoTopUp).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('seer-credit-usage-meter')).not.toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/components/ai/seerCreditUsageMeter.stories.tsx b/static/gsApp/components/ai/seerCreditUsageMeter.stories.tsx new file mode 100644 index 000000000000..41d2b7c34e23 --- /dev/null +++ b/static/gsApp/components/ai/seerCreditUsageMeter.stories.tsx @@ -0,0 +1,188 @@ +import {Fragment} from 'react'; +import {IconArrow} from '@sentry/icons/arrow'; + +import {Button} from '@sentry/scraps/button'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import * as Storybook from 'sentry/stories'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {SeerCreditUsageMeterView} from './seerCreditUsageMeter'; + +/** + * The meter beside the send button in the chat composer's toolbar. + */ +function ComposerToolbar({children}: {children: React.ReactNode}) { + return ( + + {children} +