From f346c2344aee52305018733bf039761d4d756030 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 2 Nov 2026 16:04:11 -0500 Subject: [PATCH 01/11] feat(seer): Gate agent chat on Seer credits When the org can't afford another chat message, the Seer chat shows the out-of-credits CTA right above the composer and nothing sends: the send button and Enter, suggested questions, timeout retry, /investigate, the autofix embeds' chat actions, and questions forwarded from elsewhere, which wait in the composer instead. Typing stays enabled so the draft survives a top-up. The check only runs while the composer is on screen, and does nothing for orgs without seer-credit-checks. Fixes CW-2332 Co-Authored-By: Claude Opus 5.5 --- .../seer/autofixChatContext.spec.tsx | 21 ++ .../components/chat/chatWindow.stories.tsx | 78 +++++- .../components/inputSection.spec.tsx | 32 +++ .../seerExplorer/components/inputSection.tsx | 4 + .../components/seerExplorerContent.spec.tsx | 246 ++++++++++++++++++ .../components/seerExplorerContent.tsx | 129 ++++++--- 6 files changed, 467 insertions(+), 43 deletions(-) diff --git a/static/app/components/seer/autofixChatContext.spec.tsx b/static/app/components/seer/autofixChatContext.spec.tsx index 531e276078a8..ca1427f5e097 100644 --- a/static/app/components/seer/autofixChatContext.spec.tsx +++ b/static/app/components/seer/autofixChatContext.spec.tsx @@ -218,6 +218,27 @@ 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'], + }); + 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/views/seerExplorer/components/chat/chatWindow.stories.tsx b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx index 943fc330756e..bb32675d349b 100644 --- a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx +++ b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx @@ -36,17 +36,22 @@ type Session = NonNullable; * 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; - constructor(session: Session) { + constructor(session: Session | null, hasEnoughCredits: boolean) { super({defaultOptions: {queries: {retry: false}}}); this.session = session; + this.hasEnoughCredits = hasEnoughCredits; } 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}; + } // Run history, integrations and the like: every other query in the window is a list. return []; } @@ -62,13 +67,23 @@ class FixtureQueryClient extends QueryClient { const noopDispatch = () => {}; +interface ChatWindowProps { + /** A new chat when null. */ + session: Session | null; + /** Turns on `seer-credit-checks` and answers the chat's credit check with this. */ + hasEnoughCredits?: boolean; + initialQuery?: string; +} + /** * 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}: ChatWindowProps) { const organization = useOrganization(); - const [queryClient] = useState(() => new FixtureQueryClient(session)); + const [queryClient] = useState( + () => new FixtureQueryClient(session, hasEnoughCredits ?? true) + ); return ( feature !== 'seer-chat-suggestions'), + 'seer-explorer', + ...(hasEnoughCredits === undefined ? [] : ['seer-credit-checks']), + ], }} > @@ -84,7 +105,7 @@ function ChatWindow({session}: {session: Session}) { persist the story's run over the viewer's own Explorer session. */} @@ -92,6 +113,8 @@ function ChatWindow({session}: {session: Session}) { '/issues/'} onClose={() => {}} + initialQuery={initialQuery} + appendInitialQuery={!!session} /> @@ -428,6 +451,49 @@ 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. +

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

