Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 132 additions & 23 deletions packages/app/ui/components/BareChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,60 @@
MessageInputProps,
} from '../MessageInput/MessageInputBase';
import { hydrateEditPost } from '../MessageInput/helpers';
import { type SlashCommandController } from '../SlashCommandPopup';
import type { DraftInputHandle } from '../draftInputs/shared';
import { contentToTextAndMentions, textAndMentionsToContent } from './helpers';
import {
MentionOption,
createMentionRoleOptions,
useMentions,
} from './useMentions';
import { type SlashCommandOption, useSlashCommands } from './useSlashCommands';

const bareChatInputLogger = createDevLogger('bareChatInput', false);

const DEFAULT_KEYBOARD_HEIGHT = 300;

type WebTextInputRef = TextInput & { selectionStart?: number };

type TextInputSizeEvent = {
target?: unknown;
};

type ResizableTextInputTarget = {
style: { height: string | number };
offsetHeight: number;
clientHeight: number;
scrollHeight: number;
};

type ComposerKeyPressEvent = {
preventDefault?: () => void;
nativeEvent: {
key: string;
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
};
};

function isResizableTextInputTarget(
target: unknown
): target is ResizableTextInputTarget {
if (typeof target !== 'object' || target === null) {
return false;
}

const candidate = target as Partial<ResizableTextInputTarget>;
return (
typeof candidate.style === 'object' &&
candidate.style !== null &&
typeof candidate.offsetHeight === 'number' &&
typeof candidate.clientHeight === 'number' &&
typeof candidate.scrollHeight === 'number'
);
}

function useKeyboardHeight(maxInputHeightBasic: number) {
const [maxInputHeight, setMaxInputHeight] = useState(maxInputHeightBasic);

Expand Down Expand Up @@ -293,6 +335,14 @@
handleSelectMention,
handleMentionEscape,
} = useMentions({ chatId: groupId ?? channelId, roleOptions });
const {
validSlashCommands,
isSlashCommandModeActive,
hasSlashCommandCandidates,
handleSlashCommand,
handleSelectSlashCommand,
handleSlashCommandEscape,
} = useSlashCommands();
const maxInputHeight = useKeyboardHeight(maxInputHeightBasic);
const inputRef = useRef<TextInput>(null);

Expand Down Expand Up @@ -343,6 +393,17 @@

const lastProcessedRef = useRef('');
const mentionRef = useRef<MentionController>(null);
const slashCommandRef = useRef<SlashCommandController>(null);

const getWebSelectionStart = useCallback(() => {
if (!isWeb) {
return undefined;
}

const selectionStart = (inputRef.current as WebTextInputRef | null)
?.selectionStart;
return typeof selectionStart === 'number' ? selectionStart : undefined;
}, []);

const handleTextChange = useCallback(
(newText: string) => {
Expand All @@ -358,15 +419,14 @@
if (REF_REGEX.test(newText) && lastProcessedRef.current !== newText) {
lastProcessedRef.current = newText;
const textWithoutRefs = processReferences(newText);
const cursorPos = isWeb
? (inputRef.current as any)?.selectionStart
: undefined;
const cursorPos = getWebSelectionStart();
const adjustedCursorPos =
cursorPos != null
? Math.max(0, cursorPos - (newText.length - textWithoutRefs.length))
: undefined;
setControlledText(textWithoutRefs);
handleMention(oldText, textWithoutRefs, adjustedCursorPos);
handleSlashCommand(oldText, textWithoutRefs, adjustedCursorPos);

const jsonContent = textAndMentionsToContent(textWithoutRefs, mentions);
bareChatInputLogger.log('setting draft', jsonContent);
Expand All @@ -383,18 +443,25 @@
}
} else if (!REF_REGEX.test(newText)) {
// if there's no reference to process, just update normally
const cursorPos = isWeb
? (inputRef.current as any)?.selectionStart
: undefined;
const cursorPos = getWebSelectionStart();
setControlledText(newText);
handleMention(oldText, newText, cursorPos);
handleSlashCommand(oldText, newText, cursorPos);

const jsonContent = textAndMentionsToContent(newText, mentions);
bareChatInputLogger.log('setting draft', jsonContent);
storeDraft(jsonContent);
}
},
[controlledText, processReferences, handleMention, mentions, storeDraft]
[
controlledText,
processReferences,
getWebSelectionStart,
handleMention,
handleSlashCommand,
mentions,
storeDraft,
]
);

const onMentionSelect = useCallback(
Expand All @@ -413,6 +480,26 @@
[handleSelectMention, controlledText]
);

