Skip to content

Commit 5910df7

Browse files
authored
fix(seer): use standard hotkey for minimize behavior, remove tab hijacking (#112993)
Seer panel currently hijacks the `Tab` key to act as a hotkey when the panel is minimized, breaking keyboard-based navigation. Since `Tab` is the native "move focus" keybinding, keyboard users are effectively trapped in the Seer panel with no way to navigate to other focusable elements on the page. This PR updates the implementation to use the normal `Cmd+/` keybinding to open the panel when it's minimized. It also removes the stateful placeholder since typing any message will refocus the textarea.
1 parent 956a1a0 commit 5910df7

File tree

6 files changed

+70
-78
lines changed

6 files changed

+70
-78
lines changed

static/app/views/seerExplorer/explorerPanel.tsx

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ import {
5050
} from 'sentry/views/seerExplorer/utils';
5151

5252
export function ExplorerPanel() {
53-
const {isOpen: isVisible, openExplorerPanel} = useExplorerPanel();
53+
const {
54+
isOpen: isVisible,
55+
openExplorerPanel,
56+
isMinimized,
57+
setIsMinimized,
58+
} = useExplorerPanel();
5459
const {getPageReferrer} = usePageReferrer();
5560
const organization = useOrganization({allowNull: true});
5661
const {projects} = useProjects();
@@ -61,7 +66,6 @@ export function ExplorerPanel() {
6166

6267
const [inputValue, setInputValue] = useState('');
6368
const [focusedBlockIndex, setFocusedBlockIndex] = useState(-1); // -1 means input is focused
64-
const [isMinimized, setIsMinimized] = useState(false); // state for slide-down
6569
const textareaRef = useRef<HTMLTextAreaElement>(null);
6670
const scrollContainerRef = useRef<HTMLDivElement>(null);
6771
const blockRefs = useRef<Array<HTMLDivElement | null>>([]);
@@ -132,7 +136,7 @@ export function ExplorerPanel() {
132136
switchToRun,
133137
sessionRunId: runId ?? undefined,
134138
sessionBlocks: sessionData?.blocks,
135-
onUnminimize: useCallback(() => setIsMinimized(false), []),
139+
onUnminimize: useCallback(() => setIsMinimized(false), [setIsMinimized]),
136140
});
137141

138142
// Extract repo_pr_states from session
@@ -184,7 +188,20 @@ export function ExplorerPanel() {
184188
textareaRef.current?.focus();
185189
}, 100);
186190
}
187-
}, [isVisible]);
191+
}, [isVisible, setIsMinimized]);
192+
193+
// Focus textarea whenever the panel transitions from minimized → unminimized
194+
const prevIsMinimizedRef = useRef(isMinimized);
195+
useEffect(() => {
196+
if (prevIsMinimizedRef.current && !isMinimized) {
197+
allowHoverFocusChange.current = false;
198+
setTimeout(() => {
199+
setFocusedBlockIndex(-1);
200+
textareaRef.current?.focus();
201+
}, 100);
202+
}
203+
prevIsMinimizedRef.current = isMinimized;
204+
}, [isMinimized]);
188205

189206
// Detect clicks outside the panel to minimize it (but not when seer drawer is open)
190207
useEffect(() => {
@@ -202,7 +219,7 @@ export function ExplorerPanel() {
202219
return () => {
203220
document.removeEventListener('mousedown', handleClickOutside);
204221
};
205-
}, [isVisible, focusedBlockIndex, isSeerDrawerOpen]);
222+
}, [isVisible, focusedBlockIndex, isSeerDrawerOpen, setIsMinimized]);
206223

