diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx new file mode 100644 index 00000000000000..581137bf436621 --- /dev/null +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -0,0 +1,375 @@ +import {Activity, useCallback, useEffect, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; + +import {DateTime} from 'sentry/components/dateTime'; +import {space} from 'sentry/styles/space'; +import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; +import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; + +type MenuMode = + | 'slash-commands-keyboard' + | 'slash-commands-manual' + | 'session-history' + | 'hidden'; + +interface ExplorerMenuProps { + clearInput: () => void; + focusInput: () => void; + inputValue: string; + onChangeSession: (runId: number) => void; + panelSize: 'max' | 'med'; + panelVisible: boolean; + slashCommandHandlers: { + onMaxSize: () => void; + onMedSize: () => void; + onNew: () => void; + }; + textAreaRef: React.RefObject; +} + +interface MenuItemProps { + description: string | React.ReactNode; + handler: () => void; + key: string; + title: string; +} + +export function useExplorerMenu({ + clearInput, + inputValue, + focusInput, + textAreaRef, + panelSize, + panelVisible, + slashCommandHandlers, + onChangeSession, +}: ExplorerMenuProps) { + const [menuMode, setMenuMode] = useState('hidden'); + + const allSlashCommands = useSlashCommands(slashCommandHandlers); + + const filteredSlashCommands = useMemo(() => { + // Filter commands based on current input + if (!inputValue.startsWith('/') || inputValue.includes(' ')) { + return []; + } + const query = inputValue.toLowerCase(); + return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query)); + }, [allSlashCommands, inputValue]); + + const {sessionItems, refetchSessions, isSessionsPending, isSessionsError} = useSessions( + {onChangeSession, enabled: panelVisible} + ); + + // Menu items and select handlers change based on the mode. + const menuItems = useMemo(() => { + switch (menuMode) { + case 'slash-commands-keyboard': + return filteredSlashCommands; + case 'slash-commands-manual': + return allSlashCommands; + case 'session-history': + return sessionItems; + default: + return []; + } + }, [menuMode, allSlashCommands, filteredSlashCommands, sessionItems]); + + const close = useCallback(() => { + setMenuMode('hidden'); + if (menuMode === 'slash-commands-keyboard') { + // Clear input and reset textarea height. + clearInput(); + if (textAreaRef.current) { + textAreaRef.current.style.height = 'auto'; + } + } + }, [menuMode, setMenuMode, clearInput, textAreaRef]); + + const closeAndFocusInput = useCallback(() => { + close(); + focusInput(); + }, [close, focusInput]); + + const onSelect = useCallback( + (item: MenuItemProps) => { + // Execute custom handler. + item.handler(); + + if (menuMode === 'slash-commands-keyboard') { + // Clear input and reset textarea height. + clearInput(); + if (textAreaRef.current) { + textAreaRef.current.style.height = 'auto'; + } + } + + if (item.key === '/resume') { + // Handle /resume command here - avoid changing menu state from item handlers. + setMenuMode('session-history'); + refetchSessions(); + } else { + // Default to closing the menu after an item is selected and handled. + closeAndFocusInput(); + } + }, + // clearInput and textAreaRef are both expected to be stable. + [menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions, closeAndFocusInput] + ); + + // Toggle between slash-commands-keyboard and hidden modes based on filteredSlashCommands. + useEffect(() => { + if (menuMode === 'slash-commands-keyboard' && filteredSlashCommands.length === 0) { + setMenuMode('hidden'); + } else if (menuMode === 'hidden' && filteredSlashCommands.length > 0) { + setMenuMode('slash-commands-keyboard'); + } + }, [menuMode, setMenuMode, filteredSlashCommands]); + + const isVisible = menuMode !== 'hidden'; + + const [selectedIndex, setSelectedIndex] = useState(0); + + // Reset selected index when items change + useEffect(() => { + setSelectedIndex(0); + }, [menuItems]); + + // Handle keyboard navigation with higher priority + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isVisible) return; + + const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; + + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex(prev => Math.max(0, prev - 1)); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex(prev => Math.min(menuItems.length - 1, prev + 1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (menuItems[selectedIndex]) { + onSelect(menuItems[selectedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeAndFocusInput(); + if (menuMode === 'slash-commands-keyboard') { + clearInput(); + } + } else if (isPrintableChar && menuMode !== 'slash-commands-keyboard') { + closeAndFocusInput(); + } + }, + [ + isVisible, + selectedIndex, + menuItems, + onSelect, + clearInput, + menuMode, + closeAndFocusInput, + ] + ); + + useEffect(() => { + if (isVisible) { + // Use capture phase to intercept events before they reach other handlers + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + } + return undefined; + }, [handleKeyDown, isVisible]); + + const menu = ( + + + {menuItems.map((item, index) => ( + onSelect(item)} + > + {item.title} + {item.description} + + ))} + {menuMode === 'session-history' && menuItems.length === 0 && ( + + + {isSessionsPending + ? 'Loading sessions...' + : isSessionsError + ? 'Error loading sessions.' + : 'No session history found.'} + + + )} + + + ); + + // Handler for the button entrypoint. + const onMenuButtonClick = useCallback(() => { + if (menuMode === 'hidden') { + setMenuMode('slash-commands-manual'); + } else { + close(); + } + }, [menuMode, setMenuMode, close]); + + return { + menu, + menuMode, + isMenuOpen: menuMode !== 'hidden', + closeMenu: close, + onMenuButtonClick, + }; +} + +function useSlashCommands({ + onMaxSize, + onMedSize, + onNew, +}: { + onMaxSize: () => void; + onMedSize: () => void; + onNew: () => void; +}): MenuItemProps[] { + const openFeedbackForm = useFeedbackForm(); + + return useMemo( + (): MenuItemProps[] => [ + { + title: '/new', + key: '/new', + description: 'Start a new session', + handler: onNew, + }, + { + title: '/resume', + key: '/resume', + description: 'View your session history to resume past sessions', + handler: () => {}, // Handled by parent onSelect callback. + }, + { + title: '/max-size', + key: '/max-size', + description: 'Expand panel to full viewport height', + handler: onMaxSize, + }, + { + title: '/med-size', + key: '/med-size', + description: 'Set panel to medium size (default)', + handler: onMedSize, + }, + ...(openFeedbackForm + ? [ + { + title: '/feedback', + key: '/feedback', + description: 'Open feedback form to report issues or suggestions', + handler: () => + openFeedbackForm({ + formTitle: 'Seer Explorer Feedback', + messagePlaceholder: 'How can we make Seer Explorer better for you?', + tags: { + ['feedback.source']: 'seer_explorer', + }, + }), + }, + ] + : []), + ], + [onNew, onMaxSize, onMedSize, openFeedbackForm] + ); +} + +function useSessions({ + onChangeSession, + enabled, +}: { + onChangeSession: (runId: number) => void; + enabled?: boolean; +}) { + const {data, isPending, isError, refetch} = useExplorerSessions({limit: 20, enabled}); + + const sessionItems = useMemo(() => { + if (isPending || isError) { + return []; + } + + return data.data.map(session => ({ + title: session.title, + key: session.run_id.toString(), + description: ( + + Last updated at + + ), + handler: () => { + onChangeSession(session.run_id); + }, + })); + }, [data, isPending, isError, onChangeSession]); + + return { + sessionItems, + isSessionsPending: isPending, + isSessionsError: isError, + isError, + refetchSessions: refetch, + }; +} + +const MenuPanel = styled('div')<{ + panelSize: 'max' | 'med'; +}>` + position: absolute; + bottom: 100%; + left: ${space(2)}; + width: 300px; + background: ${p => p.theme.background}; + border: 1px solid ${p => p.theme.border}; + border-bottom: none; + border-radius: ${p => p.theme.borderRadius}; + box-shadow: ${p => p.theme.dropShadowHeavy}; + max-height: ${p => + p.panelSize === 'max' ? 'calc(100vh - 120px)' : `calc(50vh - 80px)`}; + overflow-y: auto; + z-index: 10; +`; + +const MenuItem = styled('div')<{isSelected: boolean}>` + padding: ${space(1.5)} ${space(2)}; + cursor: pointer; + background: ${p => (p.isSelected ? p.theme.hover : 'transparent')}; + border-bottom: 1px solid ${p => p.theme.border}; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: ${p => p.theme.hover}; + } +`; + +const ItemName = styled('div')` + font-weight: 600; + color: ${p => p.theme.purple400}; + font-size: ${p => p.theme.fontSize.sm}; +`; + +const ItemDescription = styled('div')` + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSize.xs}; + margin-top: 2px; +`; diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index 6411874e260487..e7ad29b84f172e 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -12,45 +12,72 @@ jest.mock('react-dom', () => ({ })); describe('ExplorerPanel', () => { + const organization = OrganizationFixture({ + features: ['seer-explorer'], + hideAiFeatures: false, + }); + beforeEach(() => { MockApiClient.clearMockResponses(); + + // This matches the real behavior when no run ID is provided. + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/explorer-chat/`, + method: 'GET', + body: {session: null}, + statusCode: 404, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/seer/explorer-runs/`, + method: 'GET', + body: { + data: [ + { + run_id: 456, + title: 'Old Run', + created_at: '2024-01-02T00:00:00Z', + last_triggered_at: '2024-01-03T00:00:00Z', + }, + { + run_id: 451, + title: 'Another Run', + created_at: '2024-01-01T00:00:00Z', + last_triggered_at: '2024-01-01T17:53:33Z', + }, + ], + }, + }); }); describe('Feature Flag and Organization Checks', () => { it('renders when feature flag is enabled', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - hideAiFeatures: false, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - render(, {organization}); expect(screen.getByText(/Welcome to Seer Explorer/)).toBeInTheDocument(); }); it('does not render when feature flag is disabled', () => { - const organization = OrganizationFixture({ + const disabledOrg = OrganizationFixture({ features: [], }); - const {container} = render(, {organization}); + const {container} = render(, { + organization: disabledOrg, + }); expect(container).toBeEmptyDOMElement(); }); it('does not render when AI features are hidden', () => { - const organization = OrganizationFixture({ + const disabledOrg = OrganizationFixture({ features: ['seer-explorer'], hideAiFeatures: true, }); - const {container} = render(, {organization}); + const {container} = render(, { + organization: disabledOrg, + }); expect(container).toBeEmptyDOMElement(); }); @@ -58,32 +85,12 @@ describe('ExplorerPanel', () => { describe('Empty State', () => { it('shows empty state when no messages exist', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - render(, {organization}); expect(screen.getByText(/Welcome to Seer Explorer/)).toBeInTheDocument(); }); it('shows input section in empty state', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - render(, {organization}); expect( @@ -94,10 +101,6 @@ describe('ExplorerPanel', () => { describe('Messages Display', () => { it('renders messages when session data exists', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - const mockSessionData = { blocks: [ { @@ -133,10 +136,11 @@ describe('ExplorerPanel', () => { startNewSession: jest.fn(), isPolling: false, isPending: false, - runId: 123, deletedFromIndex: null, interruptRun: jest.fn(), interruptRequested: false, + runId: null, + setRunId: jest.fn(), }); render(, {organization}); @@ -154,35 +158,15 @@ describe('ExplorerPanel', () => { describe('Input Handling', () => { it('can type in textarea', async () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - render(, {organization}); - const textarea = screen.getByRole('textbox'); + const textarea = screen.getByTestId('seer-explorer-input'); await userEvent.type(textarea, 'Test message'); expect(textarea).toHaveValue('Test message'); }); it('sends message when Enter is pressed', async () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - const postMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/explorer-chat/`, method: 'POST', @@ -204,18 +188,22 @@ describe('ExplorerPanel', () => { method: 'GET', body: { session: { - messages: [ + blocks: [ { id: 'msg-1', - type: 'user-input', - content: 'Test message', + message: { + role: 'user', + content: 'What is this error?', + }, timestamp: '2024-01-01T00:00:00Z', loading: false, }, { - id: 'response-1', - type: 'response', - content: 'Response content', + id: 'msg-2', + message: { + role: 'assistant', + content: 'This error indicates a null pointer exception.', + }, timestamp: '2024-01-01T00:01:00Z', loading: false, }, @@ -229,7 +217,7 @@ describe('ExplorerPanel', () => { render(, {organization}); - const textarea = screen.getByRole('textbox'); + const textarea = screen.getByTestId('seer-explorer-input'); await userEvent.type(textarea, 'Test message'); await userEvent.keyboard('{Enter}'); @@ -245,16 +233,6 @@ describe('ExplorerPanel', () => { }); it('clears input after sending message', async () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/seer/explorer-chat/`, method: 'POST', @@ -276,18 +254,22 @@ describe('ExplorerPanel', () => { method: 'GET', body: { session: { - messages: [ + blocks: [ { id: 'msg-1', - type: 'user-input', - content: 'Test message', + message: { + role: 'user', + content: 'What is this error?', + }, timestamp: '2024-01-01T00:00:00Z', loading: false, }, { - id: 'response-1', - type: 'response', - content: 'Response', + id: 'msg-2', + message: { + role: 'assistant', + content: 'This error indicates a null pointer exception.', + }, timestamp: '2024-01-01T00:01:00Z', loading: false, }, @@ -301,7 +283,7 @@ describe('ExplorerPanel', () => { render(, {organization}); - const textarea = screen.getByRole('textbox'); + const textarea = screen.getByTestId('seer-explorer-input'); await userEvent.type(textarea, 'Test message'); await userEvent.keyboard('{Enter}'); @@ -311,37 +293,17 @@ describe('ExplorerPanel', () => { describe('Visibility Control', () => { it('renders when isVisible=true', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - render(, {organization}); - expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); }); it('can handle visibility changes', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/explorer-chat/`, - method: 'GET', - body: {session: null}, - }); - const {rerender} = render(, {organization}); rerender(); - expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByTestId('seer-explorer-input')).toBeInTheDocument(); }); }); }); diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 593a8a6e03373e..66186d96808b81 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; import useOrganization from 'sentry/utils/useOrganization'; @@ -8,9 +8,9 @@ import {usePanelSizing} from './hooks/usePanelSizing'; import {useSeerExplorer} from './hooks/useSeerExplorer'; import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; +import {useExplorerMenu} from './explorerMenu'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; -import type {SlashCommand} from './slashCommands'; import type {Block, ExplorerPanelProps} from './types'; function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { @@ -18,7 +18,6 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { const [inputValue, setInputValue] = useState(''); const [focusedBlockIndex, setFocusedBlockIndex] = useState(-1); // -1 means input is focused - const [isSlashCommandsVisible, setIsSlashCommandsVisible] = useState(false); const [isMinimized, setIsMinimized] = useState(false); // state for slide-down const textareaRef = useRef(null); const scrollContainerRef = useRef(null); @@ -40,6 +39,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { isPolling, interruptRun, interruptRequested, + setRunId, } = useSeerExplorer(); // Get blocks from session data or empty array @@ -166,40 +166,11 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { } }, [focusedBlockIndex]); - // Auto-focus input when user starts typing while a block is focused - useEffect(() => { - if (!isVisible) { - return undefined; - } - - const handleKeyDown = (e: KeyboardEvent) => { - if (focusedBlockIndex !== -1) { - const isPrintableChar = - e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; - - if (isPrintableChar) { - e.preventDefault(); - setFocusedBlockIndex(-1); - textareaRef.current?.focus(); - setInputValue(prev => prev + e.key); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isVisible, focusedBlockIndex]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape' && isPolling && !interruptRequested) { - e.preventDefault(); - interruptRun(); - } else if (e.key === 'Enter' && !e.shiftKey) { + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (inputValue.trim() && !isPolling) { - sendMessage(inputValue.trim()); + sendMessage(inputValue.trim(), undefined); setInputValue(''); // Reset scroll state so we auto-scroll to show the response userScrolledUpRef.current = false; @@ -232,29 +203,79 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { setIsMinimized(false); }; - const handleInputClick = () => { + const focusInput = useCallback(() => { hoveredBlockIndex.current = -1; setFocusedBlockIndex(-1); textareaRef.current?.focus(); setIsMinimized(false); - }; + }, [setFocusedBlockIndex, textareaRef, setIsMinimized]); + + const {menu, isMenuOpen, closeMenu, onMenuButtonClick} = useExplorerMenu({ + clearInput: () => setInputValue(''), + inputValue, + focusInput, + textAreaRef: textareaRef, + panelSize, + panelVisible: isVisible, + slashCommandHandlers: { + onMaxSize: handleMaxSize, + onMedSize: handleMedSize, + onNew: startNewSession, + }, + onChangeSession: setRunId, + }); - const handlePanelBackgroundClick = () => { + const handlePanelBackgroundClick = useCallback(() => { setIsMinimized(false); - }; - - const handleCommandSelect = (command: SlashCommand) => { - // Execute the command - command.handler(); + closeMenu(); + }, [closeMenu]); - // Clear input - setInputValue(''); + const handleInputClick = useCallback(() => { + // Click handler for the input textarea. + focusInput(); + closeMenu(); + }, [closeMenu, focusInput]); - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; + useEffect(() => { + if (!isVisible || isMinimized || isMenuOpen) { + return undefined; } - }; + + // Global keyboard event listeners for when the panel is open and menu is closed. + // Menu keyboard listeners are in the menu component. + const handleKeyDown = (e: KeyboardEvent) => { + const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; + + if (e.key === 'Escape' && isPolling && !interruptRequested) { + e.preventDefault(); + interruptRun(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsMinimized(true); + } else if (isPrintableChar) { + if (focusedBlockIndex !== -1) { + // If a block is focused, auto-focus input when user starts typing. + e.preventDefault(); + setFocusedBlockIndex(-1); + textareaRef.current?.focus(); + setInputValue(prev => prev + e.key); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [ + isVisible, + isMenuOpen, + isPolling, + focusedBlockIndex, + interruptRun, + interruptRequested, + isMinimized, + ]); const panelContent = ( handleBlockClick(index)} onMouseEnter={() => { - // Don't change focus while slash commands menu is open or if already on this block - if (isSlashCommandsVisible || hoveredBlockIndex.current === index) { + // Don't change focus while menu is open or if already on this block + if (isMenuOpen || hoveredBlockIndex.current === index) { return; } @@ -307,19 +328,17 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { )} setInputValue('')} onInputChange={handleInputChange} - onKeyDown={handleKeyDown} onInputClick={handleInputClick} - onCommandSelect={handleCommandSelect} - onSlashCommandsVisibilityChange={setIsSlashCommandsVisible} - onMaxSize={handleMaxSize} - onMedSize={handleMedSize} - onClear={startNewSession} + textAreaRef={textareaRef} + onKeyDown={handleInputKeyDown} /> ); diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx new file mode 100644 index 00000000000000..e06b30c3b79e75 --- /dev/null +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -0,0 +1,35 @@ +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; + +interface SessionsResponse { + data: ExplorerSession[]; +} + +export function useExplorerSessions({ + limit, + enabled = true, +}: { + limit: number; + enabled?: boolean; +}) { + const organization = useOrganization({allowNull: true}); + const query = useApiQuery( + [ + `/organizations/${organization?.slug ?? ''}/seer/explorer-runs/`, + { + query: { + per_page: limit, + }, + }, + ], + { + staleTime: 10_000, + enabled: enabled && Boolean(organization), + } + ); + + return { + ...query, + }; +} diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 81b3a5bbe2e083..45a5c9e84e05a4 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useState} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import { setApiQueryData, @@ -94,7 +94,7 @@ export const useSeerExplorer = () => { const orgSlug = organization?.slug; const captureAsciiSnapshot = useAsciiSnapshot(); - const [currentRunId, setCurrentRunId] = useState(null); + const [runId, setRunId] = useState(null); const [waitingForResponse, setWaitingForResponse] = useState(false); const [deletedFromIndex, setDeletedFromIndex] = useState(null); const [interruptRequested, setInterruptRequested] = useState(false); @@ -108,11 +108,11 @@ export const useSeerExplorer = () => { } | null>(null); const {data: apiData, isPending} = useApiQuery( - makeSeerExplorerQueryKey(orgSlug || '', currentRunId || undefined), + makeSeerExplorerQueryKey(orgSlug || '', runId || undefined), { staleTime: 0, retry: false, - enabled: !!currentRunId && !!orgSlug, + enabled: !!runId && !!orgSlug, refetchInterval: query => { if (isPolling(query.state.data?.[0]?.session || null, waitingForResponse)) { return POLL_INTERVAL; @@ -176,7 +176,7 @@ export const useSeerExplorer = () => { try { const response = (await api.requestPromise( - `/organizations/${orgSlug}/seer/explorer-chat/${currentRunId ? `${currentRunId}/` : ''}`, + `/organizations/${orgSlug}/seer/explorer-chat/${runId ? `${runId}/` : ''}`, { method: 'POST', data: { @@ -188,8 +188,8 @@ export const useSeerExplorer = () => { )) as SeerExplorerChatResponse; // Set run ID if this is a new session - if (!currentRunId) { - setCurrentRunId(response.run_id); + if (!runId) { + setRunId(response.run_id); } // Invalidate queries to fetch fresh data @@ -201,7 +201,7 @@ export const useSeerExplorer = () => { setOptimistic(null); setApiQueryData( queryClient, - makeSeerExplorerQueryKey(orgSlug, currentRunId || undefined), + makeSeerExplorerQueryKey(orgSlug, runId || undefined), makeErrorSeerExplorerData(e?.responseJSON?.detail ?? 'An error occurred') ); } @@ -210,34 +210,20 @@ export const useSeerExplorer = () => { queryClient, api, orgSlug, - currentRunId, + runId, apiData, deletedFromIndex, captureAsciiSnapshot, + setRunId, ] ); - const startNewSession = useCallback(() => { - setCurrentRunId(null); - setWaitingForResponse(false); - setDeletedFromIndex(null); - setOptimistic(null); - setInterruptRequested(false); - if (orgSlug) { - setApiQueryData( - queryClient, - makeSeerExplorerQueryKey(orgSlug), - makeInitialSeerExplorerData() - ); - } - }, [queryClient, orgSlug]); - const deleteFromIndex = useCallback((index: number) => { setDeletedFromIndex(index); }, []); const interruptRun = useCallback(async () => { - if (!orgSlug || !currentRunId) { + if (!orgSlug || !runId || interruptRequested) { return; } @@ -245,7 +231,7 @@ export const useSeerExplorer = () => { try { await api.requestPromise( - `/organizations/${orgSlug}/seer/explorer-update/${currentRunId}/`, + `/organizations/${orgSlug}/seer/explorer-update/${runId}/`, { method: 'POST', data: { @@ -259,12 +245,12 @@ export const useSeerExplorer = () => { // If the request fails, reset the interrupt state setInterruptRequested(false); } - }, [api, orgSlug, currentRunId]); + }, [api, orgSlug, runId, interruptRequested]); // Always filter messages based on optimistic state and deletedFromIndex before any other processing const sessionData = apiData?.session ?? null; - const filteredSessionData = (() => { + const filteredSessionData = useMemo(() => { const realBlocks = sessionData?.blocks || []; // Respect rewound/deleted index first for the real blocks view @@ -295,7 +281,7 @@ export const useSeerExplorer = () => { ]; const baseSession: NonNullable = sessionData ?? { - run_id: currentRunId ?? undefined, + run_id: runId ?? undefined, blocks: [], status: 'processing', updated_at: new Date().toISOString(), @@ -316,7 +302,7 @@ export const useSeerExplorer = () => { } return sessionData; - })(); + }, [sessionData, deletedFromIndex, optimistic, runId]); // Clear optimistic blocks once the real blocks change in poll results useEffect(() => { @@ -353,13 +339,44 @@ export const useSeerExplorer = () => { } } + /** Resets the hook state. The session isn't actually created until the user sends a message. */ + const startNewSession = useCallback(() => { + if (!interruptRequested && isPolling(filteredSessionData, waitingForResponse)) { + // Make interrupt request before resetting state. + interruptRun(); + } + // Reset state. + setRunId(null); + setWaitingForResponse(false); + setDeletedFromIndex(null); + setOptimistic(null); + setInterruptRequested(false); + if (orgSlug) { + setApiQueryData( + queryClient, + makeSeerExplorerQueryKey(orgSlug), + makeInitialSeerExplorerData() + ); + } + }, [ + queryClient, + orgSlug, + setRunId, + filteredSessionData, + waitingForResponse, + interruptRun, + interruptRequested, + ]); + return { sessionData: filteredSessionData, isPolling: isPolling(filteredSessionData, waitingForResponse), isPending, sendMessage, + runId, + setRunId, + /** Resets the run id, blocks, and other state. The new session isn't actually created until the user sends a message. */ startNewSession, - runId: currentRunId, deleteFromIndex, deletedFromIndex, interruptRun, diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index b3477730257f5c..92b15ff0330fdf 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -1,40 +1,35 @@ import styled from '@emotion/styled'; -import {IconChevron} from 'sentry/icons'; -import {space} from 'sentry/styles/space'; +import {Button} from '@sentry/scraps/button'; -import SlashCommands, {type SlashCommand} from './slashCommands'; +import {IconMenu} from 'sentry/icons'; +import {space} from 'sentry/styles/space'; interface InputSectionProps { focusedBlockIndex: number; inputValue: string; interruptRequested: boolean; isPolling: boolean; + menu: React.ReactElement; onClear: () => void; - onCommandSelect: (command: SlashCommand) => void; onInputChange: (e: React.ChangeEvent) => void; onInputClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; - onMaxSize: () => void; - onMedSize: () => void; - onSlashCommandsVisibilityChange: (isVisible: boolean) => void; - ref?: React.RefObject; + onMenuButtonClick: () => void; + textAreaRef: React.RefObject; } function InputSection({ + menu, + onMenuButtonClick, inputValue, focusedBlockIndex, isPolling, interruptRequested, - onClear, onInputChange, - onKeyDown, onInputClick, - onCommandSelect, - onSlashCommandsVisibilityChange, - onMaxSize, - onMedSize, - ref, + onKeyDown, + textAreaRef, }: InputSectionProps) { const getPlaceholder = () => { if (focusedBlockIndex !== -1) { @@ -51,28 +46,28 @@ function InputSection({ return ( - - - - - + +