const onSlashCommandSelect = useCallback(
(option: SlashCommandOption) => {
const newText = handleSelectSlashCommand(option, controlledText);

if (!newText) {
return;
}

setControlledText(newText);

const jsonContent = textAndMentionsToContent(newText, mentions);
bareChatInputLogger.log('setting draft', jsonContent);
storeDraft(jsonContent);

// Force focus back to input after slash command selection
inputRef.current?.focus();
},

Check failure on line 499 in packages/app/ui/components/BareChatInput/index.tsx

View check run for this annotation

Claude / Claude Code Review

Mention positions not adjusted after slash command insertion

Selecting a slash command rewrites controlledText via handleSelectSlashCommand without adjusting any mention indices, so any mention whose start ≥ slashCommandStartIndex now points at the wrong substring of the new text. This corrupts the immediately-stored draft (textAndMentionsToContent slices from stale offsets, dropping characters and duplicating mention text) and can silently lose mentions on the next keystroke when handleMention's display-equality check fails. Fix by shifting mention.start
Comment thread
patosullivan marked this conversation as resolved.
[handleSelectSlashCommand, controlledText, mentions, storeDraft]
);

const sendMessage = useCallback(
async (isEdit?: boolean) => {
const jsonContent = textAndMentionsToContent(controlledText, mentions);
Expand Down Expand Up @@ -810,14 +897,14 @@
};
const inputTextColor = getVariableValue(theme.primaryText);

const adjustTextInputSize = (e: any) => {
const adjustTextInputSize = (e: TextInputSizeEvent) => {
if (!isWeb) {
return;
}

const el = e?.target;
if (el && 'style' in el && 'height' in el.style) {
el.style.height = 0;
const el = e.target;
if (isResizableTextInputTarget(el)) {
el.style.height = '0px';
const newHeight = el.offsetHeight - el.clientHeight + el.scrollHeight;
el.style.height = `${newHeight}px`;
setInputHeight(newHeight);
Expand All @@ -841,38 +928,53 @@
}, [channelId]);

const handleKeyPress = useCallback(
(e: any) => {
const keyEvent = e.nativeEvent as unknown as KeyboardEvent;
(e: ComposerKeyPressEvent) => {
const keyEvent = e.nativeEvent;
if (!isWeb) return;

if (
(keyEvent.metaKey || keyEvent.ctrlKey) &&
keyEvent.key.toLowerCase() === 'k'
) {
e.preventDefault();
e.preventDefault?.();
inputRef.current?.blur();
setIsOpen(true);
return;
}

if (
(keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') &&
isMentionModeActive
) {
e.preventDefault();
mentionRef.current?.handleMentionKey(keyEvent.key);
if (keyEvent.key === 'ArrowUp' || keyEvent.key === 'ArrowDown') {
if (isSlashCommandModeActive) {
e.preventDefault?.();
slashCommandRef.current?.handleSlashCommandKey(keyEvent.key);
return;
}

if (isMentionModeActive) {
e.preventDefault?.();
mentionRef.current?.handleMentionKey(keyEvent.key);
return;
}
}

Check failure on line 957 in packages/app/ui/components/BareChatInput/index.tsx

View check run for this annotation

Claude / Claude Code Review

Arrow/Escape keys swallowed when slash mode active with no candidates

ArrowUp/Down (and Escape) in handleKeyPress only check `isSlashCommandModeActive`, not `hasSlashCommandCandidates`. When the slash trigger activates but no commands match (e.g. typing `hello /unknownword`, or any `/word` token after whitespace that isn't a known command), `SlashCommandPopup` renders nothing yet `preventDefault()` is still fired — so multiline cursor navigation is silently broken whenever a non-matching slash token is in the draft. The fix is to add the same `&& hasSlashCommandCa
Comment thread
patosullivan marked this conversation as resolved.

if (keyEvent.key === 'Escape') {
if (isSlashCommandModeActive) {
e.preventDefault?.();
handleSlashCommandEscape();
return;
}

if (isMentionModeActive) {
e.preventDefault();
e.preventDefault?.();
handleMentionEscape();
return;
}
}

if (keyEvent.key === 'Enter' && !keyEvent.shiftKey) {
e.preventDefault();
if (isMentionModeActive && hasMentionCandidates) {
e.preventDefault?.();
if (isSlashCommandModeActive && hasSlashCommandCandidates) {
slashCommandRef.current?.handleSlashCommandKey('Enter');
} else if (isMentionModeActive && hasMentionCandidates) {
mentionRef.current?.handleMentionKey('Enter');
} else if (editingPost) {
handleEdit();
Expand All @@ -883,9 +985,12 @@
},
[
isMentionModeActive,
isSlashCommandModeActive,
setIsOpen,
handleMentionEscape,
handleSlashCommandEscape,
hasMentionCandidates,
hasSlashCommandCandidates,
editingPost,
disableSend,
handleEdit,
Expand All @@ -902,10 +1007,14 @@
sendError={sendError}
showWayfindingTooltip={showWayfindingTooltip}
isMentionModeActive={isMentionModeActive}
isSlashCommandModeActive={isSlashCommandModeActive}
mentionText={mentionSearchText}
mentionOptions={validOptions}
slashCommandOptions={validSlashCommands}
mentionRef={mentionRef}
slashCommandRef={slashCommandRef}
onSelectMention={onMentionSelect}
onSelectSlashCommand={onSlashCommandSelect}
showAttachmentButton={showAttachmentButton}
isEditing={!!editingPost}
cancelEditing={handleCancelEditing}
Expand Down
Loading
Loading