Skip to content
Merged
Show file tree
Hide file tree
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 Oct 30, 2025
352fc7f
dropdown mock ui
aliu39 Nov 3, 2025
37aa5ff
Change session on select and clear
aliu39 Nov 3, 2025
86777cd
Switch clear cmd to 'new' + fix tests
aliu39 Nov 3, 2025
b3dca7d
Merge branch 'master' of github.com:getsentry/sentry into aliu/runs-ui
aliu39 Nov 3, 2025
91513f4
Add new session button, refetch on new chat, share sessions hook
aliu39 Nov 3, 2025
2f282c4
Move run id to state to useSeerExplorer
aliu39 Nov 3, 2025
cc0048a
update loading/error
aliu39 Nov 3, 2025
e4d211a
Merge branch 'master' into aliu/runs-ui
aliu39 Nov 7, 2025
7f086e3
renaming and rm dropdown
aliu39 Nov 8, 2025
b49b2c8
refactor props to a context
aliu39 Nov 8, 2025
58c9b90
support manual slash cmds
aliu39 Nov 8, 2025
91c88d6
impl session switcher, todo style, search
aliu39 Nov 8, 2025
1935ca2
works
aliu39 Nov 8, 2025
4195d66
adjust max height
aliu39 Nov 8, 2025
e952d98
better focusing
aliu39 Nov 8, 2025
4f519b4
close on input click
aliu39 Nov 8, 2025
ae94b93
dont prevent default on type
aliu39 Nov 8, 2025
7b05178
Merge branch 'master' of github.com:getsentry/sentry into aliu/runs-ui2
aliu39 Nov 11, 2025
881f1af
rename, tidy
aliu39 Nov 11, 2025
909105d
fix jest
aliu39 Nov 11, 2025
545008c
comment
aliu39 Nov 11, 2025
8e8e0c3
ref to not use context
aliu39 Nov 11, 2025
d928177
timezone
aliu39 Nov 11, 2025
6e2b3d3
dont export menu types
aliu39 Nov 11, 2025
29f93e3
disable sessions req if panel hidden
aliu39 Nov 11, 2025
9d17776
bot review
aliu39 Nov 11, 2025
acb1492
feedback
aliu39 Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
375 changes: 375 additions & 0 deletions static/app/views/seerExplorer/explorerMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,375 @@
import {Activity, useCallback, useEffect, useMemo, useState} from 'react';
Copy link

Choose a reason for hiding this comment

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

Bug: Activity is imported from 'react' but is not exported by React, resulting in undefined when used as a JSX component.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

When the ExplorerMenu component renders, React attempts to render <Activity>. Since Activity is incorrectly imported from 'react' (which does not export it), it will be undefined. 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 Activity as a styled component within explorerMenu.tsx or import it from its correct source, as React does not export it.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: static/app/views/seerExplorer/explorerMenu.tsx#L1

Potential issue: When the `ExplorerMenu` component renders, React attempts to render
`<Activity>`. Since `Activity` is incorrectly imported from 'react' (which does not
export it), it will be `undefined`. 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.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member Author

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

Copy link
Member

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

Copy link
Member Author

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 🚀

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]);

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;
`;
Loading
Loading