207224
// Track scroll position to detect if user scrolled up
208225
useEffect(() => {
@@ -384,7 +401,7 @@ export function ExplorerPanel() {
384401
const handlePanelBackgroundClick = useCallback(() => {
385402
setIsMinimized(false);
386403
closeMenu();
387-
}, [closeMenu]);
404+
}, [closeMenu, setIsMinimized]);
388405

389406
// Close menu when clicking outside of it
390407
useEffect(() => {
@@ -424,21 +441,8 @@ export function ExplorerPanel() {
424441

425442
const handleUnminimize = useCallback(() => {
426443
setIsMinimized(false);
427-
// Disable hover focus changes until mouse actually moves
428-
allowHoverFocusChange.current = false;
429-
// Restore focus to the previously focused block if it exists and is valid
430-
if (focusedBlockIndex >= 0 && focusedBlockIndex < blocks.length) {
431-
setTimeout(() => {
432-
blockRefs.current[focusedBlockIndex]?.scrollIntoView({block: 'nearest'});
433-
}, 100);
434-
} else {
435-
// No valid block focus, focus input
436-
setTimeout(() => {
437-
setFocusedBlockIndex(-1);
438-
textareaRef.current?.focus();
439-
}, 100);
440-
}
441-
}, [focusedBlockIndex, blocks.length]);
444+
// focus/scroll side-effects are handled by the prevIsMinimizedRef effect above
445+
}, [setIsMinimized]);
442446

443447
const isAwaitingUserInput = sessionData?.status === 'awaiting_user_input';
444448
const pendingInput = sessionData?.pending_user_input;
@@ -527,6 +531,7 @@ export function ExplorerPanel() {
527531
isMinimized,
528532
isFileApprovalPending,
529533
isQuestionPending,
534+
setIsMinimized,
530535
]);
531536

532537
useBlockNavigation({
@@ -710,7 +715,6 @@ export function ExplorerPanel() {
710715
<InputSection
711716
blocks={blocks}
712717
enabled={!readOnly}
713-
focusedBlockIndex={focusedBlockIndex}
714718
inputValue={inputValue}
715719
interruptRequested={interruptRequested}
716720
wasJustInterrupted={wasJustInterrupted}

static/app/views/seerExplorer/hooks/useBlockNavigation.spec.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -165,29 +165,27 @@ describe('useBlockNavigation', () => {
165165
});
166166

167167
describe('Tab Navigation', () => {
168-
it('always returns to input on Tab', () => {
168+
it('does not intercept Tab key (allows native browser focus management)', () => {
169169
const props = {...defaultProps, focusedBlockIndex: 1};
170170
renderHook(() => useBlockNavigation(props));
171171

172-
const event = new KeyboardEvent('keydown', {key: 'Tab'});
172+
const event = new KeyboardEvent('keydown', {key: 'Tab', cancelable: true});
173173
document.dispatchEvent(event);
174174

175-
expect(props.setFocusedBlockIndex).toHaveBeenCalledWith(-1);
176-
expect(props.textareaRef.current?.focus).toHaveBeenCalled();
177-
expect(mockTextarea.scrollIntoView).toHaveBeenCalledWith({
178-
block: 'nearest',
179-
behavior: 'smooth',
180-
});
175+
expect(props.setFocusedBlockIndex).not.toHaveBeenCalled();
176+
expect(props.textareaRef.current?.focus).not.toHaveBeenCalled();
177+
expect(event.defaultPrevented).toBe(false);
181178
});
182179

183-
it('focuses textarea even when already at input', () => {
180+
it('does not intercept Tab key when at input', () => {
184181
renderHook(() => useBlockNavigation(defaultProps));
185182

186-
const event = new KeyboardEvent('keydown', {key: 'Tab'});
183+
const event = new KeyboardEvent('keydown', {key: 'Tab', cancelable: true});
187184
document.dispatchEvent(event);
188185

189-
expect(defaultProps.setFocusedBlockIndex).toHaveBeenCalledWith(-1);
190-
expect(defaultProps.textareaRef.current?.focus).toHaveBeenCalled();
186+
expect(defaultProps.setFocusedBlockIndex).not.toHaveBeenCalled();
187+
expect(defaultProps.textareaRef.current?.focus).not.toHaveBeenCalled();
188+
expect(event.defaultPrevented).toBe(false);
191189
});
192190
});
193191

@@ -206,7 +204,7 @@ describe('useBlockNavigation', () => {
206204
const props = {...defaultProps, isOpen: true};
207205
renderHook(() => useBlockNavigation(props));
208206

209-
const event = new KeyboardEvent('keydown', {key: 'Tab'});
207+
const event = new KeyboardEvent('keydown', {key: 'ArrowUp'});
210208
document.dispatchEvent(event);
211209

212210
expect(props.setFocusedBlockIndex).toHaveBeenCalled();
@@ -311,11 +309,15 @@ describe('useBlockNavigation', () => {
311309
});
312310
});
313311

314-
it('handles null textarea ref gracefully', () => {
315-
const props = {...defaultProps, textareaRef: {current: null}};
312+
it('handles null textarea ref gracefully on ArrowDown from last block', () => {
313+
const props = {
314+
...defaultProps,
315+
focusedBlockIndex: 2,
316+
textareaRef: {current: null},
317+
};
316318
renderHook(() => useBlockNavigation(props));
317319

318-
const event = new KeyboardEvent('keydown', {key: 'Tab'});
320+
const event = new KeyboardEvent('keydown', {key: 'ArrowDown'});
319321
document.dispatchEvent(event);
320322

321323
// Should not throw error with null textarea ref

static/app/views/seerExplorer/hooks/useBlockNavigation.tsx

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,6 @@ export function useBlockNavigation({
9999
}
100100
}
101101
}
102-
} else if (e.key === 'Tab') {
103-
e.preventDefault();
104-
onNavigate?.();
105-
if (isMinimized && focusedBlockIndex >= 0 && focusedBlockIndex < blocks.length) {
106-
scrollToElement(blockRefs.current[focusedBlockIndex] ?? null);
107-
} else {
108-
setFocusedBlockIndex(-1);
109-
const textareaElement = textareaRef.current;
110-
if (textareaElement) {
111-
textareaElement.focus();
112-
scrollToElement(textareaElement);
113-
}
114-
}
115102
} else if (e.key === 'Enter' && focusedBlockIndex >= 0) {
116103
e.preventDefault();
117104
onKeyPress?.(focusedBlockIndex, 'Enter');

static/app/views/seerExplorer/inputSection.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ interface QuestionActions {
3333
interface InputSectionProps {
3434
blocks: Block[];
3535
enabled: boolean;
36-
focusedBlockIndex: number;
3736
inputValue: string;
3837
interruptRequested: boolean;
3938
isPolling: boolean;
@@ -58,7 +57,6 @@ export function InputSection({
5857
blocks,
5958
enabled,
6059
inputValue,
61-
focusedBlockIndex,
6260
isMinimized = false,
6361
isPolling,
6462
interruptRequested,
@@ -82,12 +80,9 @@ export function InputSection({
8280
}, [blocks]);
8381
const getPlaceholder = () => {
8482
if (wasJustInterrupted) {
85-
return 'Interrupted. What should Seer do instead?';
83+
return t('Interrupted. What should Seer do instead?');
8684
}
87-
if (focusedBlockIndex !== -1) {
88-
return 'Press Tab ⇥ to return here';
89-
}
90-
return 'Type your message or / command and press Enter ↵';
85+
return t('Type your message or / command and press Enter ↵');
9186
};
9287

9388
// Handle keyboard shortcuts for file approval

static/app/views/seerExplorer/panelContainers.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import styled from '@emotion/styled';
22
import {AnimatePresence, motion} from 'framer-motion';
33

4+
import {Hotkey} from '@sentry/scraps/hotkey';
45
import {Flex, Stack} from '@sentry/scraps/layout';
56
import {Text} from '@sentry/scraps/text';
67

78
import {IconSeer} from 'sentry/icons';
8-
import {t} from 'sentry/locale';
9+
import {t, tct} from 'sentry/locale';
910
import type {Block, PanelSize} from 'sentry/views/seerExplorer/types';
1011
import {getToolsStringFromBlock} from 'sentry/views/seerExplorer/utils';
1112

@@ -121,7 +122,11 @@ export function PanelContainers({
121122
style={{width: '100%'}}
122123
>
123124
<IconSeer animation={isPolling ? 'loading' : 'waiting'} size="lg" />
124-
<Text size="xs">{t('Tab ⇥ to continue')}</Text>
125+
<Text size="xs">
126+
{tct('[hotkey] to continue', {
127+
hotkey: <Hotkey value={['cmd+/', 'ctrl+/']} />,
128+
})}
129+
</Text>
125130
<Text size="xs" variant="muted" ellipsis style={{maxWidth: '100%'}}>
126131
{statusText}
127132
</Text>
@@ -202,7 +207,7 @@ const PanelContent = styled('div')`
202207
background: ${p => p.theme.tokens.background.primary};
203208
border: 1px solid ${p => p.theme.tokens.border.primary};
204209
border-radius: ${p => p.theme.radius.md};
205-
box-shadow: ${p => p.theme.dropShadowHeavy};
210+
box-shadow: ${p => p.theme.shadow.high};
206211
display: flex;
207212
flex-direction: column;
208213
overflow: hidden;
@@ -221,25 +226,11 @@ const MinimizedOverlay = styled(motion.div)`
221226
left: 0;
222227
right: 0;
223228
bottom: 0;
224-
background: ${p => p.theme.tokens.background.primary};
229+
background: ${p => p.theme.tokens.background.overlay};
225230
border-radius: ${p => p.theme.radius.md};
226231
border: 1px solid ${p => p.theme.tokens.border.primary};
227232
z-index: 1;
228233
cursor: pointer;
229-
230-
/* Purple tint */
231-
&::after {
232-
content: '';
233-
position: absolute;
234-
top: 0;
235-
left: 0;
236-
right: 0;
237-
bottom: 0;
238-
background: ${p => p.theme.tokens.background.transparent.accent.muted};
239-
border-radius: inherit;
240-
z-index: -1;
241-
pointer-events: none;
242-
}
243234
`;
244235

245236
const MinimizedCorner = styled('div')`

static/app/views/seerExplorer/useExplorerPanel.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,26 @@ import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal';
1313

1414
type ExplorerPanelContextValue = {
1515
closeExplorerPanel: () => void;
16+
isMinimized: boolean;
1617
isOpen: boolean;
1718
openExplorerPanel: () => void;
19+
setIsMinimized: (value: boolean) => void;
1820
toggleExplorerPanel: () => void;
1921
};
2022

2123
const ExplorerPanelContext = createContext<ExplorerPanelContextValue>({
2224
closeExplorerPanel: () => {},
25+
isMinimized: false,
2326
isOpen: false,
2427
openExplorerPanel: () => {},
28+
setIsMinimized: () => {},
2529
toggleExplorerPanel: () => {},
2630
});
2731

2832
export function ExplorerPanelProvider({children}: {children: ReactNode}) {
2933
// Initialize the global explorer panel state. Includes hotkeys.
3034
const [isOpen, setIsOpen] = useState(false);
35+
const [isMinimized, setIsMinimized] = useState(false);
3136

3237
const openExplorerPanel = useCallback(() => {
3338
setIsOpen(true);
@@ -44,11 +49,13 @@ export function ExplorerPanelProvider({children}: {children: ReactNode}) {
4449
const contextValue = useMemo(
4550
() => ({
4651
isOpen,
52+
isMinimized,
4753
openExplorerPanel,
4854
closeExplorerPanel,
55+
setIsMinimized,
4956
toggleExplorerPanel,
5057
}),
51-
[isOpen, openExplorerPanel, closeExplorerPanel, toggleExplorerPanel]
58+
[isOpen, isMinimized, openExplorerPanel, closeExplorerPanel, toggleExplorerPanel]
5259
);
5360

5461
// Hot keys for toggling the explorer panel.
@@ -60,7 +67,13 @@ export function ExplorerPanelProvider({children}: {children: ReactNode}) {
6067
: [
6168
{
6269
match: ['command+/', 'ctrl+/', 'command+.', 'ctrl+.'],
63-
callback: () => toggleExplorerPanel(),
70+
callback: () => {
71+
if (isOpen && isMinimized) {
72+
setIsMinimized(false);
73+
} else {
74+
toggleExplorerPanel();
75+
}
76+
},
6477
includeInputs: true,
6578
},
6679
]

0 commit comments

Comments
 (0)