Skip to content
Closed
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
15 changes: 13 additions & 2 deletions static/app/views/seerExplorer/emptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ import {t, tct} from 'sentry/locale';
interface EmptyStateProps {
isError?: boolean;
isLoading?: boolean;
isTimedOut?: boolean;
runId?: number | null;
}

export function EmptyState({isLoading = false, isError = false, runId}: EmptyStateProps) {
export function EmptyState({
isLoading = false,
isError = false,
isTimedOut = false,
runId,
}: EmptyStateProps) {
const runIdDisplay = runId?.toString() ?? 'null';
return (
<Container>
{isError ? (
{isTimedOut ? (
<Fragment>
<IconSeer size="xl" />
<Text>{t('The request timed out. Please try again.')}</Text>
</Fragment>
) : isError ? (
<Fragment>
<IconSeer size="xl" />
<Text>
Expand Down
115 changes: 78 additions & 37 deletions static/app/views/seerExplorer/explorerPanel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@
import * as useSeerExplorerModule from './hooks/useSeerExplorer';
import {ExplorerPanel} from './explorerPanel';

function mockUseSeerExplorer(
overrides: Partial<ReturnType<typeof useSeerExplorerModule.useSeerExplorer>>
) {
return jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({
runId: null,
sessionData: null,
sendMessage: jest.fn(),
deleteFromIndex: jest.fn(),
startNewSession: jest.fn(),
isPolling: false,
isError: false,
isTimedOut: false,
deletedFromIndex: null,
interruptRun: jest.fn(),
waitingForInterrupt: false,
switchToRun: jest.fn(),
respondToUserInput: jest.fn(),
createPR: jest.fn(),
overrideCtxEngEnable: true,
setOverrideCtxEngEnable: jest.fn(),
...overrides,
});
}

// Mock createPortal to render content directly
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
Expand Down Expand Up @@ -196,27 +220,7 @@
});

it('shows error when hook returns isError=true', async () => {
const useSeerExplorerSpy = jest
.spyOn(useSeerExplorerModule, 'useSeerExplorer')
.mockReturnValue({
runId: 123,
sessionData: null, // should always be null when isError
sendMessage: jest.fn(),
deleteFromIndex: jest.fn(),
startNewSession: jest.fn(),
isPolling: false,
isError: true, // isError
deletedFromIndex: null,
interruptRun: jest.fn(),
interruptRequested: false,
wasJustInterrupted: false,
switchToRun: jest.fn(),
respondToUserInput: jest.fn(),
createPR: jest.fn(),
overrideCtxEngEnable: true,
setOverrideCtxEngEnable: jest.fn(),
});

const useSeerExplorerSpy = mockUseSeerExplorer({runId: 123, isError: true});
renderWithPanelContext(<ExplorerPanel />, true, {organization});

expect(
Expand Down Expand Up @@ -259,24 +263,9 @@
};

// Mock the hook to return our test data
jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({
mockUseSeerExplorer({
sessionData:
mockSessionData as useSeerExplorerModule.SeerExplorerResponse['session'],
sendMessage: jest.fn(),
deleteFromIndex: jest.fn(),
startNewSession: jest.fn(),
isPolling: false,
isError: false,
deletedFromIndex: null,
interruptRun: jest.fn(),
interruptRequested: false,
wasJustInterrupted: false,
runId: null,
respondToUserInput: jest.fn(),
switchToRun: jest.fn(),
createPR: jest.fn(),
overrideCtxEngEnable: true,
setOverrideCtxEngEnable: jest.fn(),
});

renderWithPanelContext(<ExplorerPanel />, true, {organization});
Expand Down Expand Up @@ -624,4 +613,56 @@
expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument();
});
});

describe('Timeout UI', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('shows timeout message in empty state when isTimedOut is true', async () => {
mockUseSeerExplorer({isTimedOut: true});

renderWithPanelContext(<ExplorerPanel />, true, {organization});

expect(
await screen.findByText('The request timed out. Please try again.')
).toBeInTheDocument();
expect(
screen.queryByText(/Ask Seer anything about your application./)
).not.toBeInTheDocument();
});

it('shows timeout placeholder on the input when isTimedOut is true', async () => {
mockUseSeerExplorer({isTimedOut: true});

renderWithPanelContext(<ExplorerPanel />, true, {organization});

const textarea = await screen.findByTestId('seer-explorer-input');
expect(textarea).toHaveAttribute(
'placeholder',
'The request timed out. Please try again.'
);
});

it('input is still enabled when timed out so user can retry', async () => {
mockUseSeerExplorer({isTimedOut: true});

renderWithPanelContext(<ExplorerPanel />, true, {organization});

const textarea = await screen.findByTestId('seer-explorer-input');
expect(textarea).toBeEnabled();
});

it('timeout placeholder takes precedence over interrupted state', async () => {
mockUseSeerExplorer({isTimedOut: true, wasJustInterrupted: true});

Check failure on line 657 in static/app/views/seerExplorer/explorerPanel.spec.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Object literal may only specify known properties, and 'wasJustInterrupted' does not exist in type 'Partial<{ sessionData: { blocks: Block[]; status: "awaiting_user_input" | "completed" | "error" | "processing"; updated_at: string; owner_user_id?: number | null | undefined; pending_user_input?: PendingUserInput | ... 1 more ... | undefined; repo_pr_states?: Record<...> | undefined; run_id?: number | undefined; } |...'.

Check failure on line 657 in static/app/views/seerExplorer/explorerPanel.spec.tsx

View workflow job for this annotation

GitHub Actions / typescript

Object literal may only specify known properties, and 'wasJustInterrupted' does not exist in type 'Partial<{ sessionData: { blocks: Block[]; status: "error" | "completed" | "processing" | "awaiting_user_input"; updated_at: string; owner_user_id?: number | null | undefined; pending_user_input?: PendingUserInput | ... 1 more ... | undefined; repo_pr_states?: Record<...> | undefined; run_id?: number | undefined; } |...'.

renderWithPanelContext(<ExplorerPanel />, true, {organization});

const textarea = await screen.findByTestId('seer-explorer-input');
expect(textarea).toHaveAttribute(
'placeholder',
'The request timed out. Please try again.'
);
});
});
});
49 changes: 24 additions & 25 deletions static/app/views/seerExplorer/explorerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ export function ExplorerPanel() {
sessionData,
isPolling,
isError,
isTimedOut,
sendMessage,
deleteFromIndex,
startNewSession,
switchToRun,
respondToUserInput,
createPR,
interruptRun,
interruptRequested,
wasJustInterrupted,
waitingForInterrupt,
overrideCtxEngEnable,
setOverrideCtxEngEnable,
} = useSeerExplorer();
Expand Down Expand Up @@ -283,30 +283,28 @@ export function ExplorerPanel() {
}
}, [focusedBlockIndex]);

