Files
cinny/src/app/features/room/CommandAutocomplete.tsx
T
Lotus CIandClaude Opus 5.5 d250333290
CI / Build & Quality Checks (push) Successful in 1m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
fix(composer): Enter picks the @-mention / #room / command suggestion
With the mention list open, pressing Enter sent the half-typed text
("hey @bo") instead of inserting the highlighted person — found while
investigating the "composer hit a snag" report; only Tab picked. Enter now
picks the top suggestion for people, rooms and commands, as in Discord,
Slack and Element.

useAutocompleteEnter listens in the window capture phase (so it runs before
the composer's submit handler) and only while the list has suggestions —
with no command match Enter still sends. Emoji suggestions are deliberately
left on Tab only, so "lol :p" + Enter still sends rather than inserting an
emoji. IME composition and Shift/Ctrl+Enter are untouched. Tab now inserts
the member's display name, same as a click.

Verified: "@bo" + Enter inserts the mention and sends nothing; the next
Enter sends "hey @bob hi" with m.mentions; ":smi" + Enter still sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:13:47 -04:00

113 lines
3.4 KiB
TypeScript

import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands';
import {
AutocompleteMenu,
AutocompleteQuery,
createCommandElement,
moveCursor,
replaceWithElement,
} from '../../components/editor';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useKeyDown } from '../../hooks/useKeyDown';
import { onTabPress } from '../../utils/keyboard';
import { useAutocompleteEnter } from '../../hooks/useAutocompleteEnter';
type CommandAutoCompleteHandler = (commandName: string) => void;
type CommandAutocompleteProps = {
room: Room;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: {
contain: true,
},
};
export function CommandAutocomplete({
room,
editor,
query,
requestClose,
}: CommandAutocompleteProps) {
const mx = useMatrixClient();
const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
const [result, search, resetSearch] = useAsyncSearch(
commandNames,
useCallback((commandName: string) => commandName, []),
SEARCH_OPTIONS,
);
const autoCompleteNames = result ? result.items : commandNames;
useEffect(() => {
if (query.text) search(query.text);
else resetSearch();
}, [query.text, search, resetSearch]);
const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
const cmdEl = createCommandElement(commandName);
replaceWithElement(editor, query.range, cmdEl);
moveCursor(editor, true);
requestClose();
};
const pickTop = () => {
if (autoCompleteNames.length === 0) return;
handleAutocomplete(autoCompleteNames[0]);
};
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
// Only while suggestions show: with no match Enter must still send.
useAutocompleteEnter(autoCompleteNames.length > 0, pickTop);
return autoCompleteNames.length === 0 ? null : (
<AutocompleteMenu
headerContent={
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
<Text size="L400">Commands</Text>
</Box>
}
requestClose={requestClose}
>
{autoCompleteNames.map((commandName) => (
<MenuItem
key={commandName}
as="button"
radii="300"
style={{ height: 'unset' }}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Box
style={{ padding: `${config.space.S300} 0` }}
grow="Yes"
direction="Column"
gap="100"
justifyContent="SpaceBetween"
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{commands[commandName].description}
</Text>
</Box>
</MenuItem>
))}
</AutocompleteMenu>
);
}