From a2b6a5eadbbdbd0c53fdc5e9b3fc74f98da88557 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:54:39 -0700 Subject: [PATCH 01/25] ui v0 with mock data --- .../app/views/seerExplorer/explorerPanel.tsx | 49 ++++++ .../hooks/useExplorerSessions.tsx | 41 +++++ .../seerExplorer/sessionSelectorDropdown.tsx | 165 ++++++++++++++++++ static/app/views/seerExplorer/types.tsx | 7 + 4 files changed, 262 insertions(+) create mode 100644 static/app/views/seerExplorer/hooks/useExplorerSessions.tsx create mode 100644 static/app/views/seerExplorer/sessionSelectorDropdown.tsx diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 4676843ccc8821..4f789eaf7ee141 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -1,6 +1,12 @@ import {useEffect, useMemo, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; +import styled from '@emotion/styled'; +import moment from 'moment-timezone'; +import {Flex} from '@sentry/scraps/layout'; +import {Heading} from '@sentry/scraps/text'; + +import {space} from 'sentry/styles/space'; import useOrganization from 'sentry/utils/useOrganization'; import {useBlockNavigation} from './hooks/useBlockNavigation'; @@ -10,6 +16,7 @@ import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; +import {MOCK_SESSIONS, SessionSelectorDropdown} from './sessionSelectorDropdown'; import type {SlashCommand} from './slashCommands'; import type {Block, ExplorerPanelProps} from './types'; @@ -20,6 +27,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { 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 [activeSessionId, setActiveSessionId] = useState(undefined); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); const blockRefs = useRef>([]); @@ -44,6 +52,18 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); + // Get active session title for display + const activeSessionTitle = useMemo(() => { + const session = MOCK_SESSIONS.find(s => s.run_id === activeSessionId); + const title = session?.title ?? 'New Session'; + + const createdDate = session?.created_at + ? moment(session.created_at).format('MM/DD h:mm A') + : moment(Date.now()).format('MM/DD h:mm A'); + + return `${createdDate} - ${title}`.trim(); + }, [activeSessionId]); + useBlockNavigation({ isOpen: isVisible, focusedBlockIndex, @@ -210,6 +230,15 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onUnminimize={() => setIsMinimized(false)} > + + + + {activeSessionTitle} + + {blocks.length === 0 ? ( ) : ( @@ -277,4 +306,24 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { return createPortal(panelContent, document.body); } +const SessionDropdownHeader = styled('div')` + padding: ${space(2)}; + border-bottom: 1px solid ${p => p.theme.border}; + background: ${p => p.theme.background}; + position: sticky; + top: 0; + z-index: 1; +`; + +const SessionTitle = styled(Heading)` + margin: 0; + font-size: ${p => p.theme.fontSize.lg}; + font-weight: ${p => p.theme.fontWeight.bold}; + color: ${p => p.theme.textColor}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +`; + export default ExplorerPanel; diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx new file mode 100644 index 00000000000000..6bbecf986abfa5 --- /dev/null +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -0,0 +1,41 @@ +import {useMemo} from 'react'; +import uniqBy from 'lodash/uniqBy'; + +import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; +import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; + +interface SeerResponse { + data: ExplorerSession[]; +} + +export function useExplorerSessions({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + const organization = useOrganization(); + + const queryResult = useInfiniteApiQuery({ + queryKey: ['infinite', `/organizations/${organization.slug}/seer/explorer-runs/`], + enabled: enabled && Boolean(organization), + staleTime: 30_000, // 30 seconds + }); + + // Deduplicate sessions in case pages overlap + // Access result[0].data since response format is {"data": list[item]} + const sessions = useMemo( + () => + uniqBy( + queryResult.data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], + 'run_id' + ), + [queryResult.data?.pages] + ); + + return { + sessions, + isFetching: queryResult.isFetching, + isError: queryResult.isError, + }; +} diff --git a/static/app/views/seerExplorer/sessionSelectorDropdown.tsx b/static/app/views/seerExplorer/sessionSelectorDropdown.tsx new file mode 100644 index 00000000000000..d09174eb23de43 --- /dev/null +++ b/static/app/views/seerExplorer/sessionSelectorDropdown.tsx @@ -0,0 +1,165 @@ +import styled from '@emotion/styled'; +import moment from 'moment-timezone'; + +import {Button} from '@sentry/scraps/button'; +import {Text} from '@sentry/scraps/text'; + +import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; +import {IconChevron} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; + +interface SessionSelectorDropdownProps { + activeSessionId?: number; + onSelectSession?: (runId: number) => void; +} + +// Mock data for now - will be replaced with actual hook later +export const MOCK_SESSIONS: ExplorerSession[] = [ + { + run_id: 1, + title: 'Debug authentication issue in user login flow', + created_at: '2025-01-15T10:30:00Z', + last_triggered_at: '2025-01-15T12:45:00Z', + }, + { + run_id: 2, + title: 'Investigate performance regression', + created_at: '2025-01-15T09:15:00Z', + last_triggered_at: '2025-01-15T11:20:00Z', + }, + { + run_id: 3, + title: 'API endpoint returning 500 errors', + created_at: '2025-01-14T16:45:00Z', + last_triggered_at: '2025-01-14T18:30:00Z', + }, + { + run_id: 4, + title: 'Memory leak in background worker', + created_at: '2025-01-14T14:00:00Z', + last_triggered_at: '2025-01-14T15:30:00Z', + }, + { + run_id: 5, + title: 'Frontend bundle size optimization', + created_at: '2025-01-13T11:20:00Z', + last_triggered_at: '2025-01-13T13:45:00Z', + }, + { + run_id: 6, + title: 'Database query optimization', + created_at: '2025-01-12T15:30:00Z', + last_triggered_at: '2025-01-12T16:00:00Z', + }, +]; + +function getRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000); + + if (diffInMinutes < 1) { + return 'Just now'; + } + if (diffInMinutes < 60) { + return `${diffInMinutes}m ago`; + } + if (diffInMinutes < 1440) { + const hours = Math.floor(diffInMinutes / 60); + return `${hours}h ago`; + } + const days = Math.floor(diffInMinutes / 1440); + return `${days}d ago`; +} + +function makeSelectOption(session: ExplorerSession): SelectOption { + const relativeTime = getRelativeTime(session.last_triggered_at); + const createdDate = moment(session.created_at).format('MMM D'); + + return { + label: ( + + {session.title} + + {relativeTime} • Created {createdDate} + + + ), + value: session.run_id, + textValue: session.title, + }; +} + +export function SessionSelectorDropdown({ + activeSessionId, + onSelectSession, +}: SessionSelectorDropdownProps) { + const selectOptions = MOCK_SESSIONS.map(makeSelectOption); + + const makeTrigger = ( + props: Omit, 'children'>, + isOpen: boolean + ) => { + return ( + + + + ); + }; + + return ( + { + if (opt?.value) { + onSelectSession?.(opt.value); + } + }} + options={selectOptions} + trigger={makeTrigger} + emptyMessage={t('No previous sessions')} + /> + ); +} + +const TriggerButton = styled(Button)` + display: flex; + padding: ${space(0.75)} ${space(1.5)}; + border: 1px solid ${p => p.theme.border}; + border-radius: ${p => p.theme.borderRadius}; + background: ${p => p.theme.background}; + max-width: 300px; + + &:hover { + background: ${p => p.theme.hover}; + } +`; + +const SessionOption = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(0.5)}; + padding: ${space(0.5)} 0; + min-width: 0; +`; + +const SessionTitle = styled(Text)` + font-size: ${p => p.theme.fontSize.sm}; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SessionMeta = styled(Text)` + font-size: ${p => p.theme.fontSize.xs}; + color: ${p => p.theme.subText}; +`; diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 390847d04a3b45..07e56f04142d03 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -28,3 +28,10 @@ export interface ExplorerPanelProps { isVisible?: boolean; onClose?: () => void; } + +export interface ExplorerSession { + created_at: string; // ISO date string + last_triggered_at: string; + run_id: number; + title: string; // ISO date string +} From 352fc7f8435a16ff2e1161973de253374d18046a Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:44:04 -0800 Subject: [PATCH 02/25] dropdown mock ui --- .../app/views/seerExplorer/explorerPanel.tsx | 25 +++-- .../hooks/useExplorerSessions.tsx | 20 +++- ...lectorDropdown.tsx => sessionDropdown.tsx} | 95 +++++++++---------- 3 files changed, 76 insertions(+), 64 deletions(-) rename static/app/views/seerExplorer/{sessionSelectorDropdown.tsx => sessionDropdown.tsx} (66%) diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 4f789eaf7ee141..39b75b483b73ee 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -10,13 +10,14 @@ import {space} from 'sentry/styles/space'; import useOrganization from 'sentry/utils/useOrganization'; import {useBlockNavigation} from './hooks/useBlockNavigation'; +import {useExplorerSessions} from './hooks/useExplorerSessions'; import {usePanelSizing} from './hooks/usePanelSizing'; import {useSeerExplorer} from './hooks/useSeerExplorer'; import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; -import {MOCK_SESSIONS, SessionSelectorDropdown} from './sessionSelectorDropdown'; +import {SessionDropdown} from './sessionDropdown'; import type {SlashCommand} from './slashCommands'; import type {Block, ExplorerPanelProps} from './types'; @@ -39,6 +40,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Custom hooks const {panelSize, handleMaxSize, handleMedSize} = usePanelSizing(); + const {sessions: allSessions} = useExplorerSessions({enabled: isVisible}); const { sessionData, sendMessage, @@ -54,7 +56,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Get active session title for display const activeSessionTitle = useMemo(() => { - const session = MOCK_SESSIONS.find(s => s.run_id === activeSessionId); + const session = allSessions.find(s => s.run_id === activeSessionId); const title = session?.title ?? 'New Session'; const createdDate = session?.created_at @@ -62,7 +64,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { : moment(Date.now()).format('MM/DD h:mm A'); return `${createdDate} - ${title}`.trim(); - }, [activeSessionId]); + }, [activeSessionId, allSessions]); useBlockNavigation({ isOpen: isVisible, @@ -230,15 +232,18 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onUnminimize={() => setIsMinimized(false)} > - + - + {organization && ( + + )} {activeSessionTitle} - + {blocks.length === 0 ? ( ) : ( @@ -306,7 +311,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { return createPortal(panelContent, document.body); } -const SessionDropdownHeader = styled('div')` +const SessionHeader = styled('div')` padding: ${space(2)}; border-bottom: 1px solid ${p => p.theme.border}; background: ${p => p.theme.background}; diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx index 6bbecf986abfa5..a2d06957572b95 100644 --- a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -11,13 +11,22 @@ interface SeerResponse { export function useExplorerSessions({ enabled = true, + perPage = 20, }: { enabled?: boolean; -} = {}) { - const organization = useOrganization(); - + perPage?: number; +}) { + const organization = useOrganization({allowNull: true}); const queryResult = useInfiniteApiQuery({ - queryKey: ['infinite', `/organizations/${organization.slug}/seer/explorer-runs/`], + queryKey: [ + 'infinite', + `/organizations/${organization?.slug ?? ''}/seer/explorer-runs/`, + { + query: { + per_page: perPage, + }, + }, + ], enabled: enabled && Boolean(organization), staleTime: 30_000, // 30 seconds }); @@ -37,5 +46,8 @@ export function useExplorerSessions({ sessions, isFetching: queryResult.isFetching, isError: queryResult.isError, + hasNextPage: queryResult.hasNextPage, + fetchNextPage: queryResult.fetchNextPage, + isFetchingNextPage: queryResult.isFetchingNextPage, }; } diff --git a/static/app/views/seerExplorer/sessionSelectorDropdown.tsx b/static/app/views/seerExplorer/sessionDropdown.tsx similarity index 66% rename from static/app/views/seerExplorer/sessionSelectorDropdown.tsx rename to static/app/views/seerExplorer/sessionDropdown.tsx index d09174eb23de43..7954dedb3420ab 100644 --- a/static/app/views/seerExplorer/sessionSelectorDropdown.tsx +++ b/static/app/views/seerExplorer/sessionDropdown.tsx @@ -1,3 +1,4 @@ +import {Fragment} from 'react'; import styled from '@emotion/styled'; import moment from 'moment-timezone'; @@ -5,56 +6,20 @@ import {Button} from '@sentry/scraps/button'; import {Text} from '@sentry/scraps/text'; import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; import {IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import type {Organization} from 'sentry/types/organization'; +import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; -interface SessionSelectorDropdownProps { - activeSessionId?: number; +interface SessionDropdownProps { + organization: Organization | null; onSelectSession?: (runId: number) => void; + runId?: number; } -// Mock data for now - will be replaced with actual hook later -export const MOCK_SESSIONS: ExplorerSession[] = [ - { - run_id: 1, - title: 'Debug authentication issue in user login flow', - created_at: '2025-01-15T10:30:00Z', - last_triggered_at: '2025-01-15T12:45:00Z', - }, - { - run_id: 2, - title: 'Investigate performance regression', - created_at: '2025-01-15T09:15:00Z', - last_triggered_at: '2025-01-15T11:20:00Z', - }, - { - run_id: 3, - title: 'API endpoint returning 500 errors', - created_at: '2025-01-14T16:45:00Z', - last_triggered_at: '2025-01-14T18:30:00Z', - }, - { - run_id: 4, - title: 'Memory leak in background worker', - created_at: '2025-01-14T14:00:00Z', - last_triggered_at: '2025-01-14T15:30:00Z', - }, - { - run_id: 5, - title: 'Frontend bundle size optimization', - created_at: '2025-01-13T11:20:00Z', - last_triggered_at: '2025-01-13T13:45:00Z', - }, - { - run_id: 6, - title: 'Database query optimization', - created_at: '2025-01-12T15:30:00Z', - last_triggered_at: '2025-01-12T16:00:00Z', - }, -]; - function getRelativeTime(dateString: string): string { const date = new Date(dateString); const now = new Date(); @@ -92,11 +57,14 @@ function makeSelectOption(session: ExplorerSession): SelectOption { }; } -export function SessionSelectorDropdown({ - activeSessionId, - onSelectSession, -}: SessionSelectorDropdownProps) { - const selectOptions = MOCK_SESSIONS.map(makeSelectOption); +export function SessionDropdown({runId, onSelectSession}: SessionDropdownProps) { + const {sessions, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage} = + useExplorerSessions({ + enabled: true, + perPage: 20, + }); + + const selectOptions = sessions.map(makeSelectOption); const makeTrigger = ( props: Omit, 'children'>, @@ -109,13 +77,32 @@ export function SessionSelectorDropdown({ ); }; + const menuFooter = ( + + {hasNextPage && ( + + + + )} + + ); + return ( { @@ -125,7 +112,8 @@ export function SessionSelectorDropdown({ }} options={selectOptions} trigger={makeTrigger} - emptyMessage={t('No previous sessions')} + emptyMessage={isFetching ? t('Loading sessions...') : t('No sessions found.')} + menuFooter={menuFooter} /> ); } @@ -163,3 +151,10 @@ const SessionMeta = styled(Text)` font-size: ${p => p.theme.fontSize.xs}; color: ${p => p.theme.subText}; `; + +const FooterWrapper = styled('div')` + display: flex; + justify-content: center; + padding: ${space(1)}; + border-top: 1px solid ${p => p.theme.border}; +`; From 37aa5ff0ca705dab28906b46b941816f31c4b1b5 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:36:11 -0800 Subject: [PATCH 03/25] Change session on select and clear --- .../app/views/seerExplorer/explorerPanel.tsx | 14 ++-- .../seerExplorer/hooks/useSeerExplorer.tsx | 78 ++++++++++++------- .../views/seerExplorer/sessionDropdown.tsx | 13 ++-- 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 39b75b483b73ee..9102925da0eb83 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -28,7 +28,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { 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 [activeSessionId, setActiveSessionId] = useState(undefined); + const [runId, setRunId] = useState(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); const blockRefs = useRef>([]); @@ -49,14 +49,14 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { isPolling, interruptRun, interruptRequested, - } = useSeerExplorer(); + } = useSeerExplorer({runId, setRunId}); // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); // Get active session title for display const activeSessionTitle = useMemo(() => { - const session = allSessions.find(s => s.run_id === activeSessionId); + const session = allSessions.find(s => s.run_id === runId); const title = session?.title ?? 'New Session'; const createdDate = session?.created_at @@ -64,7 +64,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { : moment(Date.now()).format('MM/DD h:mm A'); return `${createdDate} - ${title}`.trim(); - }, [activeSessionId, allSessions]); + }, [runId, allSessions]); useBlockNavigation({ isOpen: isVisible, @@ -162,7 +162,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { }, [isVisible, focusedBlockIndex]); const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape' && isPolling && !interruptRequested) { + if (e.key === 'Escape' && isPolling) { e.preventDefault(); interruptRun(); } else if (e.key === 'Enter' && !e.shiftKey) { @@ -237,8 +237,8 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { {organization && ( )} {activeSessionTitle} diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 49b7648ddef39c..8655504226da91 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -90,14 +90,19 @@ const isPolling = (sessionData: SeerExplorerResponse['session'], runStarted: boo ); }; -export const useSeerExplorer = () => { +export function useSeerExplorer({ + runId, + setRunId, +}: { + runId: number | null; + setRunId: (runId: number | null) => void; +}) { const api = useApi(); const queryClient = useQueryClient(); const organization = useOrganization({allowNull: true}); const orgSlug = organization?.slug; const captureAsciiSnapshot = useAsciiSnapshot(); - const [currentRunId, setCurrentRunId] = useState(null); const [waitingForResponse, setWaitingForResponse] = useState(false); const [deletedFromIndex, setDeletedFromIndex] = useState(null); const [interruptRequested, setInterruptRequested] = useState(false); @@ -111,11 +116,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; @@ -171,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: { @@ -184,8 +189,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 @@ -197,7 +202,7 @@ export const useSeerExplorer = () => { setOptimistic(null); setApiQueryData( queryClient, - makeSeerExplorerQueryKey(orgSlug, currentRunId || undefined), + makeSeerExplorerQueryKey(orgSlug, runId || undefined), makeErrorSeerExplorerData(e?.responseJSON?.detail ?? 'An error occurred') ); } @@ -206,34 +211,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; } @@ -241,7 +232,7 @@ export const useSeerExplorer = () => { try { await api.requestPromise( - `/organizations/${orgSlug}/seer/explorer-update/${currentRunId}/`, + `/organizations/${orgSlug}/seer/explorer-update/${runId}/`, { method: 'POST', data: { @@ -255,7 +246,7 @@ 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; @@ -291,7 +282,7 @@ export const useSeerExplorer = () => { ]; const baseSession: NonNullable = sessionData ?? { - run_id: currentRunId ?? undefined, + run_id: runId ?? undefined, blocks: [], status: 'processing', updated_at: new Date().toISOString(), @@ -349,16 +340,43 @@ export const useSeerExplorer = () => { } } + 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, startNewSession, - runId: currentRunId, deleteFromIndex, deletedFromIndex, interruptRun, interruptRequested, }; -}; +} diff --git a/static/app/views/seerExplorer/sessionDropdown.tsx b/static/app/views/seerExplorer/sessionDropdown.tsx index 7954dedb3420ab..ee05cc0e399edb 100644 --- a/static/app/views/seerExplorer/sessionDropdown.tsx +++ b/static/app/views/seerExplorer/sessionDropdown.tsx @@ -15,9 +15,9 @@ import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSe import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; interface SessionDropdownProps { + onSelectSession: (runId: number | null) => void; organization: Organization | null; - onSelectSession?: (runId: number) => void; - runId?: number; + runId: number | null; } function getRelativeTime(dateString: string): string { @@ -53,7 +53,7 @@ function makeSelectOption(session: ExplorerSession): SelectOption { ), value: session.run_id, - textValue: session.title, + textValue: session.title, // Used for search. }; } @@ -96,19 +96,18 @@ export function SessionDropdown({runId, onSelectSession}: SessionDropdownProps) return ( { - if (opt?.value) { - onSelectSession?.(opt.value); - } + onSelectSession(opt.value ?? null); }} options={selectOptions} trigger={makeTrigger} From 86777cd39719bde60540f090b28691483ad17c82 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:18:46 -0800 Subject: [PATCH 04/25] Switch clear cmd to 'new' + fix tests --- .../views/seerExplorer/explorerPanel.spec.tsx | 179 +++++++----------- .../app/views/seerExplorer/explorerPanel.tsx | 2 +- .../hooks/useSeerExplorer.spec.tsx | 72 ++++--- .../app/views/seerExplorer/inputSection.tsx | 7 +- .../app/views/seerExplorer/slashCommands.tsx | 12 +- 5 files changed, 132 insertions(+), 140 deletions(-) diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index 6411874e260487..a6225c0bffd210 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -11,46 +11,78 @@ jest.mock('react-dom', () => ({ createPortal: (node: React.ReactNode) => node, })); +// Mock SessionDropdown to avoid async Popper.js updates in tests +jest.mock('./sessionDropdown', () => ({ + SessionDropdown: () =>
, +})); + 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 +90,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 +106,6 @@ describe('ExplorerPanel', () => { describe('Messages Display', () => { it('renders messages when session data exists', () => { - const organization = OrganizationFixture({ - features: ['seer-explorer'], - }); - const mockSessionData = { blocks: [ { @@ -133,7 +141,6 @@ describe('ExplorerPanel', () => { startNewSession: jest.fn(), isPolling: false, isPending: false, - runId: 123, deletedFromIndex: null, interruptRun: jest.fn(), interruptRequested: false, @@ -154,35 +161,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 +191,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 +220,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 +236,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 +257,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 +286,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 +296,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 9102925da0eb83..ea606c929081da 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -298,7 +298,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onSlashCommandsVisibilityChange={setIsSlashCommandsVisible} onMaxSize={handleMaxSize} onMedSize={handleMedSize} - onClear={startNewSession} + onNew={startNewSession} /> ); diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx index 9e9c9664349fd4..64376cf2005c9a 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx @@ -16,13 +16,16 @@ describe('useSeerExplorer', () => { describe('Initial State', () => { it('returns initial state with no session data', () => { - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); expect(result.current.sessionData).toBeNull(); expect(result.current.isPolling).toBe(false); - expect(result.current.runId).toBeNull(); expect(result.current.deletedFromIndex).toBeNull(); }); }); @@ -76,9 +79,13 @@ describe('useSeerExplorer', () => { }, }); - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); await act(async () => { await result.current.sendMessage('Test query'); @@ -111,9 +118,13 @@ describe('useSeerExplorer', () => { body: {detail: 'Server error'}, }); - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); // Should handle error without throwing await act(async () => { @@ -130,15 +141,18 @@ describe('useSeerExplorer', () => { body: {session: null}, }); - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); act(() => { result.current.startNewSession(); }); - expect(result.current.runId).toBeNull(); expect(result.current.deletedFromIndex).toBeNull(); }); }); @@ -151,9 +165,13 @@ describe('useSeerExplorer', () => { body: {session: null}, }); - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); act(() => { result.current.deleteFromIndex(2); @@ -163,9 +181,13 @@ describe('useSeerExplorer', () => { }); it('filters messages based on deleted index', () => { - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); act(() => { result.current.deleteFromIndex(1); @@ -177,9 +199,13 @@ describe('useSeerExplorer', () => { describe('Polling Logic', () => { it('returns false for polling when no session exists', () => { - const {result} = renderHookWithProviders(() => useSeerExplorer(), { - organization, - }); + const setRunId = jest.fn(); + const {result} = renderHookWithProviders( + () => useSeerExplorer({runId: null, setRunId}), + { + organization, + } + ); expect(result.current.isPolling).toBe(false); }); diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index b3477730257f5c..0d8c2e123b5295 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -10,13 +10,13 @@ interface InputSectionProps { inputValue: string; interruptRequested: boolean; isPolling: boolean; - onClear: () => void; onCommandSelect: (command: SlashCommand) => void; onInputChange: (e: React.ChangeEvent) => void; onInputClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; onMaxSize: () => void; onMedSize: () => void; + onNew: () => void; onSlashCommandsVisibilityChange: (isVisible: boolean) => void; ref?: React.RefObject; } @@ -26,7 +26,7 @@ function InputSection({ focusedBlockIndex, isPolling, interruptRequested, - onClear, + onNew, onInputChange, onKeyDown, onInputClick, @@ -58,7 +58,7 @@ function InputSection({ onVisibilityChange={onSlashCommandsVisibilityChange} onMaxSize={onMaxSize} onMedSize={onMedSize} - onClear={onClear} + onNew={onNew} /> @@ -69,6 +69,7 @@ function InputSection({ onKeyDown={onKeyDown} placeholder={getPlaceholder()} rows={1} + data-test-id="seer-explorer-input" /> {focusedBlockIndex === -1 && } diff --git a/static/app/views/seerExplorer/slashCommands.tsx b/static/app/views/seerExplorer/slashCommands.tsx index 83098a9a7fc4d6..f009551283a0cc 100644 --- a/static/app/views/seerExplorer/slashCommands.tsx +++ b/static/app/views/seerExplorer/slashCommands.tsx @@ -12,10 +12,10 @@ export interface SlashCommand { interface SlashCommandsProps { inputValue: string; - onClear: () => void; onCommandSelect: (command: SlashCommand) => void; onMaxSize: () => void; onMedSize: () => void; + onNew: () => void; onVisibilityChange?: (isVisible: boolean) => void; } @@ -24,7 +24,7 @@ function SlashCommands({ onCommandSelect, onMaxSize, onMedSize, - onClear, + onNew, onVisibilityChange, }: SlashCommandsProps) { const [selectedIndex, setSelectedIndex] = useState(0); @@ -34,9 +34,9 @@ function SlashCommands({ const DEFAULT_COMMANDS = useMemo( (): SlashCommand[] => [ { - command: '/clear', - description: 'Clear conversation and start a new session', - handler: onClear, + command: '/new', + description: 'Start a new session', + handler: onNew, }, { command: '/max-size', @@ -65,7 +65,7 @@ function SlashCommands({ ] : []), ], - [onClear, onMaxSize, onMedSize, openFeedbackForm] + [onNew, onMaxSize, onMedSize, openFeedbackForm] ); // Filter commands based on current input From 91513f4fdd71e8152eaf38589b4dd6b65619afc5 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:50:32 -0800 Subject: [PATCH 05/25] Add new session button, refetch on new chat, share sessions hook --- .../app/views/seerExplorer/explorerPanel.tsx | 14 +++--- .../hooks/useExplorerSessions.tsx | 39 ++++++++-------- .../seerExplorer/hooks/useSeerExplorer.tsx | 5 ++- .../views/seerExplorer/sessionDropdown.tsx | 45 ++++++++++++------- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index ea606c929081da..338e0030fba23d 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -40,7 +40,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Custom hooks const {panelSize, handleMaxSize, handleMedSize} = usePanelSizing(); - const {sessions: allSessions} = useExplorerSessions({enabled: isVisible}); + const sessionsResult = useExplorerSessions({perPage: 20, enabled: isVisible}); const { sessionData, sendMessage, @@ -56,7 +56,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Get active session title for display const activeSessionTitle = useMemo(() => { - const session = allSessions.find(s => s.run_id === runId); + const session = sessionsResult.sessions.find(s => s.run_id === runId); const title = session?.title ?? 'New Session'; const createdDate = session?.created_at @@ -64,7 +64,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { : moment(Date.now()).format('MM/DD h:mm A'); return `${createdDate} - ${title}`.trim(); - }, [runId, allSessions]); + }, [runId, sessionsResult.sessions]); useBlockNavigation({ isOpen: isVisible, @@ -168,7 +168,9 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (inputValue.trim() && !isPolling) { - sendMessage(inputValue.trim()); + sendMessage(inputValue.trim(), undefined, () => { + sessionsResult.refetch(); + }); setInputValue(''); // Reset textarea height if (textareaRef.current) { @@ -237,8 +239,10 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { {organization && ( )} {activeSessionTitle} diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx index a2d06957572b95..331273ed6e3612 100644 --- a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -5,19 +5,27 @@ import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; -interface SeerResponse { +interface RunsResponse { data: ExplorerSession[]; } export function useExplorerSessions({ + perPage, enabled = true, - perPage = 20, }: { + perPage: number; enabled?: boolean; - perPage?: number; }) { const organization = useOrganization({allowNull: true}); - const queryResult = useInfiniteApiQuery({ + const { + data, + isFetching, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useInfiniteApiQuery({ queryKey: [ 'infinite', `/organizations/${organization?.slug ?? ''}/seer/explorer-runs/`, @@ -28,26 +36,21 @@ export function useExplorerSessions({ }, ], enabled: enabled && Boolean(organization), - staleTime: 30_000, // 30 seconds }); - // Deduplicate sessions in case pages overlap - // Access result[0].data since response format is {"data": list[item]} + // Deduplicate sessions in case pages shift (new runs, order changes). const sessions = useMemo( - () => - uniqBy( - queryResult.data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], - 'run_id' - ), - [queryResult.data?.pages] + () => uniqBy(data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], 'run_id'), + [data] ); return { sessions, - isFetching: queryResult.isFetching, - isError: queryResult.isError, - hasNextPage: queryResult.hasNextPage, - fetchNextPage: queryResult.fetchNextPage, - isFetchingNextPage: queryResult.isFetchingNextPage, + isFetching, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, }; } diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 8655504226da91..dad47be7ad073f 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -131,7 +131,7 @@ export function useSeerExplorer({ ); const sendMessage = useCallback( - async (query: string, insertIndex?: number) => { + async (query: string, insertIndex?: number, onNewSession?: () => void) => { if (!orgSlug) { return; } @@ -191,6 +191,7 @@ export function useSeerExplorer({ // Set run ID if this is a new session if (!runId) { setRunId(response.run_id); + onNewSession?.(); } // Invalidate queries to fetch fresh data @@ -340,6 +341,7 @@ export function 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. @@ -373,6 +375,7 @@ export function useSeerExplorer({ isPolling: isPolling(filteredSessionData, waitingForResponse), isPending, sendMessage, + /** Resets the hook state. The session isn't actually created until the user sends a message. */ startNewSession, deleteFromIndex, deletedFromIndex, diff --git a/static/app/views/seerExplorer/sessionDropdown.tsx b/static/app/views/seerExplorer/sessionDropdown.tsx index ee05cc0e399edb..34fab28460fd31 100644 --- a/static/app/views/seerExplorer/sessionDropdown.tsx +++ b/static/app/views/seerExplorer/sessionDropdown.tsx @@ -3,23 +3,18 @@ import styled from '@emotion/styled'; import moment from 'moment-timezone'; import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; import {CompactSelect, type SelectOption} from 'sentry/components/core/compactSelect'; import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {IconChevron} from 'sentry/icons'; +import {IconAdd, IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; -interface SessionDropdownProps { - onSelectSession: (runId: number | null) => void; - organization: Organization | null; - runId: number | null; -} - function getRelativeTime(dateString: string): string { const date = new Date(dateString); const now = new Date(); @@ -57,12 +52,22 @@ function makeSelectOption(session: ExplorerSession): SelectOption { }; } -export function SessionDropdown({runId, onSelectSession}: SessionDropdownProps) { +interface SessionDropdownProps { + activeRunId: number | null; + onSelectSession: (runId: number | null) => void; + organization: Organization | null; + startNewSession: () => void; + useExplorerSessionsResult: ReturnType; +} + +export function SessionDropdown({ + activeRunId, + useExplorerSessionsResult, + onSelectSession, + startNewSession, +}: SessionDropdownProps) { const {sessions, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage} = - useExplorerSessions({ - enabled: true, - perPage: 20, - }); + useExplorerSessionsResult; const selectOptions = sessions.map(makeSelectOption); @@ -94,13 +99,22 @@ export function SessionDropdown({runId, onSelectSession}: SessionDropdownProps) ); + const newSessionButton = activeRunId && ( + + ); + return ( ); } @@ -154,6 +169,4 @@ const SessionMeta = styled(Text)` const FooterWrapper = styled('div')` display: flex; justify-content: center; - padding: ${space(1)}; - border-top: 1px solid ${p => p.theme.border}; `; From 2f282c463ba73ae0781df7a1e97543462bb04e18 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:58:22 -0800 Subject: [PATCH 06/25] Move run id to state to useSeerExplorer --- .../views/seerExplorer/explorerPanel.spec.tsx | 2 + .../app/views/seerExplorer/explorerPanel.tsx | 5 +- .../hooks/useSeerExplorer.spec.tsx | 70 ++++++------------- .../seerExplorer/hooks/useSeerExplorer.tsx | 13 ++-- .../views/seerExplorer/sessionDropdown.tsx | 11 ++- 5 files changed, 40 insertions(+), 61 deletions(-) diff --git a/static/app/views/seerExplorer/explorerPanel.spec.tsx b/static/app/views/seerExplorer/explorerPanel.spec.tsx index a6225c0bffd210..7a88189467c71f 100644 --- a/static/app/views/seerExplorer/explorerPanel.spec.tsx +++ b/static/app/views/seerExplorer/explorerPanel.spec.tsx @@ -144,6 +144,8 @@ describe('ExplorerPanel', () => { deletedFromIndex: null, interruptRun: jest.fn(), interruptRequested: false, + runId: null, + setRunId: jest.fn(), }); render(, {organization}); diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 338e0030fba23d..775289f34e69b3 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -28,7 +28,6 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { 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 [runId, setRunId] = useState(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); const blockRefs = useRef>([]); @@ -49,7 +48,9 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { isPolling, interruptRun, interruptRequested, - } = useSeerExplorer({runId, setRunId}); + runId, + setRunId, + } = useSeerExplorer(); // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx index 64376cf2005c9a..243dd8e6db5265 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx @@ -16,13 +16,9 @@ describe('useSeerExplorer', () => { describe('Initial State', () => { it('returns initial state with no session data', () => { - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); expect(result.current.sessionData).toBeNull(); expect(result.current.isPolling).toBe(false); @@ -79,13 +75,9 @@ describe('useSeerExplorer', () => { }, }); - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); await act(async () => { await result.current.sendMessage('Test query'); @@ -118,13 +110,9 @@ describe('useSeerExplorer', () => { body: {detail: 'Server error'}, }); - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); // Should handle error without throwing await act(async () => { @@ -141,13 +129,9 @@ describe('useSeerExplorer', () => { body: {session: null}, }); - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); act(() => { result.current.startNewSession(); @@ -165,13 +149,9 @@ describe('useSeerExplorer', () => { body: {session: null}, }); - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); act(() => { result.current.deleteFromIndex(2); @@ -181,13 +161,9 @@ describe('useSeerExplorer', () => { }); it('filters messages based on deleted index', () => { - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); act(() => { result.current.deleteFromIndex(1); @@ -199,13 +175,9 @@ describe('useSeerExplorer', () => { describe('Polling Logic', () => { it('returns false for polling when no session exists', () => { - const setRunId = jest.fn(); - const {result} = renderHookWithProviders( - () => useSeerExplorer({runId: null, setRunId}), - { - organization, - } - ); + const {result} = renderHookWithProviders(() => useSeerExplorer(), { + organization, + }); expect(result.current.isPolling).toBe(false); }); diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index dad47be7ad073f..097156d0a68db1 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -90,19 +90,14 @@ const isPolling = (sessionData: SeerExplorerResponse['session'], runStarted: boo ); }; -export function useSeerExplorer({ - runId, - setRunId, -}: { - runId: number | null; - setRunId: (runId: number | null) => void; -}) { +export const useSeerExplorer = () => { const api = useApi(); const queryClient = useQueryClient(); const organization = useOrganization({allowNull: true}); const orgSlug = organization?.slug; const captureAsciiSnapshot = useAsciiSnapshot(); + const [runId, setRunId] = useState(null); const [waitingForResponse, setWaitingForResponse] = useState(false); const [deletedFromIndex, setDeletedFromIndex] = useState(null); const [interruptRequested, setInterruptRequested] = useState(false); @@ -375,6 +370,8 @@ export function useSeerExplorer({ isPolling: isPolling(filteredSessionData, waitingForResponse), isPending, sendMessage, + runId, + setRunId, /** Resets the hook state. The session isn't actually created until the user sends a message. */ startNewSession, deleteFromIndex, @@ -382,4 +379,4 @@ export function useSeerExplorer({ interruptRun, interruptRequested, }; -} +}; diff --git a/static/app/views/seerExplorer/sessionDropdown.tsx b/static/app/views/seerExplorer/sessionDropdown.tsx index 34fab28460fd31..3cc18a449ada03 100644 --- a/static/app/views/seerExplorer/sessionDropdown.tsx +++ b/static/app/views/seerExplorer/sessionDropdown.tsx @@ -100,12 +100,12 @@ export function SessionDropdown({ ); const newSessionButton = activeRunId && ( - + ); return ( @@ -145,6 +145,13 @@ const TriggerButton = styled(Button)` } `; +const NewSessionButton = styled(Button)` + padding-top: 0; + padding-bottom: 0; + border: none; + background: transparent; +`; + const SessionOption = styled('div')` display: flex; flex-direction: column; From cc0048a5b72f669f0dc75b34408b085a86ca0f90 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:16:41 -0800 Subject: [PATCH 07/25] update loading/error --- static/app/views/seerExplorer/sessionDropdown.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/static/app/views/seerExplorer/sessionDropdown.tsx b/static/app/views/seerExplorer/sessionDropdown.tsx index 3cc18a449ada03..c4f3ec1feb6435 100644 --- a/static/app/views/seerExplorer/sessionDropdown.tsx +++ b/static/app/views/seerExplorer/sessionDropdown.tsx @@ -66,7 +66,7 @@ export function SessionDropdown({ onSelectSession, startNewSession, }: SessionDropdownProps) { - const {sessions, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage} = + const {sessions, isFetching, isError, hasNextPage, fetchNextPage, isFetchingNextPage} = useExplorerSessionsResult; const selectOptions = sessions.map(makeSelectOption); @@ -115,9 +115,7 @@ export function SessionDropdown({ menuWidth={350} position="bottom-start" value={activeRunId ?? undefined} - menuTitle={ - isFetching && sessions.length === 0 ? t('Loading...') : t('Session History') - } + menuTitle={t('Session History')} searchPlaceholder={t('Search sessions...')} size="sm" onChange={opt => { @@ -125,7 +123,13 @@ export function SessionDropdown({ }} options={selectOptions} trigger={makeTrigger} - emptyMessage={isFetching ? t('Loading sessions...') : t('No sessions found.')} + emptyMessage={ + isError + ? t('Error loading sessions') + : isFetching + ? t('Loading sessions...') + : t('No sessions found.') + } menuFooter={menuFooter} menuHeaderTrailingItems={newSessionButton} /> From 7f086e3a71dd8c2b6c6f83dc76f6ff479835cdbf Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:50:15 -0800 Subject: [PATCH 08/25] renaming and rm dropdown --- .../{slashCommands.tsx => explorerMenu.tsx} | 58 ++++++++------- .../app/views/seerExplorer/explorerPanel.tsx | 71 ++----------------- .../hooks/useExplorerSessions.tsx | 32 +++------ .../seerExplorer/hooks/useSeerExplorer.tsx | 3 +- .../app/views/seerExplorer/inputSection.tsx | 18 +++-- 5 files changed, 54 insertions(+), 128 deletions(-) rename static/app/views/seerExplorer/{slashCommands.tsx => explorerMenu.tsx} (80%) diff --git a/static/app/views/seerExplorer/slashCommands.tsx b/static/app/views/seerExplorer/explorerMenu.tsx similarity index 80% rename from static/app/views/seerExplorer/slashCommands.tsx rename to static/app/views/seerExplorer/explorerMenu.tsx index f009551283a0cc..0bb34df246c733 100644 --- a/static/app/views/seerExplorer/slashCommands.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -4,54 +4,54 @@ import styled from '@emotion/styled'; import {space} from 'sentry/styles/space'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; -export interface SlashCommand { - command: string; +export interface MenuAction { description: string; handler: () => void; + title: string; } -interface SlashCommandsProps { +interface ExplorerMenuProps { inputValue: string; - onCommandSelect: (command: SlashCommand) => void; + onCommandSelect: (command: MenuAction) => void; onMaxSize: () => void; onMedSize: () => void; onNew: () => void; onVisibilityChange?: (isVisible: boolean) => void; } -function SlashCommands({ +function ExplorerMenu({ inputValue, onCommandSelect, onMaxSize, onMedSize, onNew, onVisibilityChange, -}: SlashCommandsProps) { +}: ExplorerMenuProps) { const [selectedIndex, setSelectedIndex] = useState(0); const openFeedbackForm = useFeedbackForm(); // Default slash commands - const DEFAULT_COMMANDS = useMemo( - (): SlashCommand[] => [ + const DEFAULT_SLASH_COMMANDS = useMemo( + (): MenuAction[] => [ { - command: '/new', + title: '/new', description: 'Start a new session', handler: onNew, }, { - command: '/max-size', + title: '/max-size', description: 'Expand panel to full viewport height', handler: onMaxSize, }, { - command: '/med-size', + title: '/med-size', description: 'Set panel to medium size (default)', handler: onMedSize, }, ...(openFeedbackForm ? [ { - command: '/feedback', + title: '/feedback', description: 'Open feedback form to report issues or suggestions', handler: () => openFeedbackForm({ @@ -70,13 +70,11 @@ function SlashCommands({ // Filter commands based on current input const filteredCommands = useMemo(() => { - if (!inputValue.startsWith('/') || inputValue.includes(' ')) { - return []; - } - const query = inputValue.toLowerCase(); - return DEFAULT_COMMANDS.filter(cmd => cmd.command.toLowerCase().startsWith(query)); - }, [inputValue, DEFAULT_COMMANDS]); + return DEFAULT_SLASH_COMMANDS.filter(cmd => + cmd.title.toLowerCase().startsWith(query) + ); + }, [inputValue, DEFAULT_SLASH_COMMANDS]); // Show suggestions panel const showSuggestions = filteredCommands.length > 0; @@ -135,24 +133,24 @@ function SlashCommands({ } return ( - + {filteredCommands.map((command, index) => ( - onCommandSelect(command)} > - {command.command} - {command.description} - + {command.title} + {command.description} + ))} - + ); } -export default SlashCommands; +export default ExplorerMenu; -const SuggestionsPanel = styled('div')` +const MenuPanel = styled('div')` position: absolute; bottom: 100%; left: ${space(2)}; @@ -167,7 +165,7 @@ const SuggestionsPanel = styled('div')` z-index: 10; `; -const SuggestionItem = styled('div')<{isSelected: boolean}>` +const MenuItem = styled('div')<{isSelected: boolean}>` padding: ${space(1.5)} ${space(2)}; cursor: pointer; background: ${p => (p.isSelected ? p.theme.hover : 'transparent')}; @@ -182,13 +180,13 @@ const SuggestionItem = styled('div')<{isSelected: boolean}>` } `; -const CommandName = styled('div')` +const ActionName = styled('div')` font-weight: 600; color: ${p => p.theme.purple400}; font-size: ${p => p.theme.fontSize.sm}; `; -const CommandDescription = styled('div')` +const ActionDescription = 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.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index a8cba0d5c88a95..c8d04089cb1d0e 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -1,24 +1,16 @@ import {useEffect, useMemo, useRef, useState} from 'react'; import {createPortal} from 'react-dom'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; -import {Flex} from '@sentry/scraps/layout'; -import {Heading} from '@sentry/scraps/text'; - -import {space} from 'sentry/styles/space'; import useOrganization from 'sentry/utils/useOrganization'; import {useBlockNavigation} from './hooks/useBlockNavigation'; -import {useExplorerSessions} from './hooks/useExplorerSessions'; import {usePanelSizing} from './hooks/usePanelSizing'; import {useSeerExplorer} from './hooks/useSeerExplorer'; import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; +import type {MenuAction} from './explorerMenu'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; -import {SessionDropdown} from './sessionDropdown'; -import type {SlashCommand} from './slashCommands'; import type {Block, ExplorerPanelProps} from './types'; function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { @@ -26,7 +18,7 @@ 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 [isMenuOpen, setIsMenuOpen] = useState(false); const [isMinimized, setIsMinimized] = useState(false); // state for slide-down const textareaRef = useRef(null); const scrollContainerRef = useRef(null); @@ -40,7 +32,6 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { // Custom hooks const {panelSize, handleMaxSize, handleMedSize} = usePanelSizing(); - const sessionsResult = useExplorerSessions({perPage: 20, enabled: isVisible}); const { sessionData, sendMessage, @@ -49,25 +40,11 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { isPolling, interruptRun, interruptRequested, - runId, - setRunId, } = useSeerExplorer(); // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); - // Get active session title for display - const activeSessionTitle = useMemo(() => { - const session = sessionsResult.sessions.find(s => s.run_id === runId); - const title = session?.title ?? 'New Session'; - - const createdDate = session?.created_at - ? moment(session.created_at).format('MM/DD h:mm A') - : moment(Date.now()).format('MM/DD h:mm A'); - - return `${createdDate} - ${title}`.trim(); - }, [runId, sessionsResult.sessions]); - useBlockNavigation({ isOpen: isVisible, focusedBlockIndex, @@ -222,9 +199,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (inputValue.trim() && !isPolling) { - sendMessage(inputValue.trim(), undefined, () => { - sessionsResult.refetch(); - }); + sendMessage(inputValue.trim(), undefined); setInputValue(''); // Reset scroll state so we auto-scroll to show the response userScrolledUpRef.current = false; @@ -268,7 +243,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { setIsMinimized(false); }; - const handleCommandSelect = (command: SlashCommand) => { + const handleCommandSelect = (command: MenuAction) => { // Execute the command command.handler(); @@ -290,20 +265,6 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onUnminimize={() => setIsMinimized(false)} > - - - {organization && ( - - )} - {activeSessionTitle} - - {blocks.length === 0 ? ( ) : ( @@ -321,7 +282,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onClick={() => handleBlockClick(index)} onMouseEnter={() => { // Don't change focus while slash commands menu is open or if already on this block - if (isSlashCommandsVisible || hoveredBlockIndex.current === index) { + if (isMenuOpen || hoveredBlockIndex.current === index) { return; } @@ -355,7 +316,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onKeyDown={handleKeyDown} onInputClick={handleInputClick} onCommandSelect={handleCommandSelect} - onSlashCommandsVisibilityChange={setIsSlashCommandsVisible} + onMenuVisibilityChange={setIsMenuOpen} onMaxSize={handleMaxSize} onMedSize={handleMedSize} onNew={startNewSession} @@ -371,24 +332,4 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { return createPortal(panelContent, document.body); } -const SessionHeader = styled('div')` - padding: ${space(2)}; - border-bottom: 1px solid ${p => p.theme.border}; - background: ${p => p.theme.background}; - position: sticky; - top: 0; - z-index: 1; -`; - -const SessionTitle = styled(Heading)` - margin: 0; - font-size: ${p => p.theme.fontSize.lg}; - font-weight: ${p => p.theme.fontWeight.bold}; - color: ${p => p.theme.textColor}; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; -`; - export default ExplorerPanel; diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx index 331273ed6e3612..fb0971ae59a40d 100644 --- a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -1,6 +1,3 @@ -import {useMemo} from 'react'; -import uniqBy from 'lodash/uniqBy'; - import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; @@ -17,15 +14,7 @@ export function useExplorerSessions({ enabled?: boolean; }) { const organization = useOrganization({allowNull: true}); - const { - data, - isFetching, - isError, - hasNextPage, - fetchNextPage, - isFetchingNextPage, - refetch, - } = useInfiniteApiQuery({ + const query = useInfiniteApiQuery({ queryKey: [ 'infinite', `/organizations/${organization?.slug ?? ''}/seer/explorer-runs/`, @@ -38,19 +27,14 @@ export function useExplorerSessions({ enabled: enabled && Boolean(organization), }); - // Deduplicate sessions in case pages shift (new runs, order changes). - const sessions = useMemo( - () => uniqBy(data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], 'run_id'), - [data] - ); + // // Deduplicate sessions in case pages shift (new runs, order changes). + // const sessions = useMemo( + // () => + // uniqBy(query.data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], 'run_id'), + // [query.data] + // ); return { - sessions, - isFetching, - isError, - hasNextPage, - fetchNextPage, - isFetchingNextPage, - refetch, + ...query, }; } diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index 197c36397b8d03..68ee67b1358ca6 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -123,7 +123,7 @@ export const useSeerExplorer = () => { ); const sendMessage = useCallback( - async (query: string, insertIndex?: number, onNewSession?: () => void) => { + async (query: string, insertIndex?: number) => { if (!orgSlug) { return; } @@ -190,7 +190,6 @@ export const useSeerExplorer = () => { // Set run ID if this is a new session if (!runId) { setRunId(response.run_id); - onNewSession?.(); } // Invalidate queries to fetch fresh data diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index 0d8c2e123b5295..f52fbe5b6664b6 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -1,23 +1,25 @@ import styled from '@emotion/styled'; +import {Button} from '@sentry/scraps/button'; + import {IconChevron} from 'sentry/icons'; import {space} from 'sentry/styles/space'; -import SlashCommands, {type SlashCommand} from './slashCommands'; +import ExplorerMenu, {type MenuAction} from './explorerMenu'; interface InputSectionProps { focusedBlockIndex: number; inputValue: string; interruptRequested: boolean; isPolling: boolean; - onCommandSelect: (command: SlashCommand) => void; + onCommandSelect: (command: MenuAction) => void; onInputChange: (e: React.ChangeEvent) => void; onInputClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; onMaxSize: () => void; onMedSize: () => void; + onMenuVisibilityChange: (isVisible: boolean) => void; onNew: () => void; - onSlashCommandsVisibilityChange: (isVisible: boolean) => void; ref?: React.RefObject; } @@ -31,7 +33,7 @@ function InputSection({ onKeyDown, onInputClick, onCommandSelect, - onSlashCommandsVisibilityChange, + onMenuVisibilityChange, onMaxSize, onMedSize, ref, @@ -52,16 +54,18 @@ function InputSection({ return ( - - + Date: Fri, 7 Nov 2025 19:06:27 -0800 Subject: [PATCH 09/25] refactor props to a context --- .../app/views/seerExplorer/explorerMenu.tsx | 35 +++++------ .../app/views/seerExplorer/explorerPanel.tsx | 35 ++++++----- .../seerExplorer/explorerPanelContext.tsx | 34 +++++++++++ .../app/views/seerExplorer/inputSection.tsx | 58 ++++++------------- 4 files changed, 88 insertions(+), 74 deletions(-) create mode 100644 static/app/views/seerExplorer/explorerPanelContext.tsx diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 0bb34df246c733..2b4c4c532cd151 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -4,29 +4,23 @@ import styled from '@emotion/styled'; import {space} from 'sentry/styles/space'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; +import {useExplorerPanelContext} from './explorerPanelContext'; + export interface MenuAction { description: string; handler: () => void; title: string; } -interface ExplorerMenuProps { - inputValue: string; - onCommandSelect: (command: MenuAction) => void; - onMaxSize: () => void; - onMedSize: () => void; - onNew: () => void; - onVisibilityChange?: (isVisible: boolean) => void; -} - -function ExplorerMenu({ - inputValue, - onCommandSelect, - onMaxSize, - onMedSize, - onNew, - onVisibilityChange, -}: ExplorerMenuProps) { +function ExplorerMenu() { + const { + inputValue, + onCommandSelect, + onMaxSize, + onMedSize, + onNew, + onMenuVisibilityChange, + } = useExplorerPanelContext(); const [selectedIndex, setSelectedIndex] = useState(0); const openFeedbackForm = useFeedbackForm(); @@ -70,6 +64,9 @@ function ExplorerMenu({ // Filter commands based on current input const filteredCommands = useMemo(() => { + if (!inputValue.startsWith('/') || inputValue.includes(' ')) { + return []; + } const query = inputValue.toLowerCase(); return DEFAULT_SLASH_COMMANDS.filter(cmd => cmd.title.toLowerCase().startsWith(query) @@ -86,8 +83,8 @@ function ExplorerMenu({ // Notify parent when visibility changes useEffect(() => { - onVisibilityChange?.(showSuggestions); - }, [showSuggestions, onVisibilityChange]); + onMenuVisibilityChange?.(showSuggestions); + }, [showSuggestions, onMenuVisibilityChange]); // Handle keyboard navigation with higher priority const handleKeyDown = useCallback( diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index c8d04089cb1d0e..307a45845860f7 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -9,6 +9,7 @@ import {useSeerExplorer} from './hooks/useSeerExplorer'; import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; import type {MenuAction} from './explorerMenu'; +import ExplorerPanelContext from './explorerPanelContext'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; import type {Block, ExplorerPanelProps} from './types'; @@ -256,6 +257,22 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { } }; + // Useful callbacks and state for the input controls. + const contextValue = { + inputValue, + focusedBlockIndex, + isPolling, + interruptRequested, + onInputChange: handleInputChange, + onKeyDown: handleKeyDown, + onInputClick: handleInputClick, + onCommandSelect: handleCommandSelect, + onMenuVisibilityChange: setIsMenuOpen, + onMaxSize: handleMaxSize, + onMedSize: handleMedSize, + onNew: startNewSession, + }; + const panelContent = ( - + + + ); diff --git a/static/app/views/seerExplorer/explorerPanelContext.tsx b/static/app/views/seerExplorer/explorerPanelContext.tsx new file mode 100644 index 00000000000000..40d03d1a5a5758 --- /dev/null +++ b/static/app/views/seerExplorer/explorerPanelContext.tsx @@ -0,0 +1,34 @@ +import {createContext, useContext} from 'react'; + +import type {MenuAction} from './explorerMenu'; + +interface ExplorerPanelContextType { + focusedBlockIndex: number; + inputValue: string; + interruptRequested: boolean; + isPolling: boolean; + onCommandSelect: (command: MenuAction) => void; + onInputChange: (e: React.ChangeEvent) => void; + onInputClick: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onMaxSize: () => void; + onMedSize: () => void; + onMenuVisibilityChange: (isVisible: boolean) => void; + onNew: () => void; +} + +const ExplorerPanelContext = createContext( + undefined +); + +export function useExplorerPanelContext() { + const context = useContext(ExplorerPanelContext); + if (context === undefined) { + throw new Error( + 'Tried to read uninitialized ExplorerPanelContext. This hook should only be used within an ExplorerPanelContext.Provider' + ); + } + return context; +} + +export default ExplorerPanelContext; diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index f52fbe5b6664b6..a787ee1ce6491e 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -5,39 +5,24 @@ import {Button} from '@sentry/scraps/button'; import {IconChevron} from 'sentry/icons'; import {space} from 'sentry/styles/space'; -import ExplorerMenu, {type MenuAction} from './explorerMenu'; - -interface InputSectionProps { - focusedBlockIndex: number; - inputValue: string; - interruptRequested: boolean; - isPolling: boolean; - onCommandSelect: (command: MenuAction) => void; - onInputChange: (e: React.ChangeEvent) => void; - onInputClick: () => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onMaxSize: () => void; - onMedSize: () => void; - onMenuVisibilityChange: (isVisible: boolean) => void; - onNew: () => void; - ref?: React.RefObject; -} +import ExplorerMenu from './explorerMenu'; +import {useExplorerPanelContext} from './explorerPanelContext'; function InputSection({ - inputValue, - focusedBlockIndex, - isPolling, - interruptRequested, - onNew, - onInputChange, - onKeyDown, - onInputClick, - onCommandSelect, - onMenuVisibilityChange, - onMaxSize, - onMedSize, - ref, -}: InputSectionProps) { + textAreaRef, +}: { + textAreaRef?: React.RefObject; +}) { + const { + inputValue, + focusedBlockIndex, + isPolling, + interruptRequested, + onInputChange, + onKeyDown, + onInputClick, + } = useExplorerPanelContext(); + const getPlaceholder = () => { if (focusedBlockIndex !== -1) { return 'Press Tab ⇥ to return here'; @@ -54,20 +39,13 @@ function InputSection({ return ( - + Date: Fri, 7 Nov 2025 20:38:51 -0800 Subject: [PATCH 10/25] support manual slash cmds --- .../app/views/seerExplorer/explorerMenu.tsx | 237 +++++++++++------- .../app/views/seerExplorer/explorerPanel.tsx | 25 +- .../seerExplorer/explorerPanelContext.tsx | 7 +- .../app/views/seerExplorer/inputSection.tsx | 55 ++-- 4 files changed, 193 insertions(+), 131 deletions(-) diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 2b4c4c532cd151..281b025579598d 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {Activity, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {space} from 'sentry/styles/space'; @@ -6,90 +6,96 @@ import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useExplorerPanelContext} from './explorerPanelContext'; -export interface MenuAction { +export interface MenuItemProps { description: string; handler: () => void; + key: string; title: string; } -function ExplorerMenu() { - const { - inputValue, - onCommandSelect, - onMaxSize, - onMedSize, - onNew, - onMenuVisibilityChange, - } = useExplorerPanelContext(); - const [selectedIndex, setSelectedIndex] = useState(0); - const openFeedbackForm = useFeedbackForm(); +type MenuMode = + | 'slash-commands-keyboard' + | 'slash-commands-manual' + | 'session-history' + | 'hidden'; - // Default slash commands - const DEFAULT_SLASH_COMMANDS = useMemo( - (): MenuAction[] => [ - { - title: '/new', - description: 'Start a new session', - handler: onNew, - }, - { - title: '/max-size', - description: 'Expand panel to full viewport height', - handler: onMaxSize, - }, - { - title: '/med-size', - description: 'Set panel to medium size (default)', - handler: onMedSize, - }, - ...(openFeedbackForm - ? [ - { - title: '/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] - ); +export function useExplorerMenu() { + const {inputValue, clearInput, onMenuVisibilityChange, textAreaRef} = + useExplorerPanelContext(); - // Filter commands based on current input - const filteredCommands = useMemo(() => { + const allSlashCommands = useSlashCommands(); + + const filteredSlashCommands = useMemo(() => { + // Filter commands based on current input if (!inputValue.startsWith('/') || inputValue.includes(' ')) { return []; } const query = inputValue.toLowerCase(); - return DEFAULT_SLASH_COMMANDS.filter(cmd => - cmd.title.toLowerCase().startsWith(query) - ); - }, [inputValue, DEFAULT_SLASH_COMMANDS]); + return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query)); + }, [allSlashCommands, inputValue]); + + // const sessionHistory: MenuItemProps[] = []; + + // Menu items and select handlers change based on the mode. + const [menuMode, setMenuMode] = useState('hidden'); + + const menuItems = useMemo(() => { + switch (menuMode) { + case 'slash-commands-keyboard': + return filteredSlashCommands; + case 'slash-commands-manual': + return allSlashCommands; + // case 'session-history': + // return sessionHistory; + default: + return []; + } + }, [menuMode, allSlashCommands, filteredSlashCommands]); + + const onSelect = useCallback( + (item: MenuItemProps) => { + // Execute custom handler. + item.handler(); + + if (menuMode === 'slash-commands-keyboard') { + // Clear input and reset textarea height // TODO: is this needed for all selects? + clearInput(); + if (textAreaRef.current) { + textAreaRef.current.style.height = 'auto'; + } + } + }, + // clearInput and textAreaRef are both expected to be stable. + [menuMode, clearInput, textAreaRef] + ); + + // 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]); - // Show suggestions panel - const showSuggestions = filteredCommands.length > 0; + const isVisible = menuMode !== 'hidden'; - // Reset selected index when filtered commands change + // Notify parent of menu visibility changes. useEffect(() => { - setSelectedIndex(0); - }, [filteredCommands]); + onMenuVisibilityChange(isVisible); + }, [isVisible, onMenuVisibilityChange]); + + const [selectedIndex, setSelectedIndex] = useState(0); - // Notify parent when visibility changes + // Reset selected index when items change useEffect(() => { - onMenuVisibilityChange?.(showSuggestions); - }, [showSuggestions, onMenuVisibilityChange]); + setSelectedIndex(0); + }, [menuItems]); // Handle keyboard navigation with higher priority const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!showSuggestions) return; + if (!isVisible) return; switch (e.key) { case 'ArrowUp': @@ -100,53 +106,102 @@ function ExplorerMenu() { case 'ArrowDown': e.preventDefault(); e.stopPropagation(); - setSelectedIndex(prev => Math.min(filteredCommands.length - 1, prev + 1)); + setSelectedIndex(prev => Math.min(menuItems.length - 1, prev + 1)); break; case 'Enter': e.preventDefault(); e.stopPropagation(); - if (filteredCommands[selectedIndex]) { - onCommandSelect(filteredCommands[selectedIndex]); + if (menuItems[selectedIndex]) { + onSelect(menuItems[selectedIndex]); } break; default: break; } }, - [showSuggestions, selectedIndex, filteredCommands, onCommandSelect] + [isVisible, selectedIndex, menuItems, onSelect] ); useEffect(() => { - if (showSuggestions) { + 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, showSuggestions]); + }, [handleKeyDown, isVisible]); + + const menu = ( + + + {menuItems.map((command, index) => ( + onSelect(command)} + > + {command.title} + {command.description} + + ))} + + + ); - if (!showSuggestions) { - return null; - } + return { + menu, + menuMode, + setMenuMode, + }; +} + +function useSlashCommands(): MenuItemProps[] { + const {onMaxSize, onMedSize, onNew} = useExplorerPanelContext(); - return ( - - {filteredCommands.map((command, index) => ( - onCommandSelect(command)} - > - {command.title} - {command.description} - - ))} - + const openFeedbackForm = useFeedbackForm(); + + return useMemo( + (): MenuItemProps[] => [ + { + title: '/new', + key: '/new', + description: 'Start a new session', + handler: onNew, + }, + { + 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] ); } -export default ExplorerMenu; - const MenuPanel = styled('div')` position: absolute; bottom: 100%; @@ -177,13 +232,13 @@ const MenuItem = styled('div')<{isSelected: boolean}>` } `; -const ActionName = styled('div')` +const ItemName = styled('div')` font-weight: 600; color: ${p => p.theme.purple400}; font-size: ${p => p.theme.fontSize.sm}; `; -const ActionDescription = styled('div')` +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.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 307a45845860f7..b33617f10483cf 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -8,8 +8,9 @@ import {usePanelSizing} from './hooks/usePanelSizing'; import {useSeerExplorer} from './hooks/useSeerExplorer'; import BlockComponent from './blockComponents'; import EmptyState from './emptyState'; -import type {MenuAction} from './explorerMenu'; -import ExplorerPanelContext from './explorerPanelContext'; +import ExplorerPanelContext, { + type ExplorerPanelContextType, +} from './explorerPanelContext'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; import type {Block, ExplorerPanelProps} from './types'; @@ -244,33 +245,21 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { setIsMinimized(false); }; - const handleCommandSelect = (command: MenuAction) => { - // Execute the command - command.handler(); - - // Clear input - setInputValue(''); - - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - } - }; - // Useful callbacks and state for the input controls. - const contextValue = { + const contextValue: ExplorerPanelContextType = { inputValue, + clearInput: () => setInputValue(''), focusedBlockIndex, isPolling, interruptRequested, onInputChange: handleInputChange, onKeyDown: handleKeyDown, onInputClick: handleInputClick, - onCommandSelect: handleCommandSelect, onMenuVisibilityChange: setIsMenuOpen, onMaxSize: handleMaxSize, onMedSize: handleMedSize, onNew: startNewSession, + textAreaRef: textareaRef, }; const panelContent = ( @@ -324,7 +313,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { )} - + ); diff --git a/static/app/views/seerExplorer/explorerPanelContext.tsx b/static/app/views/seerExplorer/explorerPanelContext.tsx index 40d03d1a5a5758..0fd65147420426 100644 --- a/static/app/views/seerExplorer/explorerPanelContext.tsx +++ b/static/app/views/seerExplorer/explorerPanelContext.tsx @@ -1,13 +1,11 @@ import {createContext, useContext} from 'react'; -import type {MenuAction} from './explorerMenu'; - -interface ExplorerPanelContextType { +export interface ExplorerPanelContextType { + clearInput: () => void; focusedBlockIndex: number; inputValue: string; interruptRequested: boolean; isPolling: boolean; - onCommandSelect: (command: MenuAction) => void; onInputChange: (e: React.ChangeEvent) => void; onInputClick: () => void; onKeyDown: (e: React.KeyboardEvent) => void; @@ -15,6 +13,7 @@ interface ExplorerPanelContextType { onMedSize: () => void; onMenuVisibilityChange: (isVisible: boolean) => void; onNew: () => void; + textAreaRef: React.RefObject; } const ExplorerPanelContext = createContext( diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index a787ee1ce6491e..9cc22651f5c0c7 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -1,28 +1,40 @@ +import {useCallback} from 'react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; -import {IconChevron} from 'sentry/icons'; +import {IconChevron, IconMenu} from 'sentry/icons'; import {space} from 'sentry/styles/space'; -import ExplorerMenu from './explorerMenu'; +import {useExplorerMenu} from './explorerMenu'; import {useExplorerPanelContext} from './explorerPanelContext'; -function InputSection({ - textAreaRef, -}: { - textAreaRef?: React.RefObject; -}) { +function InputSection() { const { inputValue, + clearInput, focusedBlockIndex, isPolling, interruptRequested, onInputChange, onKeyDown, onInputClick, + textAreaRef, } = useExplorerPanelContext(); + const {menu, menuMode, setMenuMode} = useExplorerMenu(); + + const onMenuButtonClick = useCallback(() => { + if (menuMode === 'hidden') { + setMenuMode('slash-commands-manual'); + } else if (menuMode === 'slash-commands-keyboard') { + clearInput(); + setMenuMode('hidden'); + } else { + setMenuMode('hidden'); + } + }, [menuMode, setMenuMode, clearInput]); + const getPlaceholder = () => { if (focusedBlockIndex !== -1) { return 'Press Tab ⇥ to return here'; @@ -33,16 +45,23 @@ function InputSection({ if (isPolling) { return 'Press Esc to interrupt'; } - return 'Type your message or / command and press Enter ↵'; + if (menuMode === 'hidden') { + return 'Type your message or / command and press Enter ↵'; + } + return 'Type your message and press Enter ↵'; }; return ( - + {menu} - p.theme.subText}; - margin-top: 18px; - margin-left: ${space(2)}; - margin-right: ${space(1)}; - flex-shrink: 0; -`; +// const ChevronIcon = styled(IconChevron)` +// color: ${p => p.theme.subText}; +// margin-top: 18px; +// margin-left: ${space(2)}; +// margin-right: ${space(1)}; +// flex-shrink: 0; +// `; const FocusIndicator = styled('div')` position: absolute; From 91c88d6878ac984ea753617a4139efc94764c097 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 7 Nov 2025 21:07:54 -0800 Subject: [PATCH 11/25] impl session switcher, todo style, search --- .../app/views/seerExplorer/explorerMenu.tsx | 75 +++++++++++++++---- .../app/views/seerExplorer/explorerPanel.tsx | 2 + .../seerExplorer/explorerPanelContext.tsx | 1 + .../hooks/useExplorerSessions.tsx | 29 +++---- .../seerExplorer/hooks/useSeerExplorer.tsx | 4 +- 5 files changed, 78 insertions(+), 33 deletions(-) diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 281b025579598d..b95299504532d0 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -1,8 +1,10 @@ import {Activity, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import moment from 'moment-timezone'; import {space} from 'sentry/styles/space'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; +import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; import {useExplorerPanelContext} from './explorerPanelContext'; @@ -23,7 +25,9 @@ export function useExplorerMenu() { const {inputValue, clearInput, onMenuVisibilityChange, textAreaRef} = useExplorerPanelContext(); - const allSlashCommands = useSlashCommands(); + const [menuMode, setMenuMode] = useState('hidden'); + + const allSlashCommands = useSlashCommands({setMenuMode}); const filteredSlashCommands = useMemo(() => { // Filter commands based on current input @@ -34,23 +38,21 @@ export function useExplorerMenu() { return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query)); }, [allSlashCommands, inputValue]); - // const sessionHistory: MenuItemProps[] = []; + const sessions = useSessionHistory({setMenuMode}); // Menu items and select handlers change based on the mode. - const [menuMode, setMenuMode] = useState('hidden'); - const menuItems = useMemo(() => { switch (menuMode) { case 'slash-commands-keyboard': return filteredSlashCommands; case 'slash-commands-manual': return allSlashCommands; - // case 'session-history': - // return sessionHistory; + case 'session-history': + return sessions; default: return []; } - }, [menuMode, allSlashCommands, filteredSlashCommands]); + }, [menuMode, allSlashCommands, filteredSlashCommands, sessions]); const onSelect = useCallback( (item: MenuItemProps) => { @@ -134,16 +136,21 @@ export function useExplorerMenu() { const menu = ( - {menuItems.map((command, index) => ( + {menuItems.map((item, index) => ( onSelect(command)} + onClick={() => onSelect(item)} > - {command.title} - {command.description} + {item.title} + {item.description} ))} + {menuItems.length === 0 && ( + + No results found + + )} ); @@ -155,7 +162,11 @@ export function useExplorerMenu() { }; } -function useSlashCommands(): MenuItemProps[] { +function useSlashCommands({ + setMenuMode, +}: { + setMenuMode: (mode: MenuMode) => void; +}): MenuItemProps[] { const {onMaxSize, onMedSize, onNew} = useExplorerPanelContext(); const openFeedbackForm = useFeedbackForm(); @@ -168,6 +179,14 @@ function useSlashCommands(): MenuItemProps[] { description: 'Start a new session', handler: onNew, }, + { + title: '/resume', + key: '/resume', + description: 'View your session history to resume past sessions', + handler: () => { + setMenuMode('session-history'); + }, + }, { title: '/max-size', key: '/max-size', @@ -198,10 +217,38 @@ function useSlashCommands(): MenuItemProps[] { ] : []), ], - [onNew, onMaxSize, onMedSize, openFeedbackForm] + [onNew, onMaxSize, onMedSize, openFeedbackForm, setMenuMode] ); } +function useSessionHistory({ + setMenuMode, +}: { + setMenuMode: (mode: MenuMode) => void; +}): MenuItemProps[] { + const {data, isPending, isError} = useExplorerSessions({limit: 20}); + const {onResume} = useExplorerPanelContext(); + + const formatDate = (date: string) => { + return moment(date).format('MM/DD/YYYY HH:mm'); + }; + + return useMemo(() => { + if (isPending || isError) { + return []; + } + return data?.data.map(session => ({ + title: session.title, + key: session.run_id.toString(), + description: `Last updated at ${formatDate(session.last_triggered_at)}`, + handler: () => { + onResume(session.run_id); + setMenuMode('hidden'); + }, + })); + }, [data, isPending, isError, onResume, setMenuMode]); +} + const MenuPanel = styled('div')` position: absolute; bottom: 100%; diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index b33617f10483cf..039003bf34a76a 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -42,6 +42,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { isPolling, interruptRun, interruptRequested, + resumeSession, } = useSeerExplorer(); // Get blocks from session data or empty array @@ -259,6 +260,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onMaxSize: handleMaxSize, onMedSize: handleMedSize, onNew: startNewSession, + onResume: resumeSession, textAreaRef: textareaRef, }; diff --git a/static/app/views/seerExplorer/explorerPanelContext.tsx b/static/app/views/seerExplorer/explorerPanelContext.tsx index 0fd65147420426..7ddeecfba09b2b 100644 --- a/static/app/views/seerExplorer/explorerPanelContext.tsx +++ b/static/app/views/seerExplorer/explorerPanelContext.tsx @@ -13,6 +13,7 @@ export interface ExplorerPanelContextType { onMedSize: () => void; onMenuVisibilityChange: (isVisible: boolean) => void; onNew: () => void; + onResume: (runId: number) => void; textAreaRef: React.RefObject; } diff --git a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx index fb0971ae59a40d..e06b30c3b79e75 100644 --- a/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx +++ b/static/app/views/seerExplorer/hooks/useExplorerSessions.tsx @@ -1,38 +1,33 @@ -import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; +import {useApiQuery} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; import type {ExplorerSession} from 'sentry/views/seerExplorer/types'; -interface RunsResponse { +interface SessionsResponse { data: ExplorerSession[]; } export function useExplorerSessions({ - perPage, + limit, enabled = true, }: { - perPage: number; + limit: number; enabled?: boolean; }) { const organization = useOrganization({allowNull: true}); - const query = useInfiniteApiQuery({ - queryKey: [ - 'infinite', + const query = useApiQuery( + [ `/organizations/${organization?.slug ?? ''}/seer/explorer-runs/`, { query: { - per_page: perPage, + per_page: limit, }, }, ], - enabled: enabled && Boolean(organization), - }); - - // // Deduplicate sessions in case pages shift (new runs, order changes). - // const sessions = useMemo( - // () => - // uniqBy(query.data?.pages.flatMap(result => result[0]?.data ?? []) ?? [], 'run_id'), - // [query.data] - // ); + { + 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 68ee67b1358ca6..8d810257c514ba 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -374,8 +374,8 @@ export const useSeerExplorer = () => { isPending, sendMessage, runId, - setRunId, - /** Resets the hook state. The session isn't actually created until the user sends a message. */ + resumeSession: (newRunId: number) => setRunId(newRunId), + /** Resets the run id, display, and hook state. The session isn't actually created until the user sends a message. */ startNewSession, deleteFromIndex, deletedFromIndex, From 1935ca2f3c6ea208e82e08dd89ae84302bee3000 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 8 Nov 2025 02:32:45 -0800 Subject: [PATCH 12/25] works --- .../app/views/seerExplorer/explorerMenu.tsx | 93 +++++---- .../app/views/seerExplorer/explorerPanel.tsx | 170 +++++++++------- .../seerExplorer/explorerPanelContext.tsx | 6 +- .../app/views/seerExplorer/inputSection.tsx | 79 ++++---- .../views/seerExplorer/sessionDropdown.tsx | 183 ------------------ static/app/views/seerExplorer/types.tsx | 6 + 6 files changed, 198 insertions(+), 339 deletions(-) delete mode 100644 static/app/views/seerExplorer/sessionDropdown.tsx diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index b95299504532d0..44683e75c4f01a 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -15,19 +15,11 @@ export interface MenuItemProps { title: string; } -type MenuMode = - | 'slash-commands-keyboard' - | 'slash-commands-manual' - | 'session-history' - | 'hidden'; - -export function useExplorerMenu() { - const {inputValue, clearInput, onMenuVisibilityChange, textAreaRef} = +export function ExplorerMenu() { + const {menuMode, setMenuMode, inputValue, clearInput, textAreaRef} = useExplorerPanelContext(); - const [menuMode, setMenuMode] = useState('hidden'); - - const allSlashCommands = useSlashCommands({setMenuMode}); + const allSlashCommands = useSlashCommands(); const filteredSlashCommands = useMemo(() => { // Filter commands based on current input @@ -38,7 +30,8 @@ export function useExplorerMenu() { return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query)); }, [allSlashCommands, inputValue]); - const sessions = useSessionHistory({setMenuMode}); + const {sessionItems, refetchSessions, isSessionsPending, isSessionsError} = + useSessions(); // Menu items and select handlers change based on the mode. const menuItems = useMemo(() => { @@ -48,11 +41,11 @@ export function useExplorerMenu() { case 'slash-commands-manual': return allSlashCommands; case 'session-history': - return sessions; + return sessionItems; default: return []; } - }, [menuMode, allSlashCommands, filteredSlashCommands, sessions]); + }, [menuMode, allSlashCommands, filteredSlashCommands, sessionItems]); const onSelect = useCallback( (item: MenuItemProps) => { @@ -60,15 +53,24 @@ export function useExplorerMenu() { item.handler(); if (menuMode === 'slash-commands-keyboard') { - // Clear input and reset textarea height // TODO: is this needed for all selects? + // Clear input and reset textarea height. // TODO: is this needed for all selects? clearInput(); if (textAreaRef.current) { textAreaRef.current.style.height = 'auto'; } } + + if (item.key === '/resume') { + // Handle /resume command here since it changes the menu mode. + setMenuMode('session-history'); + refetchSessions(); + } else { + // Close the menu. + setMenuMode('hidden'); + } }, // clearInput and textAreaRef are both expected to be stable. - [menuMode, clearInput, textAreaRef] + [menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions] ); // Toggle between slash-commands-keyboard and hidden modes based on filteredSlashCommands. @@ -82,11 +84,6 @@ export function useExplorerMenu() { const isVisible = menuMode !== 'hidden'; - // Notify parent of menu visibility changes. - useEffect(() => { - onMenuVisibilityChange(isVisible); - }, [isVisible, onMenuVisibilityChange]); - const [selectedIndex, setSelectedIndex] = useState(0); // Reset selected index when items change @@ -133,12 +130,12 @@ export function useExplorerMenu() { return undefined; }, [handleKeyDown, isVisible]); - const menu = ( + return ( {menuItems.map((item, index) => ( onSelect(item)} > @@ -146,27 +143,23 @@ export function useExplorerMenu() { {item.description} ))} - {menuItems.length === 0 && ( + {menuMode === 'session-history' && menuItems.length === 0 && ( - No results found + + {isSessionsPending + ? 'Loading sessions...' + : isSessionsError + ? 'Error loading sessions.' + : 'No session history found.'} + )} ); - - return { - menu, - menuMode, - setMenuMode, - }; } -function useSlashCommands({ - setMenuMode, -}: { - setMenuMode: (mode: MenuMode) => void; -}): MenuItemProps[] { +function useSlashCommands(): MenuItemProps[] { const {onMaxSize, onMedSize, onNew} = useExplorerPanelContext(); const openFeedbackForm = useFeedbackForm(); @@ -183,9 +176,7 @@ function useSlashCommands({ title: '/resume', key: '/resume', description: 'View your session history to resume past sessions', - handler: () => { - setMenuMode('session-history'); - }, + handler: () => {}, // Handled by parent onSelect callback. }, { title: '/max-size', @@ -217,36 +208,40 @@ function useSlashCommands({ ] : []), ], - [onNew, onMaxSize, onMedSize, openFeedbackForm, setMenuMode] + [onNew, onMaxSize, onMedSize, openFeedbackForm] ); } -function useSessionHistory({ - setMenuMode, -}: { - setMenuMode: (mode: MenuMode) => void; -}): MenuItemProps[] { - const {data, isPending, isError} = useExplorerSessions({limit: 20}); +function useSessions() { + const {data, isPending, isError, refetch} = useExplorerSessions({limit: 20}); const {onResume} = useExplorerPanelContext(); const formatDate = (date: string) => { return moment(date).format('MM/DD/YYYY HH:mm'); }; - return useMemo(() => { + const sessionItems = useMemo(() => { if (isPending || isError) { return []; } + return data?.data.map(session => ({ title: session.title, key: session.run_id.toString(), description: `Last updated at ${formatDate(session.last_triggered_at)}`, handler: () => { onResume(session.run_id); - setMenuMode('hidden'); }, })); - }, [data, isPending, isError, onResume, setMenuMode]); + }, [data, isPending, isError, onResume]); + + return { + sessionItems, + isSessionsPending: isPending, + isSessionsError: isError, + isError, + refetchSessions: refetch, + }; } const MenuPanel = styled('div')` diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 039003bf34a76a..b927a245cf540d 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'; @@ -13,14 +13,14 @@ import ExplorerPanelContext, { } from './explorerPanelContext'; import InputSection from './inputSection'; import PanelContainers, {BlocksContainer} from './panelContainers'; -import type {Block, ExplorerPanelProps} from './types'; +import type {Block, ExplorerPanelProps, MenuMode} from './types'; function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { const organization = useOrganization({allowNull: true}); const [inputValue, setInputValue] = useState(''); const [focusedBlockIndex, setFocusedBlockIndex] = useState(-1); // -1 means input is focused - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuMode, setMenuMode] = useState('hidden'); const [isMinimized, setIsMinimized] = useState(false); // state for slide-down const textareaRef = useRef(null); const scrollContainerRef = useRef(null); @@ -169,22 +169,39 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { } }, [focusedBlockIndex]); - // Auto-focus input when user starts typing while a block is focused useEffect(() => { - if (!isVisible) { + if (!isVisible || isMinimized) { return undefined; } + // Global keyboard event listeners for when the panel is open. const handleKeyDown = (e: KeyboardEvent) => { - if (focusedBlockIndex !== -1) { - const isPrintableChar = - e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; + const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; - if (isPrintableChar) { + if (e.key === 'Escape' && isPolling && !interruptRequested) { + e.preventDefault(); + interruptRun(); + } else if (e.key === 'Escape' && menuMode === 'slash-commands-keyboard') { + e.preventDefault(); + setInputValue(''); + setMenuMode('hidden'); + } else if (e.key === 'Escape' && menuMode !== 'hidden') { + e.preventDefault(); + setMenuMode('hidden'); + } 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); + } else if (menuMode === 'slash-commands-manual') { + setMenuMode('slash-commands-keyboard'); + } else if (menuMode === 'session-history') { + setMenuMode('hidden'); } } }; @@ -193,13 +210,18 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [isVisible, focusedBlockIndex]); + }, [ + isVisible, + isPolling, + focusedBlockIndex, + interruptRun, + interruptRequested, + isMinimized, + menuMode, + ]); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape' && isPolling) { - 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(), undefined); @@ -242,9 +264,13 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { setIsMinimized(false); }; - const handlePanelBackgroundClick = () => { + const handlePanelBackgroundClick = useCallback(() => { setIsMinimized(false); - }; + if (menuMode === 'slash-commands-keyboard') { + setInputValue(''); + } + setMenuMode('hidden'); + }, [menuMode]); // Useful callbacks and state for the input controls. const contextValue: ExplorerPanelContextType = { @@ -253,10 +279,10 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { focusedBlockIndex, isPolling, interruptRequested, + menuMode, + setMenuMode, onInputChange: handleInputChange, - onKeyDown: handleKeyDown, onInputClick: handleInputClick, - onMenuVisibilityChange: setIsMenuOpen, onMaxSize: handleMaxSize, onMedSize: handleMedSize, onNew: startNewSession, @@ -265,59 +291,59 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { }; const panelContent = ( - setIsMinimized(false)} - > - - {blocks.length === 0 ? ( - - ) : ( - blocks.map((block: Block, index: number) => ( - { - blockRefs.current[index] = el; - }} - block={block} - blockIndex={index} - isLast={index === blocks.length - 1} - isFocused={focusedBlockIndex === index} - isPolling={isPolling} - onClick={() => handleBlockClick(index)} - onMouseEnter={() => { - // Don't change focus while slash commands menu is open or if already on this block - if (isMenuOpen || hoveredBlockIndex.current === index) { - return; - } - - hoveredBlockIndex.current = index; - setFocusedBlockIndex(index); - if (document.activeElement === textareaRef.current) { - textareaRef.current?.blur(); - } - }} - onMouseLeave={() => { - if (hoveredBlockIndex.current === index) { - hoveredBlockIndex.current = -1; - } - }} - onDelete={() => deleteFromIndex(index)} - onNavigate={() => setIsMinimized(true)} - onRegisterEnterHandler={handler => { - blockEnterHandlers.current.set(index, handler); - }} - /> - )) - )} - - - - - + + setIsMinimized(false)} + > + + {blocks.length === 0 ? ( + + ) : ( + blocks.map((block: Block, index: number) => ( + { + blockRefs.current[index] = el; + }} + block={block} + blockIndex={index} + isLast={index === blocks.length - 1} + isFocused={focusedBlockIndex === index} + isPolling={isPolling} + onClick={() => handleBlockClick(index)} + onMouseEnter={() => { + // Don't change focus while slash commands menu is open or if already on this block + if (menuMode !== 'hidden' || hoveredBlockIndex.current === index) { + return; + } + + hoveredBlockIndex.current = index; + setFocusedBlockIndex(index); + if (document.activeElement === textareaRef.current) { + textareaRef.current?.blur(); + } + }} + onMouseLeave={() => { + if (hoveredBlockIndex.current === index) { + hoveredBlockIndex.current = -1; + } + }} + onDelete={() => deleteFromIndex(index)} + onNavigate={() => setIsMinimized(true)} + onRegisterEnterHandler={handler => { + blockEnterHandlers.current.set(index, handler); + }} + /> + )) + )} + + + + ); if (!organization?.features.includes('seer-explorer') || organization.hideAiFeatures) { diff --git a/static/app/views/seerExplorer/explorerPanelContext.tsx b/static/app/views/seerExplorer/explorerPanelContext.tsx index 7ddeecfba09b2b..9e125b3f50ac85 100644 --- a/static/app/views/seerExplorer/explorerPanelContext.tsx +++ b/static/app/views/seerExplorer/explorerPanelContext.tsx @@ -1,19 +1,21 @@ import {createContext, useContext} from 'react'; +import type {MenuMode} from './types'; + export interface ExplorerPanelContextType { clearInput: () => void; focusedBlockIndex: number; inputValue: string; interruptRequested: boolean; isPolling: boolean; + menuMode: MenuMode; onInputChange: (e: React.ChangeEvent) => void; onInputClick: () => void; - onKeyDown: (e: React.KeyboardEvent) => void; onMaxSize: () => void; onMedSize: () => void; - onMenuVisibilityChange: (isVisible: boolean) => void; onNew: () => void; onResume: (runId: number) => void; + setMenuMode: (mode: MenuMode) => void; textAreaRef: React.RefObject; } diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index 9cc22651f5c0c7..7a9e705bb639f7 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -3,13 +3,17 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; -import {IconChevron, IconMenu} from 'sentry/icons'; +import {IconMenu} from 'sentry/icons'; import {space} from 'sentry/styles/space'; -import {useExplorerMenu} from './explorerMenu'; +import {ExplorerMenu} from './explorerMenu'; import {useExplorerPanelContext} from './explorerPanelContext'; -function InputSection() { +function InputSection({ + onKeyDown, +}: { + onKeyDown: (e: React.KeyboardEvent) => void; +}) { const { inputValue, clearInput, @@ -17,24 +21,12 @@ function InputSection() { isPolling, interruptRequested, onInputChange, - onKeyDown, onInputClick, textAreaRef, + menuMode, + setMenuMode, } = useExplorerPanelContext(); - const {menu, menuMode, setMenuMode} = useExplorerMenu(); - - const onMenuButtonClick = useCallback(() => { - if (menuMode === 'hidden') { - setMenuMode('slash-commands-manual'); - } else if (menuMode === 'slash-commands-keyboard') { - clearInput(); - setMenuMode('hidden'); - } else { - setMenuMode('hidden'); - } - }, [menuMode, setMenuMode, clearInput]); - const getPlaceholder = () => { if (focusedBlockIndex !== -1) { return 'Press Tab ⇥ to return here'; @@ -51,18 +43,30 @@ function InputSection() { return 'Type your message and press Enter ↵'; }; + const onMenuButtonClick = useCallback(() => { + if (menuMode === 'hidden') { + setMenuMode('slash-commands-manual'); + } else if (menuMode === 'slash-commands-keyboard') { + clearInput(); + setMenuMode('hidden'); + } else { + setMenuMode('hidden'); + } + }, [menuMode, setMenuMode, clearInput]); + return ( - {menu} + - + + - - )} - - ); - - const newSessionButton = activeRunId && ( - - - - {t('New')} - - - ); - - return ( - { - onSelectSession(opt.value ?? null); - }} - options={selectOptions} - trigger={makeTrigger} - emptyMessage={ - isError - ? t('Error loading sessions') - : isFetching - ? t('Loading sessions...') - : t('No sessions found.') - } - menuFooter={menuFooter} - menuHeaderTrailingItems={newSessionButton} - /> - ); -} - -const TriggerButton = styled(Button)` - display: flex; - padding: ${space(0.75)} ${space(1.5)}; - border: 1px solid ${p => p.theme.border}; - border-radius: ${p => p.theme.borderRadius}; - background: ${p => p.theme.background}; - max-width: 300px; - - &:hover { - background: ${p => p.theme.hover}; - } -`; - -const NewSessionButton = styled(Button)` - padding-top: 0; - padding-bottom: 0; - border: none; - background: transparent; -`; - -const SessionOption = styled('div')` - display: flex; - flex-direction: column; - gap: ${space(0.5)}; - padding: ${space(0.5)} 0; - min-width: 0; -`; - -const SessionTitle = styled(Text)` - font-size: ${p => p.theme.fontSize.sm}; - line-height: 1.4; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const SessionMeta = styled(Text)` - font-size: ${p => p.theme.fontSize.xs}; - color: ${p => p.theme.subText}; -`; - -const FooterWrapper = styled('div')` - display: flex; - justify-content: center; -`; diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 07e56f04142d03..b093c9e88f0660 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -35,3 +35,9 @@ export interface ExplorerSession { run_id: number; title: string; // ISO date string } + +export type MenuMode = + | 'slash-commands-keyboard' + | 'slash-commands-manual' + | 'session-history' + | 'hidden'; From 4195d6649b6db32dd5afe99c3b75512c8ba3c248 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 8 Nov 2025 02:47:21 -0800 Subject: [PATCH 13/25] adjust max height --- static/app/views/seerExplorer/explorerMenu.tsx | 11 +++++++---- static/app/views/seerExplorer/explorerPanel.tsx | 1 + .../app/views/seerExplorer/explorerPanelContext.tsx | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 44683e75c4f01a..033b22ab086a31 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -16,7 +16,7 @@ export interface MenuItemProps { } export function ExplorerMenu() { - const {menuMode, setMenuMode, inputValue, clearInput, textAreaRef} = + const {menuMode, setMenuMode, inputValue, clearInput, textAreaRef, panelSize} = useExplorerPanelContext(); const allSlashCommands = useSlashCommands(); @@ -132,7 +132,7 @@ export function ExplorerMenu() { return ( - + {menuItems.map((item, index) => ( ` position: absolute; bottom: 100%; left: ${space(2)}; @@ -254,7 +256,8 @@ const MenuPanel = styled('div')` border-bottom: none; border-radius: ${p => p.theme.borderRadius}; box-shadow: ${p => p.theme.dropShadowHeavy}; - max-height: 500px; + max-height: ${p => + p.panelSize === 'max' ? 'calc(100vh - 120px)' : `calc(50vh - 80px)`}; overflow-y: auto; z-index: 10; `; diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index b927a245cf540d..933d7061a4cd69 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -288,6 +288,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onNew: startNewSession, onResume: resumeSession, textAreaRef: textareaRef, + panelSize, }; const panelContent = ( diff --git a/static/app/views/seerExplorer/explorerPanelContext.tsx b/static/app/views/seerExplorer/explorerPanelContext.tsx index 9e125b3f50ac85..fd215c08a2b46d 100644 --- a/static/app/views/seerExplorer/explorerPanelContext.tsx +++ b/static/app/views/seerExplorer/explorerPanelContext.tsx @@ -15,6 +15,7 @@ export interface ExplorerPanelContextType { onMedSize: () => void; onNew: () => void; onResume: (runId: number) => void; + panelSize: 'max' | 'med'; setMenuMode: (mode: MenuMode) => void; textAreaRef: React.RefObject; } From e952d9810a64de5b28aa564e1bb32dd0d5af4dcb Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 8 Nov 2025 03:07:31 -0800 Subject: [PATCH 14/25] better focusing --- .../app/views/seerExplorer/explorerMenu.tsx | 71 ++++++++++++------- .../app/views/seerExplorer/explorerPanel.tsx | 16 +---- .../app/views/seerExplorer/inputSection.tsx | 58 +++++++-------- 3 files changed, 75 insertions(+), 70 deletions(-) diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 033b22ab086a31..1185fefc101ef2 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -16,8 +16,15 @@ export interface MenuItemProps { } export function ExplorerMenu() { - const {menuMode, setMenuMode, inputValue, clearInput, textAreaRef, panelSize} = - useExplorerPanelContext(); + const { + menuMode, + setMenuMode, + inputValue, + clearInput, + onInputClick, + textAreaRef, + panelSize, + } = useExplorerPanelContext(); const allSlashCommands = useSlashCommands(); @@ -47,6 +54,11 @@ export function ExplorerMenu() { } }, [menuMode, allSlashCommands, filteredSlashCommands, sessionItems]); + const closeMenu = useCallback(() => { + setMenuMode('hidden'); + onInputClick(); + }, [setMenuMode, onInputClick]); + const onSelect = useCallback( (item: MenuItemProps) => { // Execute custom handler. @@ -66,21 +78,21 @@ export function ExplorerMenu() { refetchSessions(); } else { // Close the menu. - setMenuMode('hidden'); + closeMenu(); } }, // clearInput and textAreaRef are both expected to be stable. - [menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions] + [menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions, closeMenu] ); // Toggle between slash-commands-keyboard and hidden modes based on filteredSlashCommands. useEffect(() => { if (menuMode === 'slash-commands-keyboard' && filteredSlashCommands.length === 0) { - setMenuMode('hidden'); + closeMenu(); } else if (menuMode === 'hidden' && filteredSlashCommands.length > 0) { setMenuMode('slash-commands-keyboard'); } - }, [menuMode, setMenuMode, filteredSlashCommands]); + }, [menuMode, setMenuMode, closeMenu, filteredSlashCommands]); const isVisible = menuMode !== 'hidden'; @@ -96,29 +108,38 @@ export function ExplorerMenu() { (e: KeyboardEvent) => { if (!isVisible) return; - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - e.stopPropagation(); - setSelectedIndex(prev => Math.max(0, prev - 1)); - break; - case 'ArrowDown': - e.preventDefault(); - e.stopPropagation(); - setSelectedIndex(prev => Math.min(menuItems.length - 1, prev + 1)); - break; - case 'Enter': + 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(); + closeMenu(); + if (menuMode === 'slash-commands-keyboard') { + clearInput(); + } + } else if (isPrintableChar) { + if (menuMode !== 'slash-commands-keyboard') { e.preventDefault(); e.stopPropagation(); - if (menuItems[selectedIndex]) { - onSelect(menuItems[selectedIndex]); - } - break; - default: - break; + closeMenu(); + } } }, - [isVisible, selectedIndex, menuItems, onSelect] + [isVisible, selectedIndex, menuItems, onSelect, clearInput, menuMode, closeMenu] ); useEffect(() => { diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index 933d7061a4cd69..ca52b7dbc044db 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -170,24 +170,18 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { }, [focusedBlockIndex]); useEffect(() => { - if (!isVisible || isMinimized) { + if (!isVisible || isMinimized || menuMode !== 'hidden') { return undefined; } - // Global keyboard event listeners for when the panel is open. + // 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' && menuMode === 'slash-commands-keyboard') { - e.preventDefault(); - setInputValue(''); - setMenuMode('hidden'); - } else if (e.key === 'Escape' && menuMode !== 'hidden') { - e.preventDefault(); - setMenuMode('hidden'); } else if (e.key === 'Escape') { e.preventDefault(); setIsMinimized(true); @@ -198,10 +192,6 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { setFocusedBlockIndex(-1); textareaRef.current?.focus(); setInputValue(prev => prev + e.key); - } else if (menuMode === 'slash-commands-manual') { - setMenuMode('slash-commands-keyboard'); - } else if (menuMode === 'session-history') { - setMenuMode('hidden'); } } }; diff --git a/static/app/views/seerExplorer/inputSection.tsx b/static/app/views/seerExplorer/inputSection.tsx index 7a9e705bb639f7..626f9ec5dfd387 100644 --- a/static/app/views/seerExplorer/inputSection.tsx +++ b/static/app/views/seerExplorer/inputSection.tsx @@ -37,48 +37,47 @@ function InputSection({ if (isPolling) { return 'Press Esc to interrupt'; } - if (menuMode === 'hidden') { - return 'Type your message or / command and press Enter ↵'; - } - return 'Type your message and press Enter ↵'; + return 'Type your message or / command and press Enter ↵'; }; const onMenuButtonClick = useCallback(() => { if (menuMode === 'hidden') { setMenuMode('slash-commands-manual'); + textAreaRef.current?.blur(); } else if (menuMode === 'slash-commands-keyboard') { clearInput(); setMenuMode('hidden'); + onInputClick(); } else { setMenuMode('hidden'); + onInputClick(); } - }, [menuMode, setMenuMode, clearInput]); + }, [menuMode, setMenuMode, clearInput, textAreaRef, onInputClick]); return ( - - - - -