diff --git a/static/app/views/seerExplorer/components/inputSection.tsx b/static/app/views/seerExplorer/components/inputSection.tsx index e76ec4d108e0..4659b7e7e609 100644 --- a/static/app/views/seerExplorer/components/inputSection.tsx +++ b/static/app/views/seerExplorer/components/inputSection.tsx @@ -46,7 +46,6 @@ interface InputSectionProps { canSendMessage?: boolean; fileApprovalActions?: FileApprovalActions; interruptState?: 'can-interrupt' | 'requested' | 'completed' | 'disabled'; - isTimedOut?: boolean; questionActions?: QuestionActions; } @@ -56,7 +55,6 @@ export function InputSection({ inputValue, canSendMessage = true, interruptState = 'disabled', - isTimedOut = false, onCreatePR, onInputChange, onInputClick, @@ -257,9 +255,7 @@ export function InputSection({ return ( - + { }); }); + 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( + + + {}} + /> + + , + {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({ diff --git a/static/app/views/seerExplorer/components/seerExplorerContent.tsx b/static/app/views/seerExplorer/components/seerExplorerContent.tsx index 70a1afc3316f..8d400e153379 100644 --- a/static/app/views/seerExplorer/components/seerExplorerContent.tsx +++ b/static/app/views/seerExplorer/components/seerExplorerContent.tsx @@ -2,9 +2,11 @@ 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 { @@ -12,7 +14,7 @@ import { 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'; @@ -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 = @@ -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(() => { @@ -680,13 +699,33 @@ export function SeerExplorerContent({ )} + {isTimedOut && ( + + } + onClick={handleRetry} + > + {t('Retry')} + + ) + } + > + {t('Response timed out.')} + + + )} { ...prev, session: { ...prev.session, + failure_reason: null, status: 'processing', updated_at: new Date().toISOString(), }, @@ -296,6 +297,7 @@ export const useSeerExplorer = () => { ...prev, session: { ...prev.session, + failure_reason: null, status: 'processing', updated_at: new Date().toISOString(), }, @@ -358,6 +360,7 @@ export const useSeerExplorer = () => { ...prev, session: { ...prev.session, + failure_reason: null, status: 'processing', updated_at: new Date().toISOString(), }, diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.spec.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.spec.tsx index d87bc0e84137..94ddac2cdd76 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.spec.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.spec.tsx @@ -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); + } + ); }); diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx index 1f00b9dfe5d3..479a2756f833 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorerPolling.tsx @@ -59,6 +59,9 @@ const getPollingState = ( } return 'not-polling'; } + if (sessionData?.failure_reason === 'timeout') { + return 'timed-out'; + } if (isResponseComplete(sessionData)) { return 'not-polling'; } diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 7ab0363bdb3d..21840aefaa7a 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -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;