Skip to content

Commit 6ccbe6a

Browse files
authored
feat(explorer): update menu with chonk button and session history switcher (#103135)
1 parent f4fcb4a commit 6ccbe6a

File tree

8 files changed

+657
-441
lines changed

8 files changed

+657
-441
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import {Activity, useCallback, useEffect, useMemo, useState} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {DateTime} from 'sentry/components/dateTime';
5+
import {space} from 'sentry/styles/space';
6+
import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
7+
import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions';
8+
9+
type MenuMode =
10+
| 'slash-commands-keyboard'
11+
| 'slash-commands-manual'
12+
| 'session-history'
13+
| 'hidden';
14+
15+
interface ExplorerMenuProps {
16+
clearInput: () => void;
17+
focusInput: () => void;
18+
inputValue: string;
19+
onChangeSession: (runId: number) => void;
20+
panelSize: 'max' | 'med';
21+
panelVisible: boolean;
22+
slashCommandHandlers: {
23+
onMaxSize: () => void;
24+
onMedSize: () => void;
25+
onNew: () => void;
26+
};
27+
textAreaRef: React.RefObject<HTMLTextAreaElement | null>;
28+
}
29+
30+
interface MenuItemProps {
31+
description: string | React.ReactNode;
32+
handler: () => void;
33+
key: string;
34+
title: string;
35+
}
36+
37+
export function useExplorerMenu({
38+
clearInput,
39+
inputValue,
40+
focusInput,
41+
textAreaRef,
42+
panelSize,
43+
panelVisible,
44+
slashCommandHandlers,
45+
onChangeSession,
46+
}: ExplorerMenuProps) {
47+
const [menuMode, setMenuMode] = useState<MenuMode>('hidden');
48+
49+
const allSlashCommands = useSlashCommands(slashCommandHandlers);
50+
51+
const filteredSlashCommands = useMemo(() => {
52+
// Filter commands based on current input
53+
if (!inputValue.startsWith('/') || inputValue.includes(' ')) {
54+
return [];
55+
}
56+
const query = inputValue.toLowerCase();
57+
return allSlashCommands.filter(cmd => cmd.title.toLowerCase().startsWith(query));
58+
}, [allSlashCommands, inputValue]);
59+
60+
const {sessionItems, refetchSessions, isSessionsPending, isSessionsError} = useSessions(
61+
{onChangeSession, enabled: panelVisible}
62+
);
63+
64+
// Menu items and select handlers change based on the mode.
65+
const menuItems = useMemo(() => {
66+
switch (menuMode) {
67+
case 'slash-commands-keyboard':
68+
return filteredSlashCommands;
69+
case 'slash-commands-manual':
70+
return allSlashCommands;
71+
case 'session-history':
72+
return sessionItems;
73+
default:
74+
return [];
75+
}
76+
}, [menuMode, allSlashCommands, filteredSlashCommands, sessionItems]);
77+
78+
const close = useCallback(() => {
79+
setMenuMode('hidden');
80+
if (menuMode === 'slash-commands-keyboard') {
81+
// Clear input and reset textarea height.
82+
clearInput();
83+
if (textAreaRef.current) {
84+
textAreaRef.current.style.height = 'auto';
85+
}
86+
}
87+
}, [menuMode, setMenuMode, clearInput, textAreaRef]);
88+
89+
const closeAndFocusInput = useCallback(() => {
90+
close();
91+
focusInput();
92+
}, [close, focusInput]);
93+
94+
const onSelect = useCallback(
95+
(item: MenuItemProps) => {
96+
// Execute custom handler.
97+
item.handler();
98+
99+
if (menuMode === 'slash-commands-keyboard') {
100+
// Clear input and reset textarea height.
101+
clearInput();
102+
if (textAreaRef.current) {
103+
textAreaRef.current.style.height = 'auto';
104+
}
105+
}
106+
107+
if (item.key === '/resume') {
108+
// Handle /resume command here - avoid changing menu state from item handlers.
109+
setMenuMode('session-history');
110+
refetchSessions();
111+
} else {
112+
// Default to closing the menu after an item is selected and handled.
113+
closeAndFocusInput();
114+
}
115+
},
116+
// clearInput and textAreaRef are both expected to be stable.
117+
[menuMode, clearInput, textAreaRef, setMenuMode, refetchSessions, closeAndFocusInput]
118+
);
119+
120+
// Toggle between slash-commands-keyboard and hidden modes based on filteredSlashCommands.
121+
useEffect(() => {
122+
if (menuMode === 'slash-commands-keyboard' && filteredSlashCommands.length === 0) {
123+
setMenuMode('hidden');
124+
} else if (menuMode === 'hidden' && filteredSlashCommands.length > 0) {
125+
setMenuMode('slash-commands-keyboard');
126+
}
127+
}, [menuMode, setMenuMode, filteredSlashCommands]);
128+
129+
const isVisible = menuMode !== 'hidden';
130+
131+
const [selectedIndex, setSelectedIndex] = useState(0);
132+
133+
// Reset selected index when items change
134+
useEffect(() => {
135+
setSelectedIndex(0);
136+
}, [menuItems]);
137+
138+
// Handle keyboard navigation with higher priority
139+
const handleKeyDown = useCallback(
140+
(e: KeyboardEvent) => {
141+
if (!isVisible) return;
142+
143+
const isPrintableChar = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
144+
145+
if (e.key === 'ArrowUp') {
146+
e.preventDefault();
147+
e.stopPropagation();
148+
setSelectedIndex(prev => Math.max(0, prev - 1));
149+
} else if (e.key === 'ArrowDown') {
150+
e.preventDefault();
151+
e.stopPropagation();
152+
setSelectedIndex(prev => Math.min(menuItems.length - 1, prev + 1));
153+
} else if (e.key === 'Enter') {
154+
e.preventDefault();
155+
e.stopPropagation();
156+
if (menuItems[selectedIndex]) {
157+
onSelect(menuItems[selectedIndex]);
158+
}
159+
} else if (e.key === 'Escape') {
160+
e.preventDefault();
161+
e.stopPropagation();
162+
closeAndFocusInput();
163+
if (menuMode === 'slash-commands-keyboard') {
164+
clearInput();
165+
}
166+
} else if (isPrintableChar && menuMode !== 'slash-commands-keyboard') {
167+
closeAndFocusInput();
168+
}
169+
},
170+
[
171+
isVisible,
172+
selectedIndex,
173+
menuItems,
174+
onSelect,
175+
clearInput,
176+
menuMode,
177+
closeAndFocusInput,
178+
]
179+
);
180+
181+
useEffect(() => {
182+
if (isVisible) {
183+
// Use capture phase to intercept events before they reach other handlers
184+
document.addEventListener('keydown', handleKeyDown, true);
185+
return () => document.removeEventListener('keydown', handleKeyDown, true);
186+
}
187+
return undefined;
188+
}, [handleKeyDown, isVisible]);
189+
190+
const menu = (
191+
<Activity mode={isVisible ? 'visible' : 'hidden'}>
192+
<MenuPanel panelSize={panelSize}>
193+
{menuItems.map((item, index) => (
194+
<MenuItem
195+
key={item.key}
196+
isSelected={index === selectedIndex}
197+
onClick={() => onSelect(item)}
198+
>
199+
<ItemName>{item.title}</ItemName>
200+
<ItemDescription>{item.description}</ItemDescription>
201+
</MenuItem>
202+
))}
203+
{menuMode === 'session-history' && menuItems.length === 0 && (
204+
<MenuItem key="empty-state" isSelected={false}>
205+
<ItemName>
206+
{isSessionsPending
207+
? 'Loading sessions...'
208+
: isSessionsError
209+
? 'Error loading sessions.'
210+
: 'No session history found.'}
211+
</ItemName>
212+
</MenuItem>
213+
)}
214+
</MenuPanel>
215+
</Activity>
216+
);
217+
218+
// Handler for the button entrypoint.
219+
const onMenuButtonClick = useCallback(() => {
220+
if (menuMode === 'hidden') {
221+
setMenuMode('slash-commands-manual');
222+
} else {
223+
close();
224+
}
225+
}, [menuMode, setMenuMode, close]);
226+
227+
return {
228+
menu,
229+
menuMode,
230+
isMenuOpen: menuMode !== 'hidden',
231+
closeMenu: close,
232+
onMenuButtonClick,
233+
};
234+
}
235+
236+
function useSlashCommands({
237+
onMaxSize,
238+
onMedSize,
239+
onNew,
240+
}: {
241+
onMaxSize: () => void;
242+
onMedSize: () => void;
243+
onNew: () => void;
244+
}): MenuItemProps[] {
245+
const openFeedbackForm = useFeedbackForm();
246+
247+
return useMemo(
248+
(): MenuItemProps[] => [
249+
{
250+
title: '/new',
251+
key: '/new',
252+
description: 'Start a new session',
253+
handler: onNew,
254+
},
255+
{
256+
title: '/resume',
257+
key: '/resume',
258+
description: 'View your session history to resume past sessions',
259+
handler: () => {}, // Handled by parent onSelect callback.
260+
},
261+
{
262+
title: '/max-size',
263+
key: '/max-size',
264+
description: 'Expand panel to full viewport height',
265+
handler: onMaxSize,
266+
},
267+
{
268+
title: '/med-size',
269+
key: '/med-size',
270+
description: 'Set panel to medium size (default)',
271+
handler: onMedSize,
272+
},
273+
...(openFeedbackForm
274+
? [
275+
{
276+
title: '/feedback',
277+
key: '/feedback',
278+
description: 'Open feedback form to report issues or suggestions',
279+
handler: () =>
280+
openFeedbackForm({
281+
formTitle: 'Seer Explorer Feedback',
282+
messagePlaceholder: 'How can we make Seer Explorer better for you?',
283+
tags: {
284+
['feedback.source']: 'seer_explorer',
285+
},
286+
}),
287+
},
288+
]
289+
: []),
290+
],
291+
[onNew, onMaxSize, onMedSize, openFeedbackForm]
292+
);
293+
}
294+
295+
function useSessions({
296+
onChangeSession,
297+
enabled,
298+
}: {
299+
onChangeSession: (runId: number) => void;
300+
enabled?: boolean;
301+
}) {
302+
const {data, isPending, isError, refetch} = useExplorerSessions({limit: 20, enabled});
303+
304+
const sessionItems = useMemo(() => {
305+
if (isPending || isError) {
306+
return [];
307+
}
308+
309+
return data.data.map(session => ({
310+
title: session.title,
311+
key: session.run_id.toString(),
312+
description: (
313+
<span>
314+
Last updated at <DateTime date={session.last_triggered_at} />
315+
</span>
316+
),
317+
handler: () => {
318+
onChangeSession(session.run_id);
319+
},
320+
}));
321+
}, [data, isPending, isError, onChangeSession]);
322+
323+
return {
324+
sessionItems,
325+
isSessionsPending: isPending,
326+
isSessionsError: isError,
327+
isError,
328+
refetchSessions: refetch,
329+
};
330+
}
331+
332+
const MenuPanel = styled('div')<{
333+
panelSize: 'max' | 'med';
334+
}>`
335+
position: absolute;
336+
bottom: 100%;
337+
left: ${space(2)};
338+
width: 300px;
339+
background: ${p => p.theme.background};
340+
border: 1px solid ${p => p.theme.border};
341+
border-bottom: none;
342+
border-radius: ${p => p.theme.borderRadius};
343+
box-shadow: ${p => p.theme.dropShadowHeavy};
344+
max-height: ${p =>
345+
p.panelSize === 'max' ? 'calc(100vh - 120px)' : `calc(50vh - 80px)`};
346+
overflow-y: auto;
347+
z-index: 10;
348+
`;
349+
350+
const MenuItem = styled('div')<{isSelected: boolean}>`
351+
padding: ${space(1.5)} ${space(2)};
352+
cursor: pointer;
353+
background: ${p => (p.isSelected ? p.theme.hover : 'transparent')};
354+
border-bottom: 1px solid ${p => p.theme.border};
355+
356+
&:last-child {
357+
border-bottom: none;
358+
}
359+
360+
&:hover {
361+
background: ${p => p.theme.hover};
362+
}
363+
`;
364+
365+
const ItemName = styled('div')`
366+
font-weight: 600;
367+
color: ${p => p.theme.purple400};
368+
font-size: ${p => p.theme.fontSize.sm};
369+
`;
370+
371+
const ItemDescription = styled('div')`
372+
color: ${p => p.theme.subText};
373+
font-size: ${p => p.theme.fontSize.xs};
374+
margin-top: 2px;
375+
`;

0 commit comments

Comments
 (0)