From 306f90fee4b1e022c056ac6c90f610a413cbb597 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 2 Nov 2026 16:04:11 -0500 Subject: [PATCH 01/23] 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 804dcd0522b1..353bbe6b2ae9 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 79c24f2e020e..7b91bcb740e3 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -48,6 +48,8 @@ interface InputSectionProps { disabledPlaceholder?: string; fileApprovalActions?: FileApprovalActions; interruptState?: 'can-interrupt' | 'requested' | 'completed' | 'disabled'; + /** Shown right above the composer, for example to say why sending is blocked. */ + notice?: React.ReactNode; questionActions?: QuestionActions; runId?: SeerExplorerRunId | null; } @@ -66,6 +68,7 @@ export function InputSection({ repoPRStates, composerRef, fileApprovalActions, + notice, questionActions, runId, }: InputSectionProps) { @@ -261,6 +264,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 c262a9ce15ba..99898653ff03 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -25,6 +25,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'; @@ -34,6 +35,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'; @@ -314,6 +316,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, @@ -384,7 +434,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. @@ -396,8 +459,11 @@ export function SeerExplorerContent({ initialQuery, appendInitialQuery, isEmptyState, + isCheckingCredits, + isOutOfCredits, showLoadError, sendMessage, + setInputValue, blocks.length, ]); @@ -414,36 +480,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 ? ( @@ -578,8 +614,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. @@ -601,6 +645,7 @@ export function SeerExplorerContent({ userScrolledUpRef.current = false; }, [ canSendMessage, + isOutOfCredits, inputValue, startInvestigation, sendMessage, @@ -645,7 +690,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. @@ -656,7 +701,7 @@ export function SeerExplorerContent({ retryTarget.chatPrompt ); userScrolledUpRef.current = false; - }, [readOnly, retryTarget, sendMessage]); + }, [readOnly, isOutOfCredits, retryTarget, sendMessage]); // - Scroll effects --------------------------------------------------------- @@ -789,7 +834,7 @@ export function SeerExplorerContent({ return ( ) : ( @@ -853,6 +900,7 @@ export function SeerExplorerContent({ variant="secondary" icon={} onClick={handleRetry} + disabled={isOutOfCredits} > {t('Retry')} @@ -883,6 +931,13 @@ export function SeerExplorerContent({ } inputValue={inputValue} canSendMessage={canSendMessage} + notice={ + isOutOfCredits ? ( + + + + ) : undefined + } interruptState={interruptState} onInputChange={handleInputChange} onInputClick={handleInputClick} @@ -893,7 +948,7 @@ export function SeerExplorerContent({ runId={runId} composerRef={composerRef} fileApprovalActions={ - isFileApprovalPending && fileApprovalIndex < fileApprovalTotalPatches + showFileApprovalBar ? { currentIndex: fileApprovalIndex, totalPatches: fileApprovalTotalPatches, @@ -903,7 +958,7 @@ export function SeerExplorerContent({ : undefined } questionActions={ - isQuestionPending && currentQuestion + showQuestionBar ? { currentIndex: questionIndex, totalQuestions, From 6cec02793c21b75a5c0a629b593ee4ba1c36d887 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Nov 2026 15:30:06 -0500 Subject: [PATCH 02/23] 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 353bbe6b2ae9..18571e89e53a 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 7b91bcb740e3..f7168a39d966 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -52,6 +52,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({ @@ -71,6 +73,7 @@ export function InputSection({ notice, questionActions, runId, + usageMeter, }: InputSectionProps) { const organization = useOrganization({allowNull: true}); // With ask-seer-create-pr the agent opens PRs, so show links to them. @@ -284,29 +287,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. From 572db2a3cbe7ca28bdb4e8d966c2c40497c35085 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 9 Nov 2026 11:01:16 -0800 Subject: [PATCH 22/23] fix(seer): Let the credit recheck explain a send refused for credits A 402 on send no longer shows a CTA in place of the send error. The error is hidden and the recheck brings up the out-of-credits notice above the composer, which is the only out-of-credits UI the designs have. This also stops that CTA from coming back after a top-up. Refs CW-2332 --- .../components/seerExplorerContent.spec.tsx | 12 +++------- .../components/seerExplorerContent.tsx | 23 ++++++++----------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx index 432c76c337d4..e9848014a339 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx @@ -2370,18 +2370,16 @@ describe('SeerExplorerContent', () => { ).toBeInTheDocument(); }); - it('shows the CTA instead of the send error when a send is refused for credits', async () => { + it('shows no send error when a send is refused for credits', async () => { jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ ...defaultHookReturn, requestError: {query: 'Why is this slow?', isOutOfCredits: true}, }); - mockCreditCheck(true); + const creditCheck = mockCreditCheck(true); renderContent(); - expect( - await screen.findByTestId('seer-explorer-send-out-of-credits') - ).toBeInTheDocument(); + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); expect( screen.queryByText( 'There was an error sending your message, wait and try again.' @@ -2404,13 +2402,9 @@ describe('SeerExplorerContent', () => { }); rerender(tree()); - // The CTA above the composer replaces the one in the error's place. expect( await screen.findByTestId('seer-explorer-out-of-credits') ).toBeInTheDocument(); - expect( - screen.queryByTestId('seer-explorer-send-out-of-credits') - ).not.toBeInTheDocument(); }); }); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 626251863859..93ab5c4be4b1 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -508,21 +508,16 @@ export function SeerExplorerContent({ const showReauth = isReauthPending && isReauthEnabled(organization); - // A send refused for credits gets the credits notice, not the generic error. Once - // the recheck agrees, the notice above the composer covers it. - const requestErrorAlert = requestError?.isOutOfCredits ? ( - isOutOfCredits ? null : ( - - + // A send refused for credits shows no error of its own. The recheck brings up the + // credits notice above the composer instead. + const requestErrorAlert = + requestError && !requestError.isOutOfCredits ? ( + + + {t('There was an error sending your message, wait and try again.')} + - ) - ) : requestError ? ( - - - {t('There was an error sending your message, wait and try again.')} - - - ) : null; + ) : null; // - Topbar, menu, and slash command handlers ------------------------------- const copySessionEnabled = runId !== null && !!organization?.slug; From 90a2f145d63a7bbd96a027429e3938fea5a86209 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Mon, 9 Nov 2026 11:15:35 -0800 Subject: [PATCH 23/23] fix(seer): Recheck credits for every send refused for credits A new send doesn't clear the last request error, so after the first 402 later refusals never rechecked, including after a recheck that failed open. Recheck on each refused send instead. Also fold the hidden-error test into the recheck test and fix two stale comments. Refs CW-2332 --- .../components/seerExplorerContent.spec.tsx | 36 ++++++++++++------- .../components/seerExplorerContent.tsx | 16 ++++----- .../seerExplorer/hooks/useSeerExplorer.tsx | 3 +- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx index 2cf18b0933d1..9a676bab676f 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx @@ -2425,16 +2425,24 @@ describe('SeerExplorerContent', () => { ).toBeInTheDocument(); }); - it('shows no send error when a send is refused for credits', async () => { - jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({ + it('rechecks credits when a send is refused for credits', async () => { + const useSeerExplorerSpy = jest + .spyOn(useSeerExplorerModule, 'useSeerExplorer') + .mockReturnValue(defaultHookReturn); + const creditCheck = mockCreditCheck(true); + const {rerender} = render(tree(), {organization: creditsOrganization}); + await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + + mockCreditCheck(false); + useSeerExplorerSpy.mockReturnValue({ ...defaultHookReturn, requestError: {query: 'Why is this slow?', isOutOfCredits: true}, }); - const creditCheck = mockCreditCheck(true); - - renderContent(); + rerender(tree()); - await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + expect( + await screen.findByTestId('seer-explorer-out-of-credits') + ).toBeInTheDocument(); expect( screen.queryByText( 'There was an error sending your message, wait and try again.' @@ -2442,24 +2450,28 @@ describe('SeerExplorerContent', () => { ).not.toBeInTheDocument(); }); - it('rechecks credits when a send is refused for credits', async () => { + it('rechecks credits for each send refused for credits', async () => { const useSeerExplorerSpy = jest .spyOn(useSeerExplorerModule, 'useSeerExplorer') .mockReturnValue(defaultHookReturn); + // The check keeps saying there are credits, so sending stays possible. const creditCheck = mockCreditCheck(true); const {rerender} = render(tree(), {organization: creditsOrganization}); - await waitFor(() => expect(creditCheck).toHaveBeenCalled()); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(1)); - mockCreditCheck(false); useSeerExplorerSpy.mockReturnValue({ ...defaultHookReturn, requestError: {query: 'Why is this slow?', isOutOfCredits: true}, }); rerender(tree()); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(2)); - expect( - await screen.findByTestId('seer-explorer-out-of-credits') - ).toBeInTheDocument(); + useSeerExplorerSpy.mockReturnValue({ + ...defaultHookReturn, + requestError: {query: 'Why is this slow?', isOutOfCredits: true}, + }); + rerender(tree()); + await waitFor(() => expect(creditCheck).toHaveBeenCalledTimes(3)); }); }); diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 07b444ed48b4..f05e14165f99 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -372,20 +372,18 @@ export function SeerExplorerContent({ // be out of date: a billing member may have topped up since. Only this // panel's own check counts. const isCheckingCredits = !hasCheckedSinceMount; - // Seer stopped a reply, or refused a send, for credits. Recheck so the notice - // replaces the composer instead of the next send failing too. Only on the change, - // since opening a run like this already runs the check. - const hitOutOfCredits = - !!requestError?.isOutOfCredits || sessionData?.failure_reason === 'out_of_credits'; - const prevHitOutOfCreditsRef = useRef(hitOutOfCredits); + // Seer stopped a reply, or the backend refused a send, for credits. Recheck so the + // notice shows and sending is disabled. Every refused send rechecks, since an + // earlier recheck may have failed open or the balance may have moved since. + const refusedForCredits = requestError?.isOutOfCredits ? requestError : null; + const isRunOutOfCredits = sessionData?.failure_reason === 'out_of_credits'; useEffect(() => { - if (organization && hitOutOfCredits && !prevHitOutOfCreditsRef.current) { + if (organization && (refusedForCredits || isRunOutOfCredits)) { queryClient.invalidateQueries({ queryKey: seerCreditCheckApiOptions(organization, 'agent_chat_message').queryKey, }); } - prevHitOutOfCreditsRef.current = hitOutOfCredits; - }, [hitOutOfCredits, organization, queryClient]); + }, [refusedForCredits, isRunOutOfCredits, organization, queryClient]); // 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 diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index ef9f75a80f3a..c891c0e787aa 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -155,7 +155,8 @@ export const useSeerExplorer = () => { // Set when the last chat message or user-input response failed, so the UI can show an // alert until a later request succeeds. `query` is the failed chat message, if any, // so the draft can be restored, and `chatPrompt` the question it answered. - // `isOutOfCredits` marks a message refused for Seer credits. + // `isOutOfCredits` marks a message refused for Seer credits, which gets the credits + // notice instead of that alert. const [requestError, setRequestError] = useState<{ runId: SeerExplorerRunId | null; chatPrompt?: ChatPrompt;