const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (readOnly) {
return;
}

// While input is focused, prevent backtick from triggering superuser ViewAsHookMiddleware
if (e.key === '`') {
e.nativeEvent.stopImmediatePropagation();
}
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (readOnly || e.nativeEvent.isComposing) {
return;
}

if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (inputValue.trim() && !isPolling) {
sendMessage(inputValue.trim(), undefined);
setInputValue('');
// Reset scroll state so we auto-scroll to show the response
userScrolledUpRef.current = false;
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (inputValue.trim() && !isPolling) {
sendMessage(inputValue.trim());
setInputValue('');
// Reset scroll state so we auto-scroll to show the response
userScrolledUpRef.current = false;
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}
}
};
},
[readOnly, inputValue, isPolling, sendMessage]
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backtick key handler accidentally removed during refactor

Medium Severity

When handleInputKeyDown was refactored to use useCallback, the backtick key guard was accidentally removed. The old code had an explicit if (e.key === '') { e.nativeEvent.stopImmediatePropagation(); }block to prevent the backtick from triggering the superuserViewAsHookMiddleware`. This protection is now missing, so typing a backtick in the input while focused will propagate the event and potentially toggle superuser mode for staff users.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cc89ff3. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it didnt work


const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
Expand Down Expand Up @@ -632,6 +630,7 @@ export function ExplorerPanel() {
<EmptyState
isLoading={isWaitingForSessionData}
isError={isError}
isTimedOut={isTimedOut}
runId={runId}
/>
) : (
Expand Down Expand Up @@ -716,10 +715,10 @@ export function ExplorerPanel() {
blocks={blocks}
enabled={!readOnly}
inputValue={inputValue}
interruptRequested={interruptRequested}
wasJustInterrupted={wasJustInterrupted}
waitingForInterrupt={waitingForInterrupt}
isMinimized={isMinimized}
isPolling={isPolling}
isTimedOut={isTimedOut}
isVisible={isVisible}
onClear={() => setInputValue('')}
onCreatePR={createPR}
Expand Down
Loading
Loading