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({