diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index d1c50f45289776..7bbb9fdcf7bc86 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -206,12 +206,10 @@ describe('ExplorerPanel', () => { startNewSession: jest.fn(), isPolling: false, isError: true, // isError - isPending: false, deletedFromIndex: null, interruptRun: jest.fn(), interruptRequested: false, wasJustInterrupted: false, - clearWasJustInterrupted: jest.fn(), switchToRun: jest.fn(), respondToUserInput: jest.fn(), createPR: jest.fn(), @@ -269,12 +267,10 @@ describe('ExplorerPanel', () => { startNewSession: jest.fn(), isPolling: false, isError: false, - isPending: false, deletedFromIndex: null, interruptRun: jest.fn(), interruptRequested: false, wasJustInterrupted: false, - clearWasJustInterrupted: jest.fn(), runId: null, respondToUserInput: jest.fn(), switchToRun: jest.fn(), @@ -451,7 +447,7 @@ describe('ExplorerPanel', () => { }); }); - function mockSessionResponse(ownerUserId: number | null | undefined) { + function mockSessionResponse({ownerUserId}: {ownerUserId?: number}) { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/explorer-chat/${runId}/`, method: 'GET', @@ -467,24 +463,9 @@ describe('ExplorerPanel', () => { }); } - it('should have disabled input section when useUser returns none', async () => { - ConfigStore.set('user', undefined as any); - mockSessionResponse(2); - - renderWithPanelContext(, true, {organization}); - - const textarea = await screen.findByTestId('seer-explorer-input'); - - expect(textarea).toBeDisabled(); - expect(textarea).toHaveAttribute( - 'placeholder', - 'This conversation is owned by another user and is read-only' - ); - }); - it('should have disabled input section when explorer owner differs', async () => { ConfigStore.set('user', UserFixture({id: '1'})); - mockSessionResponse(2); + mockSessionResponse({ownerUserId: 2}); renderWithPanelContext(, true, {organization}); @@ -497,9 +478,9 @@ describe('ExplorerPanel', () => { ); }); - it('enables input when owner id is null', async () => { + it('enables input when owner id matches', async () => { ConfigStore.set('user', UserFixture({id: '1'})); - mockSessionResponse(null); + mockSessionResponse({ownerUserId: 1}); renderWithPanelContext(, true, {organization}); @@ -512,9 +493,9 @@ describe('ExplorerPanel', () => { ); }); - it('enables input when owner id matches', async () => { + it('enables input when owner id is undefined', async () => { ConfigStore.set('user', UserFixture({id: '1'})); - mockSessionResponse(1); + mockSessionResponse({ownerUserId: undefined}); renderWithPanelContext(, true, {organization}); diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 3c8292be995e53..3c91c5ca15ea31 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -13,7 +13,6 @@ import {Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {IconSeer} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {User} from 'sentry/types/user'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useLocation} from 'sentry/utils/useLocation'; @@ -101,18 +100,17 @@ export function ExplorerPanel() { const { runId, sessionData, + isPolling, + isError, sendMessage, deleteFromIndex, startNewSession, - isPolling, - isError, - interruptRun, - interruptRequested, - wasJustInterrupted, - clearWasJustInterrupted, switchToRun, respondToUserInput, createPR, + interruptRun, + interruptRequested, + wasJustInterrupted, overrideCtxEngEnable, setOverrideCtxEngEnable, } = useSeerExplorer(); @@ -137,13 +135,6 @@ export function ExplorerPanel() { onUnminimize: useCallback(() => setIsMinimized(false), []), }); - // Clear wasJustInterrupted when user starts typing - useEffect(() => { - if (inputValue.length > 0 && wasJustInterrupted) { - clearWasJustInterrupted(); - } - }, [inputValue, wasJustInterrupted, clearWasJustInterrupted]); - // Extract repo_pr_states from session const repoPRStates = useMemo( () => sessionData?.repo_pr_states ?? {}, @@ -153,25 +144,11 @@ export function ExplorerPanel() { // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); - // Check owner id to determine edit permission. Defensive against any useUser return shape. - // Despite the type annotation, useUser can return null or undefined when not logged in. - // This component is in the top-level index so we have to guard against this. - const rawUser = useUser() as unknown; - const ownerUserId = sessionData?.owner_user_id ?? undefined; - const readOnly = useMemo(() => { - const isUser = (value: unknown): value is User => - Boolean( - value && - typeof value === 'object' && - 'id' in value && - typeof value.id === 'string' - ); - const userId = isUser(rawUser) ? rawUser.id : undefined; - return ( - userId === undefined || - (ownerUserId !== undefined && ownerUserId?.toString() !== userId) - ); - }, [rawUser, ownerUserId]); + // Check owner id to determine edit permission. + const user = useUser(); + const readOnly = + sessionData?.owner_user_id !== undefined && + sessionData.owner_user_id.toString() !== user.id; // Get PR widget data for menu const {menuItems: prWidgetItems, menuFooter: prWidgetFooter} = usePRWidgetData({ @@ -735,10 +712,10 @@ export function ExplorerPanel() { focusedBlockIndex={focusedBlockIndex} inputValue={inputValue} interruptRequested={interruptRequested} + wasJustInterrupted={wasJustInterrupted} isMinimized={isMinimized} isPolling={isPolling} isVisible={isVisible} - wasJustInterrupted={wasJustInterrupted} onClear={() => setInputValue('')} onCreatePR={createPR} onInputChange={handleInputChange} diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index d52ba16425657b..92dbaf3ef4e0fe 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -143,14 +143,24 @@ export const useSeerExplorer = () => { } }, [location, navigate, openExplorerPanel, setRunId]); - // Check if Seer drawer is open - if so, always poll - const isSeerDrawerOpen = !!location.query?.seerDrawer; - const [waitingForResponse, setWaitingForResponse] = useState(false); - const [deletedFromIndex, setDeletedFromIndex] = useState(null); const [interruptRequested, setInterruptRequested] = useState(false); const [wasJustInterrupted, setWasJustInterrupted] = useState(false); - const prevInterruptRequestedRef = useRef(false); + + // Helpers for managing waiting and interrupt state. + const _onNewRequest = useCallback(() => { + setWaitingForResponse(true); + setInterruptRequested(false); + setWasJustInterrupted(false); + }, []); + + const _onRequestError = useCallback(() => { + setWaitingForResponse(false); + setInterruptRequested(false); + setWasJustInterrupted(false); + }, []); + + const [deletedFromIndex, setDeletedFromIndex] = useState(null); const [optimistic, setOptimistic] = useState<{ assistantBlockId: string; assistantContent: string; @@ -161,25 +171,46 @@ export const useSeerExplorer = () => { } | null>(null); const previousPRStatesRef = useRef>({}); - const { - data: apiData, - isPending, - isError, - } = useApiQuery(makeSeerExplorerQueryKey(orgSlug || '', runId), { - staleTime: 0, - retry: false, - enabled: !!runId && !!orgSlug, - refetchInterval: query => { - // Always poll when Seer drawer is open (actions triggered from drawer need updates) - if (isSeerDrawerOpen) { - return POLL_INTERVAL; - } - if (isPolling(query.state.data?.[0]?.session || null, waitingForResponse)) { - return POLL_INTERVAL; + const {data: apiData, isError} = useApiQuery( + makeSeerExplorerQueryKey(orgSlug || '', runId), + { + staleTime: 0, + retry: false, + enabled: !!runId && !!orgSlug, + refetchInterval: query => { + if (isPolling(query.state.data?.[0]?.session || null, waitingForResponse)) { + return POLL_INTERVAL; + } + return false; + }, + } as UseApiQueryOptions + ); + + /** Switches to a different run and fetches its latest state. */ + const switchToRun = useCallback( + (newRunId: number | null) => { + // Set the new run ID + setRunId(newRunId); + + // Clear any optimistic state from previous run + setOptimistic(null); + setDeletedFromIndex(null); + setWaitingForResponse(false); + setInterruptRequested(false); + setWasJustInterrupted(false); + + // Invalidate the query to force a fresh fetch + if (orgSlug && newRunId !== null) { + queryClient.invalidateQueries({ + queryKey: makeSeerExplorerQueryKey(orgSlug, newRunId), + }); } - return false; }, - } as UseApiQueryOptions); + [orgSlug, queryClient, setRunId] + ); + + /** Resets the hook state. The session isn't actually created until the user sends a message. */ + const startNewSession = useCallback(() => switchToRun(null), [switchToRun]); const sendMessage = useCallback( async (query: string, insertIndex?: number, explicitRunId?: number | null) => { @@ -207,9 +238,6 @@ export const useSeerExplorer = () => { screenshot = captureAsciiSnapshot?.(); } - setWaitingForResponse(true); - setWasJustInterrupted(false); - trackAnalytics('seer.explorer.message_sent', { referrer: getPageReferrer(), surface: 'global_panel', @@ -255,6 +283,8 @@ export const useSeerExplorer = () => { baselineUpdatedAt: apiData?.session?.updated_at, }); + _onNewRequest(); + try { const {url} = parseQueryKey(makeSeerExplorerQueryKey(orgSlug, effectiveRunId)); const response = (await api.requestPromise(url, { @@ -278,7 +308,7 @@ export const useSeerExplorer = () => { queryKey: makeSeerExplorerQueryKey(orgSlug, response.run_id), }); } catch (e: any) { - setWaitingForResponse(false); + _onRequestError(); setOptimistic(null); if (effectiveRunId !== null) { // API data is disabled for null runId (new runs). @@ -292,6 +322,8 @@ export const useSeerExplorer = () => { } }, [ + _onNewRequest, + _onRequestError, queryClient, api, orgSlug, @@ -321,6 +353,7 @@ export const useSeerExplorer = () => { } setInterruptRequested(true); + setWasJustInterrupted(false); try { await api.requestPromise( @@ -346,7 +379,7 @@ export const useSeerExplorer = () => { return; } - setWaitingForResponse(true); + _onNewRequest(); try { await api.requestPromise( @@ -368,7 +401,7 @@ export const useSeerExplorer = () => { queryKey: makeSeerExplorerQueryKey(orgSlug, runId), }); } catch (e: any) { - setWaitingForResponse(false); + _onRequestError(); setApiQueryData( queryClient, makeSeerExplorerQueryKey(orgSlug, runId), @@ -376,7 +409,7 @@ export const useSeerExplorer = () => { ); } }, - [api, orgSlug, runId, queryClient] + [_onNewRequest, _onRequestError, api, orgSlug, runId, queryClient] ); const createPR = useCallback( @@ -523,78 +556,33 @@ export const useSeerExplorer = () => { previousPRStatesRef.current = currentPRStates; }, [sessionData?.repo_pr_states]); - if ( - waitingForResponse && - filteredSessionData && - Array.isArray(filteredSessionData.blocks) - ) { - // Stop waiting once we see the response is no longer loading - const hasLoadingMessage = filteredSessionData.blocks.some(block => block.loading); - - if (!hasLoadingMessage && filteredSessionData.status !== 'processing') { - setWaitingForResponse(false); - setInterruptRequested(false); - // Clear deleted index once response is complete - setDeletedFromIndex(null); - } - } - - // Detect when interrupt succeeds and set wasJustInterrupted + // On response load useEffect(() => { - const prevInterruptRequested = prevInterruptRequestedRef.current; - const currentlyPolling = isPolling(filteredSessionData, waitingForResponse); - - // Reset interruptRequested when polling stops after an interrupt was requested - if (interruptRequested && !currentlyPolling) { - setInterruptRequested(false); - } - - // Detect successful interrupt: was requested, now not requested, and not polling - if (prevInterruptRequested && !interruptRequested && !currentlyPolling) { - setWasJustInterrupted(true); - } - - prevInterruptRequestedRef.current = interruptRequested; - }, [interruptRequested, filteredSessionData, waitingForResponse]); - - /** Resets the hook state. The session isn't actually created until the user sends a message. */ - const startNewSession = useCallback(() => { - // Reset state. - setRunId(null); - setWaitingForResponse(false); - setDeletedFromIndex(null); - setOptimistic(null); - setInterruptRequested(false); - setWasJustInterrupted(false); - }, [setRunId]); - - /** Switches to a different run and fetches its latest state. */ - const switchToRun = useCallback( - (newRunId: number) => { - // Clear any optimistic state from previous run - setOptimistic(null); - setDeletedFromIndex(null); - setWaitingForResponse(false); - setInterruptRequested(false); - setWasJustInterrupted(false); - - // Set the new run ID - setRunId(newRunId); + if ( + waitingForResponse && + filteredSessionData && + Array.isArray(filteredSessionData.blocks) + ) { + // Stop waiting once we see the response is no longer loading + if ( + filteredSessionData.status !== 'processing' && + filteredSessionData.blocks.every(block => !block.loading) + ) { + setWaitingForResponse(false); + // Clear deleted index once response is complete + setDeletedFromIndex(null); - // Invalidate the query to force a fresh fetch - if (orgSlug) { - queryClient.invalidateQueries({ - queryKey: makeSeerExplorerQueryKey(orgSlug, newRunId), - }); + if (interruptRequested) { + setInterruptRequested(false); + setWasJustInterrupted(true); // set persistent UI flag until next request + } } - }, - [orgSlug, queryClient, setRunId] - ); + } + }, [waitingForResponse, filteredSessionData, interruptRequested]); return { sessionData: filteredSessionData, isPolling: isPolling(filteredSessionData, waitingForResponse), - isPending, isError, sendMessage, runId, @@ -608,7 +596,6 @@ export const useSeerExplorer = () => { interruptRequested, /** True after an interrupt succeeds, until the user sends a new message or switches sessions. */ wasJustInterrupted, - clearWasJustInterrupted: useCallback(() => setWasJustInterrupted(false), []), respondToUserInput, createPR, overrideCtxEngEnable, diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index 7412170a3ad6e4..68c3a4f722b63e 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -47,11 +47,11 @@ interface InputSectionProps { prWidgetButtonRef: React.RefObject; repoPRStates: Record; textAreaRef: React.RefObject; + wasJustInterrupted: boolean; fileApprovalActions?: FileApprovalActions; isMinimized?: boolean; isVisible?: boolean; questionActions?: QuestionActions; - wasJustInterrupted?: boolean; } export function InputSection({ @@ -62,6 +62,7 @@ export function InputSection({ isMinimized = false, isPolling, interruptRequested, + wasJustInterrupted = false, isVisible = false, onCreatePR, onInputChange, @@ -74,16 +75,12 @@ export function InputSection({ textAreaRef, fileApprovalActions, questionActions, - wasJustInterrupted = false, }: InputSectionProps) { // Check if there are any file patches for showing the PR widget const hasCodeChanges = useMemo(() => { return blocks.some(b => b.merged_file_patches && b.merged_file_patches.length > 0); }, [blocks]); const getPlaceholder = () => { - if (!enabled) { - return 'This conversation is owned by another user and is read-only'; - } if (wasJustInterrupted) { return 'Interrupted. What should Seer do instead?'; } @@ -169,12 +166,9 @@ export function InputSection({