Skip to content

Commit 7b8a2b5

Browse files
committed
feat(seer): Wire MentionInput into the Explorer chat input
Replace the plain InputGroup.TextArea in InputSection's enabled state with MentionInput, wired to useOrgMentionSources() so @-mentioning a user or team works the same way it does in issue activity notes. The draft persisted via useDeferredSessionStorage moves from a plain string to MentionInputValue ({text, mentions}); the storage key prefix is bumped so stale string-shaped drafts from the old format are never read back into the new shape. Adds MentionInput.onOpenChange, a small addition to the core primitive: Seer needs plain Enter to submit the message (unlike the note composer, which sidesteps the ambiguity by requiring Cmd/Ctrl+Enter), so it needs to know when the suggestion popup is open in order to defer to suggestion selection instead of sending early. explorerMenu's textAreaRef, slash-command detection, and PR-widget anchor positioning are unaffected (previous commit already widened the ref type; slash commands operate on inputValue.text, which is unchanged in shape). The read-only/disabled input state is untouched, still a plain disabled InputGroup.TextArea. Known follow-up: the warning-colored placeholder text (shown when a run is interrupted or times out) isn't replicated on MentionInput yet -- styling a generic component's ::before pseudo-element through emotion's styled() fights the component's type parameter. The placeholder text itself is unchanged, just not in the warning color.
1 parent e5a2f35 commit 7b8a2b5

6 files changed

Lines changed: 125 additions & 67 deletions

File tree

