Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
19 changes: 17 additions & 2 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Tooltip, IconButton } from '@/component-library';
import './ChatInput.scss';

const log = createLogger('ChatInput');
const IME_ENTER_GUARD_MS = 120;

export interface ChatInputProps {
className?: string;
Expand All @@ -57,6 +58,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({

const richTextInputRef = useRef<HTMLDivElement>(null);
const modeDropdownRef = useRef<HTMLDivElement>(null);
const isImeComposingRef = useRef(false);
const lastImeCompositionEndAtRef = useRef(0);

const contexts = useContextStore(state => state.contexts);
const addContext = useContextStore(state => state.addContext);
Expand Down Expand Up @@ -736,10 +739,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
}

const isComposing = (e.nativeEvent as KeyboardEvent).isComposing;
const isComposing = (e.nativeEvent as KeyboardEvent).isComposing || isImeComposingRef.current;
const justFinishedComposition = Date.now() - lastImeCompositionEndAtRef.current < IME_ENTER_GUARD_MS;

if (e.key === 'Enter' && !e.shiftKey) {
if (isComposing) {
if (isComposing || justFinishedComposition) {
return;
}

Expand All @@ -759,6 +763,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
}, [handleSendOrCancel, derivedState, transition, templateState.fillState, moveToNextPlaceholder, moveToPrevPlaceholder, exitTemplateMode, slashCommandState, getFilteredModes, selectSlashCommandMode, canSwitchModes]);

const handleImeCompositionStart = useCallback(() => {
isImeComposingRef.current = true;
}, []);

const handleImeCompositionEnd = useCallback(() => {
isImeComposingRef.current = false;
lastImeCompositionEndAtRef.current = Date.now();
}, []);

const handleImageInput = useCallback(() => {
const remaining = CHAT_INPUT_CONFIG.image.maxCount - currentImageCount;
if (remaining <= 0) {
Expand Down Expand Up @@ -974,6 +987,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
value={inputState.value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onCompositionStart={handleImeCompositionStart}
onCompositionEnd={handleImeCompositionEnd}
placeholder={t('input.placeholder')}
disabled={false}
contexts={contexts}
Expand Down
13 changes: 9 additions & 4 deletions src/web-ui/src/flow_chat/components/RichTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

import React, { useRef, useEffect, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ContextItem } from '../../shared/types/context';
import './RichTextInput.scss';

Expand All @@ -19,6 +18,8 @@ export interface RichTextInputProps {
value: string;
onChange: (value: string, contexts: ContextItem[]) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
onCompositionStart?: () => void;
onCompositionEnd?: () => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
Expand All @@ -34,6 +35,8 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
value,
onChange,
onKeyDown,
onCompositionStart,
onCompositionEnd,
onFocus,
onBlur,
placeholder = 'Describe your request...',
Expand Down Expand Up @@ -334,7 +337,7 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// IME composition: let the IME handle certain keys
const isComposing = (e.nativeEvent as KeyboardEvent).isComposing;
const isComposing = (e.nativeEvent as KeyboardEvent).isComposing || isComposingRef.current;

// Handle tag deletion only when not composing
if (!isComposing && e.key === 'Backspace' && internalRef.current) {
Expand Down Expand Up @@ -529,12 +532,14 @@ export const RichTextInput = React.forwardRef<HTMLDivElement, RichTextInputProps
// Handle IME composition
const handleCompositionStart = useCallback(() => {
isComposingRef.current = true;
}, []);
onCompositionStart?.();
}, [onCompositionStart]);

const handleCompositionEnd = useCallback(() => {
isComposingRef.current = false;
onCompositionEnd?.();
handleInput();
}, [handleInput]);
}, [handleInput, onCompositionEnd]);

return (
<div
Expand Down
26 changes: 26 additions & 0 deletions tests/e2e/page-objects/components/ChatInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,32 @@ export class ChatInput extends BasePage {
}
return false;
}

async triggerCompositionStart(): Promise<void> {
await browser.execute((selector) => {
const input = document.querySelector(selector);
if (!input) return;

const event = typeof CompositionEvent !== 'undefined'
? new CompositionEvent('compositionstart', { bubbles: true, data: '' })
: new Event('compositionstart', { bubbles: true });

input.dispatchEvent(event);
}, this.selectors.textarea);
}

async triggerCompositionEnd(): Promise<void> {
await browser.execute((selector) => {
const input = document.querySelector(selector);
if (!input) return;

const event = typeof CompositionEvent !== 'undefined'
? new CompositionEvent('compositionend', { bubbles: true, data: '' })
: new Event('compositionend', { bubbles: true });

input.dispatchEvent(event);
}, this.selectors.textarea);
}
}

export default ChatInput;
27 changes: 27 additions & 0 deletions tests/e2e/specs/chat/basic-chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,33 @@ describe('BitFun basic chat', () => {
expect(placeholder.length).toBeGreaterThan(0);
console.log('[Test] Input placeholder:', placeholder);
});

it('should not send on Enter while IME composition is active', async function () {
if (!hasWorkspace) {
this.skip();
return;
}

const testMessage = 'IME composition guard message';
await chatInput.clear();
await chatInput.typeMessage(testMessage);
await chatInput.focus();

await chatInput.triggerCompositionStart();
await browser.keys(['Enter']);
await browser.pause(300);

const valueWhileComposing = await chatInput.getValue();
expect(valueWhileComposing).toContain(testMessage);

await chatInput.triggerCompositionEnd();
await browser.pause(180);
await browser.keys(['Enter']);
await browser.pause(800);

const valueAfterComposition = await chatInput.getValue();
expect(valueAfterComposition).toBe('');
});
});

describe('Send message (no wait for response)', () => {
Expand Down