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' ? (
- }
- onClick={onInterrupt}
+
+ {notice}
+ {/* The editor and its toolbar read as one field, so the focus ring wraps both. */}
+
+
- ) : (
- }
- onClick={onSend}
- size="md"
- variant="secondary"
- disabled={!canSendMessage}
- aria-label={t('Send message')}
- />
- )}
-
+
+ {toolbarItems}
+ {interruptState === 'can-interrupt' || interruptState === 'requested' ? (
+ }
+ onClick={onInterrupt}
+ size="zero"
+ variant="primary"
+ disabled={interruptState === 'requested'}
+ aria-label={t('Interrupt button')}
+ tooltipProps={{
+ title:
+ interruptState === 'requested'
+ ? t('Winding down...')
+ : t('Interrupt'),
+ }}
+ />
+ ) : (
+ }
+ onClick={onSend}
+ size="zero"
+ variant="secondary"
+ disabled={!canSendMessage}
+ aria-label={t('Send message')}
+ />
+ )}
+
+
+
);
}
+const ComposerFrame = styled(Stack)`
+ transition: box-shadow ${p => p.theme.motion.smooth.fast};
+
+ &:focus-within {
+ ${p => p.theme.focusRing()};
+ }
+`;
+
const StyledComposer = styled(Composer, {
shouldForwardProp: prop => prop !== 'isWarningPlaceholder',
})<{isWarningPlaceholder: boolean}>`
- flex: 1;
max-height: 120px;
+ /* The frame draws the border and the focus ring around the editor and toolbar. */
+ border: none;
+ border-radius: ${p => p.theme.radius.lg} ${p => p.theme.radius.lg} 0 0;
+
+ &:focus,
+ &:focus-visible {
+ box-shadow: 0 1px 0 0 ${p => p.theme.tokens.interactive.chonky.debossed.neutral.chonk}
+ inset;
+ }
&:empty::before {
color: ${p =>
diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx
index 08613d1226dc..cd32cd2ce056 100644
--- a/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx
+++ b/static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx
@@ -1,3 +1,4 @@
+import {useState} from 'react';
import {OrganizationFixture} from 'sentry-fixture/organization';
import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
import {PageFiltersFixture} from 'sentry-fixture/pageFilters';
@@ -16,6 +17,7 @@ import {
import {PictureInPictureProvider} from '@sentry/scraps/pictureInPicture';
import {PageFiltersStore} from 'sentry/components/pageFilters/store';
+import {registerOverride} from 'sentry/overrideRegistry';
import {ConfigStore} from 'sentry/stores/configStore';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext';
@@ -2117,4 +2119,404 @@ describe('SeerExplorerContent', () => {
expect(sendMessage).toHaveBeenCalledWith('/investigate why?', 0);
});
});
+
+ describe('Out of credits', () => {
+ const creditsOrganization = OrganizationFixture({
+ openMembership: true,
+ features: [
+ 'seer-explorer',
+ 'seer-credit-checks',
+ 'token-billed-seer-enabled',
+ '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('holds the user’s own sends until a forwarded question goes out', async () => {
+ 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;
+ }),
+ });
+ const sendMessage = jest.fn();
+ jest
+ .spyOn(useSeerExplorerModule, 'useSeerExplorer')
+ .mockReturnValue({...defaultHookReturn, sendMessage});
+
+ renderContent({initialQuery: 'why is this slow?'});
+
+ // The check hasn't answered, so the forwarded question is still waiting.
+ const textarea = await getSeerExplorerInput();
+ await userEvent.type(textarea, 'my own question');
+ expect(screen.getByRole('button', {name: 'Send message'})).toBeDisabled();
+ await userEvent.keyboard('{Enter}');
+ expect(sendMessage).not.toHaveBeenCalled();
+
+ answerCreditCheck();
+
+ await waitFor(() => {
+ expect(sendMessage).toHaveBeenCalledWith('why is this slow?', 0);
+ });
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(textarea).toHaveTextContent('my own question');
+ expect(screen.getByRole('button', {name: 'Send message'})).toBeEnabled();
+ });
+
+ it('waits for its own credit check instead of an earlier panel’s answer', async () => {
+ mockCreditCheck(false);
+ const sendMessage = jest.fn();
+ jest
+ .spyOn(useSeerExplorerModule, 'useSeerExplorer')
+ .mockReturnValue({...defaultHookReturn, sendMessage});
+
+ // Each forward remounts the panel, which keeps the query cache.
+ function ForwardAgain() {
+ const [forwarded, setForwarded] = useState();
+ return (
+
+
+
+ {}}
+ 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 (
+