static/app/components/core/mentionInput/mentionInput.spec.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ function ControlledMentionInput({
3636
sources = [MEMBER_SOURCE],
3737
initialValue = '',
3838
initialMentions = [],
39+
onOpenChange,
3940
}: {
4041
initialMentions?: readonly Mention[];
4142
initialValue?: string;
43+
onOpenChange?: (isOpen: boolean) => void;
4244
sources?: readonly TestMentionSource[];
4345
}) {
4446
const [value, setValue] = useState<MentionInputValue>({
@@ -53,6 +55,7 @@ function ControlledMentionInput({
5355
sources={sources}
5456
value={value}
5557
onChange={setValue}
58+
onOpenChange={onOpenChange}
5659
/>
5760
<output aria-label="Editor value">
5861
{value.text}|{value.mentions.map(mention => mention.id).join(',')}
@@ -160,6 +163,23 @@ describe('MentionInput', () => {
160163
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
161164
});
162165

166+
it('notifies onOpenChange as suggestions open and close', async () => {
167+
const onOpenChange = jest.fn();
168+
render(<ControlledMentionInput onOpenChange={onOpenChange} />);
169+
170+
const textbox = getEditor();
171+
expect(onOpenChange).toHaveBeenCalledWith(false);
172+
onOpenChange.mockClear();
173+
174+
await userEvent.type(textbox, '@al');
175+
await screen.findByRole('listbox', {name: 'Members suggestions'});
176+
expect(onOpenChange).toHaveBeenCalledWith(true);
177+
178+
onOpenChange.mockClear();
179+
await userEvent.keyboard('{Escape}');
180+
expect(onOpenChange).toHaveBeenCalledWith(false);
181+
});
182+
163183
it('selects the current suggestion with Tab', async () => {
164184
render(<ControlledMentionInput />);
165185

static/app/components/core/mentionInput/mentionInput.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export function MentionInput<TSuggestion>({
142142
sources,
143143
onChange,
144144
minHeight,
145+
onOpenChange,
145146
placeholder,
146147
style,
147148
...editorProps
@@ -159,6 +160,10 @@ export function MentionInput<TSuggestion>({
159160
: undefined;
160161
const isOpen = activeSource !== undefined;
161162

163+
useLayoutEffect(() => {
164+
onOpenChange?.(isOpen);
165+
}, [isOpen, onOpenChange]);
166+
162167
const {
163168
activeDescendant,
164169
collectionProps,

static/app/components/core/mentionInput/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export interface MentionInputProps<TSuggestion> extends Omit<
4545
/** Controlled editor text and structured mention ranges. */
4646
value: MentionInputValue;
4747
minHeight?: number;
48+
/**
49+
* Called when the suggestion list opens or closes. Useful for consumers
50+
* that bind Enter to something else (e.g. submitting a form) and need to
51+
* defer to suggestion selection while the list is open.
52+
*/
53+
onOpenChange?: (isOpen: boolean) => void;
4854
placeholder?: string;
4955
ref?: React.Ref<HTMLDivElement>;
5056
size?: FormSize;

static/app/views/seerExplorer/components/inputSection.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import {motion} from 'framer-motion';
55
import {Button} from '@sentry/scraps/button';
66
import {InputGroup} from '@sentry/scraps/input';
77
import {Container, Flex, Grid} from '@sentry/scraps/layout';
8+
import {MentionInput, type MentionInputValue} from '@sentry/scraps/mentionInput';
89
import {Text} from '@sentry/scraps/text';
910

1011
import {IconArrow, IconPause} from 'sentry/icons';
1112
import {t} from 'sentry/locale';
13+
import {useOrgMentionSources} from 'sentry/utils/mentions/useOrgMentionSources';
1214
import {PRWidget} from 'sentry/views/seerExplorer/components/prWidget';
1315
import type {Block, RepoPRState} from 'sentry/views/seerExplorer/types';
1416

@@ -32,20 +34,21 @@ interface QuestionActions {
3234
interface InputSectionProps {
3335
blocks: Block[];
3436
enabled: boolean;
35-
inputValue: string;
37+
inputValue: MentionInputValue;
3638
onCreatePR: (repoName?: string) => void;
37-
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
39+
onInputChange: (value: MentionInputValue) => void;
3840
onInputClick: () => void;
3941
onInterrupt: () => void;
40-
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
42+
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
4143
onPRWidgetClick: () => void;
4244
onSend: () => void;
4345
prWidgetButtonRef: React.RefObject<HTMLButtonElement | null>;
4446
repoPRStates: Record<string, RepoPRState>;
45-
textAreaRef: React.RefObject<HTMLTextAreaElement | null>;
47+
textAreaRef: React.RefObject<HTMLDivElement | null>;
4648
canSendMessage?: boolean;
4749
fileApprovalActions?: FileApprovalActions;
4850
interruptState?: 'can-interrupt' | 'requested' | 'completed' | 'disabled';
51+
onSuggestionsOpenChange?: (isOpen: boolean) => void;
4952
questionActions?: QuestionActions;
5053
}
5154

@@ -66,8 +69,11 @@ export function InputSection({
6669
repoPRStates,
6770
textAreaRef,
6871
fileApprovalActions,
72+
onSuggestionsOpenChange,
6973
questionActions,
7074
}: InputSectionProps) {
75+
const mentionSources = useOrgMentionSources();
76+
7177
// Check if there are any file patches for showing the PR widget
7278
const hasCodeChanges = useMemo(() => {
7379
return blocks.some(b => b.merged_file_patches && b.merged_file_patches.length > 0);
@@ -255,25 +261,29 @@ export function InputSection({
255261
return (
256262
<InputBlock>
257263
<InputRow>
258-
<StyledInputGroup isWarningPlaceholder={interruptState === 'completed'}>
259-
<InputGroup.TextArea
260-
ref={textAreaRef}
261-
value={inputValue}
262-
onChange={onInputChange}
263-
onKeyDown={onKeyDown}
264-
onClick={onInputClick}
265-
placeholder={
266-
interruptState === 'completed'
267-
? t('Interrupted. What should Seer do instead?')
268-
: t('Ask Seer a question, or press / for commands.')
269-
}
270-
rows={1}
271-
maxRows={5}
272-
autosize
273-
size="md"
274-
data-test-id="seer-explorer-input"
275-
/>
276-
</StyledInputGroup>
264+
<MentionInput
265+
ref={textAreaRef}
266+
aria-label={t('Ask Seer a question')}
267+
sources={mentionSources}
268+
value={inputValue}
269+
onChange={onInputChange}
270+
onOpenChange={onSuggestionsOpenChange}
271+
onKeyDown={onKeyDown}
272+
onClick={onInputClick}
273+
placeholder={
274+
interruptState === 'completed'
275+
? t('Interrupted. What should Seer do instead?')
276+
: t('Ask Seer a question, or press / for commands.')
277+
}
278+
// TODO(mention-input): match the warning color used for the
279+
// disabled/interrupted textarea placeholder above (needs a way to
280+
// style :empty::before on a generic MentionInput without fighting
281+
// emotion's `styled()` over its generic type parameter).
282+
minHeight={20}
283+
style={{flex: 1, maxHeight: 120}}
284+
size="md"
285+
data-test-id="seer-explorer-input"
286+
/>
277287
{interruptState === 'can-interrupt' || interruptState === 'requested' ? (
278288
<Button
279289
icon={<IconPause />}

static/app/views/seerExplorer/components/seerExplorerContent.spec.tsx

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/types';
1818

1919
const mockGetPageReferrer = jest.fn().mockReturnValue('/issues/');
2020

21+
/**
22+
* Grabs the Seer Explorer's mention-aware input. userEvent does not yet
23+
* recognize contenteditable="plaintext-only", so this switches it to
24+
* "true" first (matching the workaround used by MentionInput's own tests).
25+
*/
26+
async function getSeerExplorerInput() {
27+
const editor = await screen.findByTestId('seer-explorer-input');
28+
editor.setAttribute('contenteditable', 'true');
29+
return editor;
30+
}
31+
2132
const defaultHookReturn: ReturnType<typeof useSeerExplorerModule.useSeerExplorer> = {
2233
sessionData: null,
2334
isPolling: false,
@@ -133,11 +144,10 @@ describe('SeerExplorerContent', () => {
133144
organization,
134145
}
135146
);
136-
expect(
137-
await screen.findByPlaceholderText(
138-
'Ask Seer a question, or press / for commands.'
139-
)
140-
).toBeInTheDocument();
147+
expect(await screen.findByTestId('seer-explorer-input')).toHaveAttribute(
148+
'data-placeholder',
149+
'Ask Seer a question, or press / for commands.'
150+
);
141151
});
142152

143153
it('sends the suggested question when a suggestion button is clicked', async () => {
@@ -320,9 +330,9 @@ describe('SeerExplorerContent', () => {
320330
organization,
321331
}
322332
);
323-
const textarea = await screen.findByTestId('seer-explorer-input');
333+
const textarea = await getSeerExplorerInput();
324334
await userEvent.type(textarea, 'Test message');
325-
expect(textarea).toHaveValue('Test message');
335+
expect(textarea).toHaveTextContent('Test message');
326336
});
327337

328338
it('calls sendMessage and clears input when send button is clicked', async () => {
@@ -346,12 +356,12 @@ describe('SeerExplorerContent', () => {
346356
}
347357
);
348358

349-
const textarea = await screen.findByTestId('seer-explorer-input');
359+
const textarea = await getSeerExplorerInput();
350360
await userEvent.type(textarea, 'Test message');
351361
await userEvent.click(screen.getByRole('button', {name: 'Send message'}));
352362

353363
expect(sendMessage).toHaveBeenCalledWith('Test message', 0);
354-
expect(textarea).toHaveValue('');
364+
expect(textarea).toBeEmptyDOMElement();
355365
});
356366

357367
it('calls sendMessage and clears input when Enter is pressed', async () => {
@@ -375,12 +385,12 @@ describe('SeerExplorerContent', () => {
375385
}
376386
);
377387

378-
const textarea = await screen.findByTestId('seer-explorer-input');
388+
const textarea = await getSeerExplorerInput();
379389
await userEvent.type(textarea, 'Test message');
380390
await userEvent.keyboard('{Enter}');
381391

382392
expect(sendMessage).toHaveBeenCalledWith('Test message', 0);
383-
expect(textarea).toHaveValue('');
393+
expect(textarea).toBeEmptyDOMElement();
384394
});
385395

386396
it('[Integration] sends message to the API when Enter is pressed', async () => {
@@ -451,7 +461,7 @@ describe('SeerExplorerContent', () => {
451461
}
452462
);
453463

454-
const textarea = await screen.findByTestId('seer-explorer-input');
464+
const textarea = await getSeerExplorerInput();
455465
await userEvent.type(textarea, 'What is this error?');
456466
await userEvent.keyboard('{Enter}');
457467

@@ -513,7 +523,7 @@ describe('SeerExplorerContent', () => {
513523
}
514524
);
515525

516-
const textarea = await screen.findByTestId('seer-explorer-input');
526+
const textarea = await getSeerExplorerInput();
517527
await userEvent.type(textarea, 'Test message');
518528
await userEvent.keyboard('{Enter}');
519529

@@ -566,7 +576,7 @@ describe('SeerExplorerContent', () => {
566576
}
567577
);
568578

569-
const textarea = await screen.findByTestId('seer-explorer-input');
579+
const textarea = await getSeerExplorerInput();
570580
await userEvent.type(textarea, 'New message');
571581
await userEvent.keyboard('{Enter}');
572582

@@ -622,7 +632,7 @@ describe('SeerExplorerContent', () => {
622632

623633
expect(await screen.findByText('Response timed out.')).toBeInTheDocument();
624634
expect(screen.getByTestId('seer-explorer-input')).toHaveAttribute(
625-
'placeholder',
635+
'data-placeholder',
626636
'Ask Seer a question, or press / for commands.'
627637
);
628638

@@ -653,10 +663,7 @@ describe('SeerExplorerContent', () => {
653663
{organization}
654664
);
655665

656-
await userEvent.type(
657-
await screen.findByTestId('seer-explorer-input'),
658-
'draft message'
659-
);
666+
await userEvent.type(await getSeerExplorerInput(), 'draft message');
660667
unmount();
661668

662669
render(
@@ -671,7 +678,7 @@ describe('SeerExplorerContent', () => {
671678
{organization}
672679
);
673680

674-
expect(await screen.findByTestId('seer-explorer-input')).toHaveValue(
681+
expect(await screen.findByTestId('seer-explorer-input')).toHaveTextContent(
675682
'draft message'
676683
);
677684
});
@@ -692,10 +699,7 @@ describe('SeerExplorerContent', () => {
692699
{organization}
693700
);
694701

695-
await userEvent.type(
696-
await screen.findByTestId('seer-explorer-input'),
697-
'draft for run 1'
698-
);
702+
await userEvent.type(await getSeerExplorerInput(), 'draft for run 1');
699703

700704
useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 2});
701705
rerender(
@@ -710,11 +714,11 @@ describe('SeerExplorerContent', () => {
710714
);
711715

712716
await waitFor(() =>
713-
expect(screen.getByTestId('seer-explorer-input')).toHaveValue('')
717+
expect(screen.getByTestId('seer-explorer-input')).toBeEmptyDOMElement()
714718
);
715719
expect(
716720
JSON.parse(sessionStorage.getItem(`${INPUT_STORAGE_KEY_PREFIX}:1`) ?? '')
717-
).toBe('draft for run 1');
721+
).toEqual({text: 'draft for run 1', mentions: []});
718722

719723
useSeerExplorerSpy.mockReturnValue({...defaultHookReturn, runId: 1});
720724
rerender(
@@ -729,7 +733,9 @@ describe('SeerExplorerContent', () => {
729733
);
730734

731735
await waitFor(() =>
732-
expect(screen.getByTestId('seer-explorer-input')).toHaveValue('draft for run 1')
736+
expect(screen.getByTestId('seer-explorer-input')).toHaveTextContent(
737+
'draft for run 1'
738+
)
733739
);
734740
});
735741

@@ -748,10 +754,7 @@ describe('SeerExplorerContent', () => {
748754
{organization}
749755
);
750756

751-
await userEvent.type(
752-
await screen.findByTestId('seer-explorer-input'),
753-
'unsaved draft'
754-
);
757+
await userEvent.type(await getSeerExplorerInput(), 'unsaved draft');
755758
unmount();
756759

757760
const draftWrites = setItemSpy.mock.calls.filter(([k]) =>
@@ -780,12 +783,12 @@ describe('SeerExplorerContent', () => {
780783
{organization}
781784
);
782785

783-
const textarea = await screen.findByTestId('seer-explorer-input');
786+
const textarea = await getSeerExplorerInput();
784787
await userEvent.type(textarea, 'hello');
785788
await userEvent.keyboard('{Enter}');
786789

787790
expect(sendMessage).toHaveBeenCalledWith('hello', 0);
788-
expect(textarea).toHaveValue('');
791+
expect(textarea).toBeEmptyDOMElement();
789792
expect(sessionStorage.getItem(`${INPUT_STORAGE_KEY_PREFIX}:42`)).toBeNull();
790793
});
791794
});
@@ -856,7 +859,7 @@ describe('SeerExplorerContent', () => {
856859
const textarea = await screen.findByTestId('seer-explorer-input');
857860
await waitFor(() => expect(textarea).toBeEnabled());
858861
expect(textarea).toHaveAttribute(
859-
'placeholder',
862+
'data-placeholder',
860863
'Ask Seer a question, or press / for commands.'
861864
);
862865
});

0 commit comments

Comments
 (0)