Skip to content
Merged
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
25 changes: 25 additions & 0 deletions static/app/components/seer/autofixChatContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AskSeerButton query="anything" />, {organization});

Expand Down
Original file line number Diff line number Diff line change
@@ -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',
});
2 changes: 2 additions & 0 deletions static/app/types/overrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -219,6 +220,7 @@ type ComponentOverrides = {
'component:replay-list-page-header': () => React.ComponentType<ReplayListPageHeaderProps> | null;
'component:replay-settings-alert': () => React.ComponentType | null;
'component:scm-github-multi-org-install': () => React.ComponentType<ScmGithubMultiOrgInstallProps>;
'component:seer-credit-usage-meter': () => React.ComponentType<SeerCreditUsageMeterProps>;
'component:seer-out-of-credits-cta': () => React.ComponentType<SeerOutOfCreditsCtaProps>;
'component:seer-trial-cta': () => React.ComponentType | null;
'component:superuser-access-category': React.ComponentType<SuperuserAccessCategoryProps>;
Expand Down
19 changes: 18 additions & 1 deletion static/app/utils/seer/useSeerCreditCheck.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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));
});
});
7 changes: 7 additions & 0 deletions static/app/utils/seer/useSeerCreditCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
126 changes: 120 additions & 6 deletions static/app/views/seerExplorer/components/chat/chatWindow.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,23 +30,57 @@ const APPROVAL_ID = '11111111-1111-4111-8111-111111111111';

type Session = NonNullable<SeerExplorerResponse['session']>;

/**
* 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 [];
}
Expand All @@ -62,36 +96,63 @@ 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 (
<OrganizationContext.Provider
value={{
...organization,
// The meter and the alert's copy are for billing members.
access: wallet ? [...organization.access, 'org:billing'] : organization.access,
openMembership: true,
hideAiFeatures: false,
features: [...organization.features, 'seer-explorer'],
features: [
// Generated suggestions would come back as a fixture list, so a new chat shows the
// fixed ones instead.
...organization.features.filter(feature => feature !== 'seer-chat-suggestions'),
'seer-explorer',
...(hasEnoughCredits === undefined
? []
: ['seer-credit-checks', 'token-billed-seer-enabled']),
],
}}
>
<QueryClientProvider client={queryClient}>
{/* Provided directly rather than through `SeerExplorerChatStateProvider`, which would
persist the story's run over the viewer's own Explorer session. */}
<SeerExplorerChatDispatchContext.Provider value={noopDispatch}>
<SeerExplorerChatStateContext.Provider
value={{runId: RUN_ID, chatPrompt: null, chatStates: {}}}
value={{runId: session ? RUN_ID : null, chatPrompt: null, chatStates: {}}}
>
<PictureInPictureProvider>
<SeerExplorerSessionsProvider>
<Container height="720px" width="480px" border="primary" radius="md">
<SeerExplorerContent
getPageReferrer={() => '/issues/'}
onClose={() => {}}
initialQuery={initialQuery}
appendInitialQuery={!!session}
/>
</Container>
</SeerExplorerSessionsProvider>
Expand Down Expand Up @@ -428,6 +489,59 @@ export default Storybook.story('ChatWindow', story => {
</Fragment>
));

story('New chat', () => (
<Fragment>
<p>
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.
</p>
<ChatWindow
session={null}
hasEnoughCredits
wallet={{autoReloadEnabled: false, remaining: 250, total: 1000}}
/>
</Fragment>
));

story('Out of credits in a new chat', () => (
<Fragment>
<p>
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.
</p>
<ChatWindow
session={null}
hasEnoughCredits={false}
initialQuery="Why is checkout slow since this morning?"
wallet={{autoReloadEnabled: false, remaining: 50, total: 1000}}
/>
</Fragment>
));

story('Out of credits in a conversation', () => (
<Fragment>
<p>
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.
</p>
<ChatWindow
session={COMPLETED_SESSION}
hasEnoughCredits={false}
initialQuery="Open a pull request that prefetches product in the cart query."
wallet={{autoReloadEnabled: true, remaining: 50, total: 1000}}
/>
</Fragment>
));

story('Awaiting an answer', () => (
<Fragment>
<p>
Expand Down
65 changes: 65 additions & 0 deletions static/app/views/seerExplorer/components/inputSection.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,71 @@ describe('InputSection', () => {
expect(placeholderRules.at(-1)?.style.color).toBe(expectedStyle.color);
});

it('shows a notice above the composer', () => {
render(<InputSection {...makeProps({notice: 'Out of credits'})} />);

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(
<InputSection
{...makeProps({
notice: 'Out of credits',
questionActions: {
canSubmit: true,
currentIndex: 0,
onBack: jest.fn(),
onMoveDown: jest.fn(),
onMoveUp: jest.fn(),
onNext: jest.fn(),
totalQuestions: 1,
},
})}
/>
);

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(<InputSection {...makeProps({toolbarItems: 'Toolbar item'})} />);

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(
<InputSection
{...makeProps({toolbarItems: 'Toolbar item', interruptState: 'can-interrupt'})}
/>
);

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(
<InputSection {...makeProps({toolbarItems: 'Toolbar item', enabled: false})} />
);

expect(screen.queryByText('Toolbar item')).not.toBeInTheDocument();
});

describe('with ask-seer-create-pr', () => {
const organization = OrganizationFixture({features: ['ask-seer-create-pr']});

Expand Down
Loading
Loading