+ The organization can't afford another message. The out-of-credits CTA sits right + above the composer, 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. 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..d694ce4a5013 100644 --- a/static/app/views/seerExplorer/components/inputSection.spec.tsx +++ b/static/app/views/seerExplorer/components/inputSection.spec.tsx @@ -75,6 +75,38 @@ 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(); + }); + 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..de4d45e7a3c0 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -49,6 +49,8 @@ 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; } @@ -67,6 +69,7 @@ export function InputSection({ repoPRStates, composerRef, fileApprovalActions, + notice, questionActions, runId, isSingleRun, @@ -264,6 +267,7 @@ export function InputSection({ return ( {prLinksBar} + {notice ? {notice} : null} { expect(sendMessage).toHaveBeenCalledWith('/investigate why?', 0); }); }); + + describe('Out of credits', () => { + const creditsOrganization = OrganizationFixture({ + openMembership: true, + features: ['seer-explorer', 'seer-credit-checks', 'investigations'], + hideAiFeatures: false, + }); + + function mockCreditCheck(hasEnoughCredits: boolean) { + return MockApiClient.addMockResponse({ + url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`, + match: [MockApiClient.matchQuery({operation: 'agent_chat_message'})], + body: {hasEnoughCredits}, + }); + } + + function renderContent({ + org = creditsOrganization, + initialQuery, + }: {initialQuery?: string; org?: typeof creditsOrganization} = {}) { + return render( + + + {}} + initialQuery={initialQuery} + /> + + , + {organization: org} + ); + } + + it('shows the CTA above the composer and keeps the draft instead of sending', async () => { + mockCreditCheck(false); + const sendMessage = jest.fn(); + jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, sendMessage}); + + renderContent(); + + const cta = await screen.findByTestId('seer-explorer-out-of-credits'); + const textarea = await getSeerExplorerInput(); + expect( + cta.compareDocumentPosition(textarea) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + await userEvent.type(textarea, 'Test message'); + expect(screen.getByRole('button', {name: 'Send message'})).toBeDisabled(); + await userEvent.keyboard('{Enter}'); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(textarea).toHaveTextContent('Test message'); + }); + + it('hides the suggested questions', async () => { + mockCreditCheck(false); + + renderContent(); + + await screen.findByTestId('seer-explorer-out-of-credits'); + expect( + screen.queryByRole('button', { + name: 'Which of my open issues are getting worse, not better?', + }) + ).not.toBeInTheDocument(); + }); + + it('puts a forwarded question in the composer instead of sending it', async () => { + mockCreditCheck(false); + const sendMessage = jest.fn(); + jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, sendMessage}); + + renderContent({initialQuery: 'why is this slow?'}); + + await screen.findByTestId('seer-explorer-out-of-credits'); + expect(await getSeerExplorerInput()).toHaveTextContent('why is this slow?'); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('sends a forwarded question once the check says there are credits', async () => { + const creditCheck = mockCreditCheck(true); + const sendMessage = jest.fn(); + jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue({...defaultHookReturn, sendMessage}); + + renderContent({initialQuery: 'why is this slow?'}); + + await waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith('why is this slow?', 0); + }); + expect(creditCheck).toHaveBeenCalledTimes(1); + expect( + screen.queryByTestId('seer-explorer-out-of-credits') + ).not.toBeInTheDocument(); + }); + + 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(); + }); + }); }); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 0e014f8cbca9..defecd2963a2 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -26,6 +26,7 @@ import { AutofixChatProvider, type SendMessageOptions, } from 'sentry/components/seer/autofixChatContext'; +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 +36,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 +318,54 @@ 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, isPending: isCheckingCredits} = useSeerCreditCheck( + 'agent_chat_message', + {enabled: isComposerShown} + ); + // Gates every way this panel starts a message: the composer, suggestions, retry, + // `/investigate`, the autofix embeds, and a question forwarded from elsewhere. + const isOutOfCredits = !hasEnoughCredits; + const suggestions = useChatSuggestions({ enabled: isEmptyState && !chatPrompt && !showLoadError && !isPolling && !readOnly, allowStructuredContext: true, @@ -386,7 +436,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 +461,11 @@ export function SeerExplorerContent({ initialQuery, appendInitialQuery, isEmptyState, + isCheckingCredits, + isOutOfCredits, showLoadError, sendMessage, + setInputValue, blocks.length, ]); @@ -416,36 +482,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 +616,16 @@ export function SeerExplorerContent({ // - Input section handlers ------------------------------------------------- const canSendMessage = - !readOnly && !showLoadError && !isPolling && !!inputValue.text.trim(); + !readOnly && + !showLoadError && + !isPolling && + !isOutOfCredits && + !!inputValue.text.trim(); const handleSend = useCallback(() => { + // Checked before `/investigate`, which starts a run of its own. The draft stays. + if (isOutOfCredits) { + 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 +647,7 @@ export function SeerExplorerContent({ userScrolledUpRef.current = false; }, [ canSendMessage, + isOutOfCredits, inputValue, startInvestigation, sendMessage, @@ -647,7 +692,7 @@ export function SeerExplorerContent({ }, [startNewSession, focusInput, readOnly, showLoadError]); const handleRetry = useCallback(() => { - if (!retryTarget || readOnly) { + if (!retryTarget || readOnly || isOutOfCredits) { return; } // Seer rebuilds the retried message from this request, so resend the question it answered. @@ -658,7 +703,7 @@ export function SeerExplorerContent({ retryTarget.chatPrompt ); userScrolledUpRef.current = false; - }, [readOnly, retryTarget, sendMessage]); + }, [readOnly, isOutOfCredits, retryTarget, sendMessage]); // - Scroll effects --------------------------------------------------------- @@ -791,7 +836,7 @@ export function SeerExplorerContent({ return ( ) : ( @@ -855,6 +902,7 @@ export function SeerExplorerContent({ variant="secondary" icon={} onClick={handleRetry} + disabled={isOutOfCredits} > {t('Retry')} @@ -885,6 +933,13 @@ export function SeerExplorerContent({ } inputValue={inputValue} canSendMessage={canSendMessage} + notice={ + isOutOfCredits ? ( + + + + ) : undefined + } interruptState={interruptState} onInputChange={handleInputChange} onInputClick={handleInputClick} @@ -896,7 +951,7 @@ export function SeerExplorerContent({ isSingleRun={featureId === SINGLE_RUN_FEATURE_ID} composerRef={composerRef} fileApprovalActions={ - isFileApprovalPending && fileApprovalIndex < fileApprovalTotalPatches + showFileApprovalBar ? { currentIndex: fileApprovalIndex, totalPatches: fileApprovalTotalPatches, @@ -906,7 +961,7 @@ export function SeerExplorerContent({ : undefined } questionActions={ - isQuestionPending && currentQuestion + showQuestionBar ? { currentIndex: questionIndex, totalQuestions, From 3b5018e3a7af890bc63806771e10553925c23c09 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 15:30:06 -0500 Subject: [PATCH 02/11] feat(seer): Show Seer credit usage next to the chat send button Billing members of orgs with seer-credit-checks now see how much of the org's Seer credits is used, next to the chat's send button. Hovering shows the balance and the auto reload state, using the billing gates copy. The meter is a getsentry override slot. It renders nothing in open source, or when the wallet doesn't load. The out-of-credits alert above the composer now passes reason="agent-chat" to the reworked CTA. Refs CW-2332 Co-Authored-By: Claude Opus 5.5 --- .../outOfCredits/seerCreditUsageMeter.tsx | 21 ++ static/app/types/overrides.tsx | 2 + .../components/chat/chatWindow.stories.tsx | 67 +++++- .../components/inputSection.spec.tsx | 31 +++ .../seerExplorer/components/inputSection.tsx | 53 +++-- .../components/seerExplorerContent.spec.tsx | 63 +++++ .../components/seerExplorerContent.tsx | 4 +- .../ai/seerCreditUsageMeter.spec.tsx | 220 ++++++++++++++++++ .../ai/seerCreditUsageMeter.stories.tsx | 176 ++++++++++++++ .../components/ai/seerCreditUsageMeter.tsx | 187 +++++++++++++++ static/gsApp/registerOverrides.tsx | 2 + 11 files changed, 790 insertions(+), 36 deletions(-) create mode 100644 static/app/components/seer/outOfCredits/seerCreditUsageMeter.tsx create mode 100644 static/gsApp/components/ai/seerCreditUsageMeter.spec.tsx create mode 100644 static/gsApp/components/ai/seerCreditUsageMeter.stories.tsx create mode 100644 static/gsApp/components/ai/seerCreditUsageMeter.tsx 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/views/seerExplorer/components/chat/chatWindow.stories.tsx b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx index bb32675d349b..8a9e1d1a65ca 100644 --- a/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx +++ b/static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx @@ -30,6 +30,16 @@ 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 @@ -38,11 +48,13 @@ type Session = NonNullable; class FixtureQueryClient extends QueryClient { session: Session | null; hasEnoughCredits: boolean; + wallet: Wallet | undefined; - constructor(session: Session | null, hasEnoughCredits: boolean) { + 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 { @@ -52,6 +64,23 @@ class FixtureQueryClient extends QueryClient { 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 []; } @@ -73,22 +102,26 @@ interface ChatWindowProps { /** Turns on `seer-credit-checks` 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, hasEnoughCredits, initialQuery}: ChatWindowProps) { +function ChatWindow({session, hasEnoughCredits, initialQuery, wallet}: ChatWindowProps) { const organization = useOrganization(); const [queryClient] = useState( - () => new FixtureQueryClient(session, hasEnoughCredits ?? true) + () => new FixtureQueryClient(session, hasEnoughCredits ?? true, wallet) ); return ( {

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. + 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. The out-of-credits CTA sits right - above the composer, 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 + 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.

@@ -475,6 +516,7 @@ export default Storybook.story('ChatWindow', story => { session={null} hasEnoughCredits={false} initialQuery="Why is checkout slow since this morning?" + wallet={{autoReloadEnabled: false, remaining: 50, total: 1000}} />
)); @@ -482,14 +524,15 @@ export default Storybook.story('ChatWindow', story => { story('Out of credits in a conversation', () => (

- The same gate partway through a conversation. 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. + 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.

)); diff --git a/static/app/views/seerExplorer/components/inputSection.spec.tsx b/static/app/views/seerExplorer/components/inputSection.spec.tsx index d694ce4a5013..e3e62a28bcd1 100644 --- a/static/app/views/seerExplorer/components/inputSection.spec.tsx +++ b/static/app/views/seerExplorer/components/inputSection.spec.tsx @@ -107,6 +107,37 @@ describe('InputSection', () => { expect(screen.queryByText('Out of credits')).not.toBeInTheDocument(); }); + it('shows the usage meter between the composer and the send button', () => { + render(); + + const meter = screen.getByText('Credit usage'); + expect( + screen.getByTestId('seer-explorer-input').compareDocumentPosition(meter) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + meter.compareDocumentPosition(screen.getByRole('button', {name: 'Send message'})) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('keeps the usage meter while Seer is responding', () => { + render( + + ); + + expect(screen.getByText('Credit usage')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Interrupt button'})).toBeInTheDocument(); + }); + + it('leaves the usage meter out of a read-only conversation', () => { + render(); + + expect(screen.queryByText('Credit usage')).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 de4d45e7a3c0..ec2205e53be3 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -53,6 +53,8 @@ interface InputSectionProps { notice?: React.ReactNode; questionActions?: QuestionActions; runId?: SeerExplorerRunId | null; + /** Shown next to the send button, for example the organization's credit usage. */ + usageMeter?: React.ReactNode; } export function InputSection({ @@ -73,6 +75,7 @@ export function InputSection({ questionActions, runId, isSingleRun, + usageMeter, }: InputSectionProps) { const organization = useOrganization({allowNull: true}); // Autofix single runs and ask-seer-create-pr runs open PRs, so show links to them. @@ -287,29 +290,33 @@ export function InputSection({ size="md" data-test-id="seer-explorer-input" /> - {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(); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 005ca30c7c08..38749fb1719f 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -359,12 +359,16 @@ export function SeerExplorerContent({ // waits on an answer to a message it already has. const isComposerShown = !readOnly && !showLoadError && !showFileApprovalBar && !showQuestionBar; - const {hasEnoughCredits, isPending: isCheckingCredits} = useSeerCreditCheck( + const {hasEnoughCredits, hasCheckedSinceMount} = useSeerCreditCheck( 'agent_chat_message', {enabled: isComposerShown} ); const isOutOfCredits = !hasEnoughCredits; - // A question forwarded from elsewhere waits for the first check (see the + // 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.