fix(work-orders): keep mention menu open after typing @

Pass live text into syncCursor instead of a render-time valueRef.
Add a composer interaction regression for @ autocomplete visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Arthur Bassi 2026-08-11 11:56:19 -03:00
parent 8783aa6e18
commit 5a29bf6c33
2 changed files with 80 additions and 27 deletions

View file

@ -25,6 +25,41 @@ type CommentMentionComposerProps = {
onSubmit?: () => void; onSubmit?: () => void;
}; };
type MentionCandidateMenuProps = {
open: boolean;
anchorEl: HTMLElement | null;
candidates: MentionLookup[];
highlightIndex: number;
onSelect: (user: MentionLookup) => void;
};
function MentionCandidateMenu({
open,
anchorEl,
candidates,
highlightIndex,
onSelect,
}: MentionCandidateMenuProps) {
return (
<Popper open={open} anchorEl={anchorEl} placement="bottom-start" className="z-50">
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
<List dense disablePadding>
{candidates.map((candidate, index) => (
<ListItemButton
key={String(candidate.id)}
selected={index === highlightIndex}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(candidate)}
>
@{candidate.name}
</ListItemButton>
))}
</List>
</Paper>
</Popper>
);
}
export function CommentMentionComposer({ export function CommentMentionComposer({
value, value,
onChange, onChange,
@ -35,8 +70,6 @@ export function CommentMentionComposer({
onSubmit, onSubmit,
}: CommentMentionComposerProps) { }: CommentMentionComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const valueRef = useRef(value);
valueRef.current = value;
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cursor, setCursor] = useState(0); const [cursor, setCursor] = useState(0);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -55,22 +88,23 @@ export function CommentMentionComposer({
setHighlightIndex(0); setHighlightIndex(0);
}, [activeQuery?.query, activeQuery?.start]); }, [activeQuery?.query, activeQuery?.start]);
const syncCursor = useCallback(() => { const syncCursor = useCallback((textOverride?: string) => {
const element = inputRef.current; const element = inputRef.current;
if (!element) { if (!element) {
return; return;
} }
const currentValue = valueRef.current; const text = textOverride ?? element.value;
const nextCursor = element.selectionStart ?? currentValue.length; const nextCursor = element.selectionStart ?? text.length;
setCursor(nextCursor); setCursor(nextCursor);
setMenuOpen(Boolean(findActiveMentionQuery(currentValue, nextCursor))); setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
}, []); }, []);
const handleChange = (nextValue: string) => { const handleChange = (nextValue: string) => {
valueRef.current = nextValue;
onChange(nextValue); onChange(nextValue);
requestAnimationFrame(syncCursor); requestAnimationFrame(() => {
syncCursor(nextValue);
});
}; };
const applyMention = useCallback( const applyMention = useCallback(
@ -150,30 +184,21 @@ export function CommentMentionComposer({
fullWidth fullWidth
value={value} value={value}
onChange={(event) => handleChange(event.target.value)} onChange={(event) => handleChange(event.target.value)}
onClick={syncCursor} onClick={() => syncCursor()}
onKeyUp={syncCursor} onKeyUp={() => syncCursor()}
onSelect={syncCursor} onSelect={() => syncCursor()}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
/> />
<Popper open={showMenu} anchorEl={anchorEl} placement="bottom-start" className="z-50"> <MentionCandidateMenu
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto"> open={showMenu}
<List dense disablePadding> anchorEl={anchorEl}
{filteredCandidates.map((candidate, index) => ( candidates={filteredCandidates}
<ListItemButton highlightIndex={highlightIndex}
key={String(candidate.id)} onSelect={applyMention}
selected={index === highlightIndex} />
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyMention(candidate)}
>
@{candidate.name}
</ListItemButton>
))}
</List>
</Paper>
</Popper>
</Box> </Box>
</ClickAwayListener> </ClickAwayListener>
); );

View file

@ -0,0 +1,28 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { CommentMentionComposer } from "@/components/common/comment-mention-composer";
import { renderWithProviders } from "@/test/test-utils";
const candidates = [{ id: "1", name: "Alice Dispatcher" }];
function MentionComposerHarness() {
const [value, setValue] = useState("");
return <CommentMentionComposer value={value} onChange={setValue} candidates={candidates} />;
}
describe("CommentMentionComposer", () => {
it("keeps the suggestion menu visible after typing @", async () => {
const user = userEvent.setup();
renderWithProviders(<MentionComposerHarness />, { withAuth: false });
await user.type(screen.getByRole("textbox"), "@");
await waitFor(() => {
expect(screen.getByRole("button", { name: "@Alice Dispatcher" })).toBeVisible();
});
});
});