Skip to content
Open
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
14 changes: 4 additions & 10 deletions static/app/views/seerExplorer/components/inputSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ interface InputSectionProps {
canSendMessage?: boolean;
fileApprovalActions?: FileApprovalActions;
interruptState?: 'can-interrupt' | 'requested' | 'completed' | 'disabled';
isTimedOut?: boolean;
questionActions?: QuestionActions;
}

Expand All @@ -56,7 +55,6 @@ export function InputSection({
inputValue,
canSendMessage = true,
interruptState = 'disabled',
isTimedOut = false,
onCreatePR,
onInputChange,
onInputClick,
Expand Down Expand Up @@ -257,21 +255,17 @@ export function InputSection({
return (
<InputBlock>
<InputRow>
<StyledInputGroup
isWarningPlaceholder={interruptState === 'completed' || isTimedOut}
>
<StyledInputGroup isWarningPlaceholder={interruptState === 'completed'}>
<InputGroup.TextArea
ref={textAreaRef}
value={inputValue}
onChange={onInputChange}
onKeyDown={onKeyDown}
onClick={onInputClick}
placeholder={
isTimedOut
? t('Response timed out. Please try again.')
: interruptState === 'completed'
? t('Interrupted. What should Seer do instead?')
: t('Ask Seer a question, or press / for commands.')
interruptState === 'completed'
? t('Interrupted. What should Seer do instead?')
: t('Ask Seer a question, or press / for commands.')
}
rows={1}
maxRows={5}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,66 @@ describe('SeerExplorerContent', () => {
});
});

describe('Timeout Recovery', () => {
it('shows the warning and retries the latest user turn', async () => {
const sendMessage = jest.fn();
const startNewSession = jest.fn();
jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({
...defaultHookReturn,
isTimedOut: true,
runId: 123,
sendMessage,
startNewSession,
sessionData: {
blocks: [
{
id: 'user-1',
message: {role: 'user', content: 'First question'},
timestamp: '2024-01-01T00:00:00Z',
},
{
id: 'assistant-1',
message: {role: 'assistant', content: 'First answer'},
timestamp: '2024-01-01T00:01:00Z',
},
{
id: 'user-2',
message: {role: 'user', content: 'Timed out question'},
timestamp: '2024-01-01T00:02:00Z',
},
],
status: 'error',
updated_at: '2024-01-01T00:03:00Z',
failure_reason: 'timeout',
},
});

render(
<PictureInPictureProvider>
<SeerExplorerSessionsProvider>
<SeerExplorerContent
getPageReferrer={mockGetPageReferrer}
onClose={() => {}}
/>
</SeerExplorerSessionsProvider>
</PictureInPictureProvider>,
{organization}
);

expect(await screen.findByText('Response timed out.')).toBeInTheDocument();
expect(screen.getByTestId('seer-explorer-input')).toHaveAttribute(
'placeholder',
'Ask Seer a question, or press / for commands.'
);

await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
expect(sendMessage).toHaveBeenCalledWith('Timed out question', 2);

await userEvent.click(screen.getByRole('button', {name: 'New chat'}));
expect(startNewSession).toHaveBeenCalledTimes(1);
});
});

describe('Input Persistence', () => {
it('restores the persisted draft when the drawer remounts', async () => {
jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import {Fragment, useCallback, useEffect, useMemo, useRef, type ReactNode} from
import styled from '@emotion/styled';
import {skipToken, useQuery} from '@tanstack/react-query';

import {Alert} from '@sentry/scraps/alert';
import {Button} from '@sentry/scraps/button';
import {Flex, Stack} from '@sentry/scraps/layout';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {usePictureInPicture} from '@sentry/scraps/pictureInPicture';
import {Text} from '@sentry/scraps/text';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {
AutofixChatProvider,
type SendMessageOptions,
} from 'sentry/components/seer/autofixChatContext';
import {SEER_AGENTS_PROJECT_ID} from 'sentry/constants';
import {IconClose} from 'sentry/icons';
import {IconClose, IconRefresh} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {OrganizationIntegration} from 'sentry/types/integrations';
import {trackAnalytics} from 'sentry/utils/analytics';
Expand Down Expand Up @@ -225,6 +227,15 @@ export function SeerExplorerContent({
sessionData.owner_user_id.toString() !== user.id;

const blocks = useMemo(() => sessionData?.blocks || [], [sessionData?.blocks]);
const retryTarget = useMemo(() => {
for (let index = blocks.length - 1; index >= 0; index--) {
const block = blocks[index];
if (block?.message.role === 'user' && block.message.content?.trim()) {
return {insertIndex: index, query: block.message.content};
}
}
return null;
}, [blocks]);
const isAwaitingUserInput = sessionData?.status === 'awaiting_user_input';
const pendingInput = sessionData?.pending_user_input ?? null;
const isAgentWriteApprovalPending =
Expand Down Expand Up @@ -465,6 +476,14 @@ export function SeerExplorerContent({
closeMenu();
};

const handleRetry = useCallback(() => {
if (!retryTarget || readOnly) {
return;
}
sendMessage(retryTarget.query, retryTarget.insertIndex);
userScrolledUpRef.current = false;
}, [readOnly, retryTarget, sendMessage]);

// - Scroll effects ---------------------------------------------------------

useEffect(() => {
Expand Down Expand Up @@ -680,13 +699,33 @@ export function SeerExplorerContent({
</Fragment>
)}
</BlocksContainer>
{isTimedOut && (
<Container padding="0 xl">
<Alert
variant="warning"
trailingItems={
retryTarget &&
!readOnly && (
<Alert.Button
variant="secondary"
icon={<IconRefresh />}
onClick={handleRetry}
>
{t('Retry')}
</Alert.Button>
)
}
>
<Text>{t('Response timed out.')}</Text>
</Alert>
</Container>
)}
<InputSection
blocks={blocks}
enabled={!readOnly}
inputValue={inputValue}
canSendMessage={canSendMessage}
interruptState={interruptState}
isTimedOut={isTimedOut}
onCreatePR={createPR}
onInputChange={handleInputChange}
onInputClick={handleInputClick}
Expand Down
3 changes: 3 additions & 0 deletions static/app/views/seerExplorer/hooks/useSeerExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export const useSeerExplorer = () => {
...prev,
session: {
...prev.session,
failure_reason: null,
status: 'processing',
updated_at: new Date().toISOString(),
},
Expand Down Expand Up @@ -296,6 +297,7 @@ export const useSeerExplorer = () => {
...prev,
session: {
...prev.session,
failure_reason: null,
status: 'processing',
updated_at: new Date().toISOString(),
},
Expand Down Expand Up @@ -358,6 +360,7 @@ export const useSeerExplorer = () => {
...prev,
session: {
...prev.session,
failure_reason: null,
status: 'processing',
updated_at: new Date().toISOString(),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,34 @@ describe('useSeerExplorerPolling', () => {
expect(result.current.isPolling).toBe(false);
expect(result.current.errorStatusCode).toBe(404);
});

it.each([
['timeout', 'timed-out', true],
['stalled', 'not-polling', false],
] as const)(
'maps a backend %s reason to %s',
async (failureReason, expectedPollingState, expectedTimedOut) => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/seer/explorer-chat/42/`,
body: {
session: {
blocks: [],
failure_reason: failureReason,
status: 'error',
updated_at: new Date().toISOString(),
},
},
});

const {result} = renderHookWithProviders(
() => useSeerExplorerPolling({runId: 42}),
{organization}
);

await waitFor(() => {
expect(result.current.pollingState).toBe(expectedPollingState);
});
expect(result.current.isTimedOut).toBe(expectedTimedOut);
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ const getPollingState = (
}
return 'not-polling';
}
if (sessionData?.failure_reason === 'timeout') {
return 'timed-out';
}
Comment thread
gricha marked this conversation as resolved.
if (isResponseComplete(sessionData)) {
return 'not-polling';
}
Expand Down
1 change: 1 addition & 0 deletions static/app/views/seerExplorer/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export type SeerExplorerResponse = {
blocks: Block[];
status: 'processing' | 'completed' | 'error' | 'awaiting_user_input';
updated_at: string;
failure_reason?: 'timeout' | 'stalled' | null;
owner_user_id?: number | null;
pending_user_input?: PendingUserInput | null;
repo_pr_states?: Record<string, RepoPRState>;
Expand Down
Loading