-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(explorer): update menu with chonk button and session history switcher #103135
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
a2b6a5e
ui v0 with mock data
aliu39 352fc7f
dropdown mock ui
aliu39 37aa5ff
Change session on select and clear
aliu39 86777cd
Switch clear cmd to 'new' + fix tests
aliu39 b3dca7d
Merge branch 'master' of github.com:getsentry/sentry into aliu/runs-ui
aliu39 91513f4
Add new session button, refetch on new chat, share sessions hook
aliu39 2f282c4
Move run id to state to useSeerExplorer
aliu39 cc0048a
update loading/error
aliu39 e4d211a
Merge branch 'master' into aliu/runs-ui
aliu39 7f086e3
renaming and rm dropdown
aliu39 b49b2c8
refactor props to a context
aliu39 58c9b90
support manual slash cmds
aliu39 91c88d6
impl session switcher, todo style, search
aliu39 1935ca2
works
aliu39 4195d66
adjust max height
aliu39 e952d98
better focusing
aliu39 4f519b4
close on input click
aliu39 ae94b93
dont prevent default on type
aliu39 7b05178
Merge branch 'master' of github.com:getsentry/sentry into aliu/runs-ui2
aliu39 881f1af
rename, tidy
aliu39 909105d
fix jest
aliu39 545008c
comment
aliu39 8e8e0c3
ref to not use context
aliu39 d928177
timezone
aliu39 6e2b3d3
dont export menu types
aliu39 29f93e3
disable sessions req if panel hidden
aliu39 9d17776
bot review
aliu39 acb1492
feedback
aliu39 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,375 @@ | ||
| import {Activity, useCallback, useEffect, useMemo, useState} from 'react'; | ||
| import styled from '@emotion/styled'; | ||
|
|
||
| import {DateTime} from 'sentry/components/dateTime'; | ||
| import {space} from 'sentry/styles/space'; | ||
| import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; | ||
| import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; | ||
|
|
||
| type MenuMode = | ||
| | 'slash-commands-keyboard' | ||
| | 'slash-commands-manual' | ||
| | 'session-history' | ||
| | 'hidden'; | ||
|
|
||
| interface ExplorerMenuProps { | ||
| clearInput: () => void; | ||
| focusInput: () => void; | ||
| inputValue: string; | ||
| onChangeSession: (runId: number) => void; | ||
| panelSize: 'max' | 'med'; | ||
| panelVisible: boolean; | ||
| slashCommandHandlers: { | ||
| onMaxSize: () => void; | ||
| onMedSize: () => void; | ||
| onNew: () => void; | ||
| }; | ||
| textAreaRef: React.RefObject<HTMLTextAreaElement | null>; | ||
| } | ||
|
|
||
| interface MenuItemProps { | ||
| description: string | React.ReactNode; | ||
| handler: () => void; | ||
| key: string; | ||
| title: string; | ||
| } | ||
|
|
||
| export function useExplorerMenu({ | ||
| clearInput, | ||
| inputValue, | ||
| focusInput, | ||
| textAreaRef, | ||
| panelSize, | ||
| panelVisible, | ||
| slashCommandHandlers, | ||
| onChangeSession, | ||
| }: ExplorerMenuProps) { | ||
| const [menuMode, setMenuMode] = useState<MenuMode>('hidden'); | ||
|
|
||
| const allSlashCommands = useSlashCommands(slashCommandHandlers); | ||
|
|
||
| const filteredSlashCommands = useMemo(() => { | ||
| // Filter commands based on current input | ||
| if (!inputValue.startsWith('/') || inputValue.includes(' ')) { | ||
| return []; | ||
| } | ||
| const query = inputValue.toLowerCase(); | ||
| return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query)); | ||
| }, [allSlashCommands, inputValue]); | ||
|
|
||
| const {sessionItems, refetchSessions, isSessionsPending, isSessionsError} = useSessions( | ||
| {onChangeSession, enabled: panelVisible} | ||
| ); | ||
|
|
||
| // Menu items and select handlers change based on the mode. | ||
| const menuItems = useMemo(() => { | ||
| switch (menuMode) { | ||
| case 'slash-commands-keyboard': | ||
| return filteredSlashCommands; | ||
| case 'slash-commands-manual': | ||
| return allSlashCommands; | ||
| case 'session-history': | ||
| return sessionItems; | ||
| default: | ||
| return []; | ||
| } | ||
| }, [menuMode, allSlashCommands, filteredSlashCommands, sessionItems]); | ||
|
|
||
| const close = useCallback(() => { | ||
| setMenuMode('hidden'); | ||
| if (menuMode === 'slash-commands-keyboard') { | ||
| // Clear input and reset textarea height. | ||
| clearInput(); | ||
| if (textAreaRef.current) { | ||
| textAreaRef.current.style.height = 'auto'; | ||
| } | ||
| } | ||
| }, [menuMode, setMenuMode, clearInput, textAreaRef]); | ||
|
|
||
| const closeAndFocusInput = useCallback(() => { | ||
| close(); | ||
| focusInput(); | ||
| }, [close, focusInput]); | ||
|
|
||
| const onSelect = useCallback( | ||
| (item: MenuItemProps) => { | ||
| // Execute custom handler. | ||
| item.handler(); | ||
|
|
||
| if (menuMode === 'slash-commands-keyboard') { | ||
| // Clear input and reset textarea height. | ||
| clearInput(); | ||
| if (textAreaRef.current) { | ||
| textAreaRef.current.style.height = 'auto'; | ||
| } | ||
| } | ||
|
|
||
| if (item.key === '/resume') { | ||
| // Handle /resume command here - avoid changing menu state from item handlers. | ||
| setMenuMode('session-history'); | ||
| refetchSessions(); | ||
| } else { | ||
| // Default to closing the menu after an item is selected and handled. | ||
| closeAndFocusInput(); | ||
| } | ||
| }, | ||
| // clearInput and textAreaRef are both expected to be stable. | ||
| [menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions, closeAndFocusInput] | ||
| ); | ||
|
|
||
| // Toggle between slash-commands-keyboard and hidden modes based on filteredSlashCommands. | ||
| useEffect(() => { | ||
| if (menuMode === 'slash-commands-keyboard' && filteredSlashCommands.length === 0) { | ||
| setMenuMode('hidden'); | ||
| } else if (menuMode === 'hidden' && filteredSlashCommands.length > 0) { | ||
| setMenuMode('slash-commands-keyboard'); | ||
| } | ||
| }, [menuMode, setMenuMode, filteredSlashCommands]); | ||
|
|
||
| const isVisible = menuMode !== 'hidden'; | ||
|
|
||
| const [selectedIndex, setSelectedIndex] = useState(0); | ||
|
|
||
| // Reset selected index when items change | ||
| useEffect(() => { | ||
| setSelectedIndex(0); | ||
| }, [menuItems]); | ||
|
|
||
| // Handle keyboard navigation with higher priority | ||
| const handleKeyDown = useCallback( | ||
| (e: KeyboardEvent) => { | ||
| if (!isVisible) return; | ||
|
|
||
| const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; | ||
|
|
||
| if (e.key === 'ArrowUp') { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| setSelectedIndex(prev => Math.max(0, prev - 1)); | ||
| } else if (e.key === 'ArrowDown') { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| setSelectedIndex(prev => Math.min(menuItems.length - 1, prev + 1)); | ||
| } else if (e.key === 'Enter') { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| if (menuItems[selectedIndex]) { | ||
| onSelect(menuItems[selectedIndex]); | ||
| } | ||
| } else if (e.key === 'Escape') { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| closeAndFocusInput(); | ||
| if (menuMode === 'slash-commands-keyboard') { | ||
| clearInput(); | ||
| } | ||
| } else if (isPrintableChar && menuMode !== 'slash-commands-keyboard') { | ||
| closeAndFocusInput(); | ||
| } | ||
| }, | ||
| [ | ||
| isVisible, | ||
| selectedIndex, | ||
| menuItems, | ||
| onSelect, | ||
| clearInput, | ||
| menuMode, | ||
| closeAndFocusInput, | ||
| ] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (isVisible) { | ||
| // Use capture phase to intercept events before they reach other handlers | ||
| document.addEventListener('keydown', handleKeyDown, true); | ||
| return () => document.removeEventListener('keydown', handleKeyDown, true); | ||
| } | ||
| return undefined; | ||
| }, [handleKeyDown, isVisible]); | ||
|
|
||
| const menu = ( | ||
| <Activity mode={isVisible ? 'visible' : 'hidden'}> | ||
| <MenuPanel panelSize={panelSize}> | ||
| {menuItems.map((item, index) => ( | ||
| <MenuItem | ||
| key={item.key} | ||
| isSelected={index === selectedIndex} | ||
| onClick={() => onSelect(item)} | ||
| > | ||
| <ItemName>{item.title}</ItemName> | ||
| <ItemDescription>{item.description}</ItemDescription> | ||
| </MenuItem> | ||
| ))} | ||
| {menuMode === 'session-history' && menuItems.length === 0 && ( | ||
| <MenuItem key="empty-state" isSelected={false}> | ||
| <ItemName> | ||
| {isSessionsPending | ||
| ? 'Loading sessions...' | ||
| : isSessionsError | ||
| ? 'Error loading sessions.' | ||
| : 'No session history found.'} | ||
| </ItemName> | ||
| </MenuItem> | ||
| )} | ||
| </MenuPanel> | ||
| </Activity> | ||
| ); | ||
|
|
||
| // Handler for the button entrypoint. | ||
| const onMenuButtonClick = useCallback(() => { | ||
| if (menuMode === 'hidden') { | ||
| setMenuMode('slash-commands-manual'); | ||
| } else { | ||
| close(); | ||
| } | ||
| }, [menuMode, setMenuMode, close]); | ||
|
|
||
| return { | ||
| menu, | ||
| menuMode, | ||
| isMenuOpen: menuMode !== 'hidden', | ||
| closeMenu: close, | ||
| onMenuButtonClick, | ||
| }; | ||
| } | ||
|
|
||
| function useSlashCommands({ | ||
| onMaxSize, | ||
| onMedSize, | ||
| onNew, | ||
| }: { | ||
| onMaxSize: () => void; | ||
| onMedSize: () => void; | ||
| onNew: () => void; | ||
| }): MenuItemProps[] { | ||
| const openFeedbackForm = useFeedbackForm(); | ||
|
|
||
| return useMemo( | ||
| (): MenuItemProps[] => [ | ||
| { | ||
| title: '/new', | ||
| key: '/new', | ||
| description: 'Start a new session', | ||
| handler: onNew, | ||
| }, | ||
| { | ||
| title: '/resume', | ||
| key: '/resume', | ||
| description: 'View your session history to resume past sessions', | ||
| handler: () => {}, // Handled by parent onSelect callback. | ||
| }, | ||
| { | ||
| title: '/max-size', | ||
| key: '/max-size', | ||
| description: 'Expand panel to full viewport height', | ||
| handler: onMaxSize, | ||
| }, | ||
| { | ||
| title: '/med-size', | ||
| key: '/med-size', | ||
| description: 'Set panel to medium size (default)', | ||
| handler: onMedSize, | ||
| }, | ||
| ...(openFeedbackForm | ||
| ? [ | ||
| { | ||
| title: '/feedback', | ||
| key: '/feedback', | ||
| description: 'Open feedback form to report issues or suggestions', | ||
| handler: () => | ||
| openFeedbackForm({ | ||
| formTitle: 'Seer Explorer Feedback', | ||
| messagePlaceholder: 'How can we make Seer Explorer better for you?', | ||
| tags: { | ||
| ['feedback.source']: 'seer_explorer', | ||
| }, | ||
| }), | ||
| }, | ||
| ] | ||
| : []), | ||
| ], | ||
| [onNew, onMaxSize, onMedSize, openFeedbackForm] | ||
| ); | ||
| } | ||
|
|
||
| function useSessions({ | ||
| onChangeSession, | ||
| enabled, | ||
| }: { | ||
| onChangeSession: (runId: number) => void; | ||
| enabled?: boolean; | ||
| }) { | ||
| const {data, isPending, isError, refetch} = useExplorerSessions({limit: 20, enabled}); | ||
|
|
||
| const sessionItems = useMemo(() => { | ||
| if (isPending || isError) { | ||
| return []; | ||
| } | ||
|
|
||
| return data.data.map(session => ({ | ||
| title: session.title, | ||
| key: session.run_id.toString(), | ||
| description: ( | ||
| <span> | ||
| Last updated at <DateTime date={session.last_triggered_at} /> | ||
| </span> | ||
| ), | ||
| handler: () => { | ||
| onChangeSession(session.run_id); | ||
| }, | ||
| })); | ||
| }, [data, isPending, isError, onChangeSession]); | ||
aliu39 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return { | ||
| sessionItems, | ||
| isSessionsPending: isPending, | ||
| isSessionsError: isError, | ||
| isError, | ||
| refetchSessions: refetch, | ||
| }; | ||
| } | ||
|
|
||
| const MenuPanel = styled('div')<{ | ||
| panelSize: 'max' | 'med'; | ||
| }>` | ||
| position: absolute; | ||
| bottom: 100%; | ||
| left: ${space(2)}; | ||
| width: 300px; | ||
| background: ${p => p.theme.background}; | ||
| border: 1px solid ${p => p.theme.border}; | ||
| border-bottom: none; | ||
| border-radius: ${p => p.theme.borderRadius}; | ||
| box-shadow: ${p => p.theme.dropShadowHeavy}; | ||
| max-height: ${p => | ||
| p.panelSize === 'max' ? 'calc(100vh - 120px)' : `calc(50vh - 80px)`}; | ||
| overflow-y: auto; | ||
| z-index: 10; | ||
| `; | ||
|
|
||
| const MenuItem = styled('div')<{isSelected: boolean}>` | ||
| padding: ${space(1.5)} ${space(2)}; | ||
| cursor: pointer; | ||
| background: ${p => (p.isSelected ? p.theme.hover : 'transparent')}; | ||
| border-bottom: 1px solid ${p => p.theme.border}; | ||
|
|
||
| &:last-child { | ||
| border-bottom: none; | ||
| } | ||
|
|
||
| &:hover { | ||
| background: ${p => p.theme.hover}; | ||
| } | ||
| `; | ||
|
|
||
| const ItemName = styled('div')` | ||
| font-weight: 600; | ||
| color: ${p => p.theme.purple400}; | ||
| font-size: ${p => p.theme.fontSize.sm}; | ||
| `; | ||
|
|
||
| const ItemDescription = styled('div')` | ||
| color: ${p => p.theme.subText}; | ||
| font-size: ${p => p.theme.fontSize.xs}; | ||
| margin-top: 2px; | ||
| `; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
Activityis imported from 'react' but is not exported by React, resulting inundefinedwhen used as a JSX component.Severity: CRITICAL | Confidence: 1.00
🔍 Detailed Analysis
When the
ExplorerMenucomponent renders, React attempts to render<Activity>. SinceActivityis incorrectly imported from 'react' (which does not export it), it will beundefined. This leads to a runtime error:Error: Cannot use 'Activity' as a JSX component. 'Activity' is 'undefined', causing the ExplorerMenu feature to crash and break the UI.💡 Suggested Fix
Either define
Activityas a styled component withinexplorerMenu.tsxor import it from its correct source, as React does not export it.🤖 Prompt for AI Agent
Did we get this right? 👍 / 👎 to inform future reviews.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a fancy new thing from react 19 I wanted to try out, we should be able to use it right?
https://react.dev/reference/react/Activity
I thought it's good optimization, since the state updates, session fetching, slash command filtering etc can keep going on in the background even while the menu's closed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'm not sure what version of react we're on, i also have no idea how this thing works
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
based on package.json and dev-ui workng I think we can use.
see the reference - basically react has different "priorities" for rendering work, state updates, data fetching etc. AFAIK is just like hiding a component with CSS display="none", but all the work for it is scheduled on the lowest priority. So the work is still done in the background when it's hidden and the rest of the UI isn't busy. Then when switching to "visible" the component is immediately ready for use.
There's a good chance all this is unnoticeable for such a simple component but wanted to take this chance to try it 🚀