Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions static/app/components/events/autofix/autofixCredits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ export function AutofixOutOfCreditsAlert({
action,
reason,
}: AutofixOutOfCreditsAlertProps) {
const {hasEnoughCredits} = useAutofixCreditCheck(action);
const {hasEnoughCredits, denialReason} = useAutofixCreditCheck(action);

if (hasEnoughCredits) {
return null;
}

return <SeerOutOfCreditsCta reason={reason} />;
return <SeerOutOfCreditsCta reason={reason} denialReason={denialReason} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ export function AutofixStartCard({
<Stack gap="md">
{/* The Billing Gates design gives the sidebar one card: the notice takes the explainer's place. */}
{showOutOfCreditsNotice && !creditCheck.hasEnoughCredits ? (
<SeerOutOfCreditsCta reason="autofix-start" variant="muted" />
<SeerOutOfCreditsCta
reason="autofix-start"
denialReason={creditCheck.denialReason}
variant="muted"
/>
) : (
<AutofixStartCardContent />
)}
Expand Down
52 changes: 37 additions & 15 deletions static/app/components/seer/autofixChatContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import {
type ReactNode,
} from 'react';

import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck';

export type SendMessageOptions = {
/** Start a fresh conversation instead of adding to the one already open. */
newChat?: boolean;
};

type ReportOutOfCredits = (embedId: string, isOutOfCredits: boolean) => void;
type ReportOutOfCredits = (
embedId: string,
isOutOfCredits: boolean,
denialReason: SeerCreditDenialReason | null
) => void;

type AutofixChatContextValue = {
/**
Expand Down Expand Up @@ -76,21 +82,26 @@ export function useAutofixChat(): AutofixChatContextValue {

/**
* For the chat: whether any of its embeds can't afford the step it offers
* next, so the chat shows one notice for all of them. Pass
* next, and why, so the chat shows one notice for all of them. Pass
* `reportOutOfCredits` to `AutofixChatProvider`.
*/
export function useAutofixOutOfCreditsEmbeds() {
const [embedIds, setEmbedIds] = useState<ReadonlySet<string>>(() => new Set());
// The denial reason each embed that can't afford its next step reported.
const [denials, setDenials] = useState<
ReadonlyMap<string, SeerCreditDenialReason | null>
>(() => new Map());

const reportOutOfCredits = useCallback<ReportOutOfCredits>(
(embedId, isOutOfCredits) => {
setEmbedIds(current => {
if (current.has(embedId) === isOutOfCredits) {
(embedId, isOutOfCredits, denialReason) => {
setDenials(current => {
// Every embed reports on mount, and most can afford their step. Skip
// those so the chat doesn't re-render for nothing.
if (!isOutOfCredits && !current.has(embedId)) {
return current;
}
const next = new Set(current);
const next = new Map(current);
if (isOutOfCredits) {
next.add(embedId);
next.set(embedId, denialReason);
} else {
next.delete(embedId);
}
Expand All @@ -100,25 +111,36 @@ export function useAutofixOutOfCreditsEmbeds() {
[]
);

return {isAutofixOutOfCredits: embedIds.size > 0, reportOutOfCredits};
const reasons = [...denials.values()];
return {
isAutofixOutOfCredits: denials.size > 0,
// The daily usage limit is the org's, so one embed hitting it means they
// all have.
autofixDenialReason:
reasons.find(reason => reason === 'daily_cap_exceeded') ?? reasons[0] ?? null,
reportOutOfCredits,
};
}

/**
* For an embed: tells the chat around it whether it can't afford the step it
* offers next. Returns whether the embed has to show the notice itself, which
* it does only outside the chat, where nothing else would.
* offers next, and why. Returns whether the embed has to show the notice
* itself, which it does only outside the chat, where nothing else would.
*/
export function useReportAutofixOutOfCredits(isOutOfCredits: boolean): boolean {
export function useReportAutofixOutOfCredits(
isOutOfCredits: boolean,
denialReason: SeerCreditDenialReason | null
): boolean {
const {reportOutOfCredits} = useAutofixChat();
const embedId = useId();

useEffect(() => {
if (!reportOutOfCredits) {
return;
}
reportOutOfCredits(embedId, isOutOfCredits);
return () => reportOutOfCredits(embedId, false);
}, [embedId, isOutOfCredits, reportOutOfCredits]);
reportOutOfCredits(embedId, isOutOfCredits, denialReason);
return () => reportOutOfCredits(embedId, false, null);
}, [embedId, isOutOfCredits, denialReason, reportOutOfCredits]);

return isOutOfCredits && !reportOutOfCredits;
}
Original file line number Diff line number Diff line change
Expand Up @@ -631,15 +631,19 @@ describe('AutofixRef embed', () => {
await expandAll(1);

await waitFor(() =>
expect(reportOutOfCredits).toHaveBeenLastCalledWith(expect.any(String), true)
expect(reportOutOfCredits).toHaveBeenLastCalledWith(
expect.any(String),
true,
null
)
);
expect(getButtons('Continue: Plan')[0]).toBeDisabled();
expect(screen.queryByTestId('seer-out-of-credits-cta')).not.toBeInTheDocument();

// A card that leaves the chat takes its report with it.
const [embedId] = reportOutOfCredits.mock.lastCall!;
unmount();
expect(reportOutOfCredits).toHaveBeenLastCalledWith(embedId, false);
expect(reportOutOfCredits).toHaveBeenLastCalledWith(embedId, false, null);
});

it('reports nothing to the Explorer chat when the org can afford the step', async () => {
Expand All @@ -662,7 +666,7 @@ describe('AutofixRef embed', () => {
await expandAll(1);

await waitFor(() => expect(getButtons('Continue: Plan')[0]).toBeEnabled());
expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true);
expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true, null);
});

it('offers no next step for a single run, so checks nothing', async () => {
Expand Down Expand Up @@ -706,7 +710,7 @@ describe('AutofixRef embed', () => {
expect(await screen.findByText('Divides by zero')).toBeInTheDocument();
expect(getButtons('Continue: Plan')).toHaveLength(0);
expect(creditCheck).not.toHaveBeenCalled();
expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true);
expect(reportOutOfCredits).not.toHaveBeenCalledWith(expect.any(String), true, null);
});

it('does not check credits in a read-only conversation', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,8 @@ function CreditsChat({
}) {
const organization = useOrganization();
const [queryClient] = useState(() => new CreditsQueryClient(run, hasEnoughCredits));
const {isAutofixOutOfCredits, reportOutOfCredits} = useAutofixOutOfCreditsEmbeds();
const {isAutofixOutOfCredits, autofixDenialReason, reportOutOfCredits} =
useAutofixOutOfCreditsEmbeds();

return (
<Stack gap="md">
Expand Down Expand Up @@ -597,7 +598,11 @@ function CreditsChat({
<ChatShell
notice={
isAutofixOutOfCredits ? (
<SeerOutOfCreditsCta reason="autofix-continue" size="md" />
<SeerOutOfCreditsCta
reason="autofix-continue"
denialReason={autofixDenialReason}
size="md"
/>
) : null
}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,8 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) {
// In the Explorer chat the notice sits above the composer, where it stays in
// view as the chat scrolls. Anywhere else the card shows its own.
const showsOutOfCreditsNotice = useReportAutofixOutOfCredits(
showsNextAction && !creditCheck.hasEnoughCredits
showsNextAction && !creditCheck.hasEnoughCredits,
creditCheck.denialReason
);

return (
Expand All @@ -335,7 +336,11 @@ function AutofixRefContent({id, shortId, step}: AutofixRefContentProps) {
)}
{/* Muted: the buttons it explains stay right below it. */}
{showsOutOfCreditsNotice && (
<SeerOutOfCreditsCta reason="autofix-continue" variant="muted" />
<SeerOutOfCreditsCta
reason="autofix-continue"
denialReason={creditCheck.denialReason}
variant="muted"
/>
)}
{showsNextAction && (
<Flex gap="sm">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {OverrideOrDefault} from 'sentry/components/overrideOrDefault';
import type {SeerCreditDenialReason} from 'sentry/utils/seer/useSeerCreditCheck';

/**
* What the organization can't afford, which picks the copy:
Expand All @@ -11,6 +12,11 @@ import {OverrideOrDefault} from 'sentry/components/overrideOrDefault';
export type SeerOutOfCreditsReason = 'agent-chat' | 'autofix-start' | 'autofix-continue';

export interface SeerOutOfCreditsCtaProps {
/**
* Why the surface's credit check denied credit, which also picks the copy:
* hitting the daily usage limit reads differently from running out.
*/
denialReason: SeerCreditDenialReason | null;
reason: SeerOutOfCreditsReason;
/**
* The action button's size. Defaults to `md` for `agent-chat`, which sits
Expand Down
14 changes: 14 additions & 0 deletions static/app/utils/seer/useSeerCreditCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,20 @@ export type SeerCreditOperation =
| 'autofix_open_pr'
| 'autofix_pr_iteration';

/**
* Why the check denied credit:
*
* - `not_enough_credits`: the wallet can't cover the operation.
* - `daily_cap_exceeded`: the operation would take today's spend over the
* org's daily usage limit.
*/
export type SeerCreditDenialReason = 'not_enough_credits' | 'daily_cap_exceeded';

interface SeerCreditCheckResponse {
/**
* Null when the check allows the operation, or doesn't say why it doesn't.
*/
denialReason: SeerCreditDenialReason | null;
hasEnoughCredits: boolean;
}

Expand Down Expand Up @@ -93,6 +106,7 @@ export function useSeerCreditCheck(

return {
hasEnoughCredits: isChecking ? (query.data?.hasEnoughCredits ?? true) : true,
denialReason: isChecking ? (query.data?.denialReason ?? null) : null,
/**
* Whether the check has answered, or failed, since the caller mounted. A
* cached answer from an earlier mount doesn't count, since credits can
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ function IssuePreviewContent() {
? 'autofix-start'
: 'autofix-continue'
}
denialReason={previewSeer.creditCheck.denialReason}
/>
)}
{summary && <IssuePreviewSummary summary={summary} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import type {SeerOutOfCreditsCtaProps} from 'sentry/components/seer/outOfCredits
import {registerOverride} from 'sentry/overrideRegistry';
import {ConfigStore} from 'sentry/stores/configStore';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {seerCreditCheckApiOptions} from 'sentry/utils/seer/useSeerCreditCheck';
import {
seerCreditCheckApiOptions,
type SeerCreditDenialReason,
} from 'sentry/utils/seer/useSeerCreditCheck';
import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext';
import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext';
import * as useSeerExplorerModule from 'sentry/views/seerExplorer/hooks/useSeerExplorer';
Expand Down Expand Up @@ -2136,11 +2139,14 @@ describe('SeerExplorerContent', () => {
hideAiFeatures: false,
});

function mockCreditCheck(hasEnoughCredits: boolean) {
function mockCreditCheck(
hasEnoughCredits: boolean,
denialReason: SeerCreditDenialReason | null = null
) {
return MockApiClient.addMockResponse({
url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`,
match: [MockApiClient.matchQuery({operation: 'agent_chat_message'})],
body: {hasEnoughCredits},
body: {hasEnoughCredits, denialReason},
});
}

Expand Down Expand Up @@ -2188,6 +2194,26 @@ describe('SeerExplorerContent', () => {
expect(textarea).toHaveTextContent('Test message');
});

it('tells the CTA why the chat was denied credit', async () => {
// getsentry fills in the CTA; this stand-in shows what it was asked for.
registerOverride(
'component:seer-out-of-credits-cta',
() =>
function StubOutOfCreditsCta({reason, denialReason}: SeerOutOfCreditsCtaProps) {
return (
<div data-test-id="seer-out-of-credits-cta">{`${reason} ${denialReason}`}</div>
);
}
);
mockCreditCheck(false, 'daily_cap_exceeded');

renderContent();

expect(await screen.findByTestId('seer-out-of-credits-cta')).toHaveTextContent(
'agent-chat daily_cap_exceeded'
);
});

it('hides the suggested questions', async () => {
mockCreditCheck(false);

Expand Down Expand Up @@ -2479,6 +2505,29 @@ describe('SeerExplorerContent', () => {
).not.toBeInTheDocument();
});

it('tells the autofix CTA why the card was denied credit', async () => {
registerOverride(
'component:seer-out-of-credits-cta',
() =>
function DenialReasonCta({reason, denialReason}: SeerOutOfCreditsCtaProps) {
return (
<div data-test-id="seer-out-of-credits-cta">{`${reason} ${denialReason}`}</div>
);
}
);
mockCreditCheck(true);
MockApiClient.addMockResponse({
url: `/organizations/${creditsOrganization.slug}/seer/credit-check/`,
match: [MockApiClient.matchQuery({operation: 'autofix_solution'})],
body: {hasEnoughCredits: false, denialReason: 'daily_cap_exceeded'},
});

renderContent();

const cta = await screen.findByTestId('seer-explorer-autofix-out-of-credits');
expect(cta).toHaveTextContent('autofix-continue daily_cap_exceeded');
});

it('shows only the chat CTA when the chat is out of credits too', async () => {
mockCreditCheck(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export function SeerExplorerContent({
// waits on an answer to a message it already has.
const isComposerShown =
!readOnly && !showLoadError && !showFileApprovalBar && !showQuestionBar;
const {hasEnoughCredits, hasCheckedSinceMount} = useSeerCreditCheck(
const {hasEnoughCredits, denialReason, hasCheckedSinceMount} = useSeerCreditCheck(
'agent_chat_message',
{enabled: isComposerShown}
);
Expand Down Expand Up @@ -399,18 +399,23 @@ export function SeerExplorerContent({
// own notice wins, so the card's waits for the chat's check to answer. While
// the approval or question bar stands in for the composer, there's nowhere to
// put it, so cards show their own.
const {isAutofixOutOfCredits, reportOutOfCredits} = useAutofixOutOfCreditsEmbeds();
const {isAutofixOutOfCredits, autofixDenialReason, reportOutOfCredits} =
useAutofixOutOfCreditsEmbeds();
let outOfCreditsNotice: ReactNode = null;
if (isOutOfCredits) {
outOfCreditsNotice = (
<Container data-test-id="seer-explorer-out-of-credits">
<SeerOutOfCreditsCta reason="agent-chat" />
<SeerOutOfCreditsCta reason="agent-chat" denialReason={denialReason} />
</Container>
);
} else if (isAutofixOutOfCredits && !isCheckingCredits) {
outOfCreditsNotice = (
<Container data-test-id="seer-explorer-autofix-out-of-credits">
<SeerOutOfCreditsCta reason="autofix-continue" size="md" />
<SeerOutOfCreditsCta
reason="autofix-continue"
denialReason={autofixDenialReason}
size="md"
/>
</Container>
);
}
Expand Down
Loading
Loading