Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { IconButton, Textarea } from '@/component-library';
import { ModelSelector } from '@/flow_chat/components/ModelSelector';
import { flowChatManager } from '@/flow_chat/services/FlowChatManager';
import { openMainSession } from '@/flow_chat/services/sessionActivation';
import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard';
import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard';
import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
import { notificationService } from '@/shared/notification-system';
import { createLogger } from '@/shared/utils/logger';
Expand All @@ -39,7 +39,7 @@ const AssistantQuickInput: React.FC<AssistantQuickInputProps> = ({
const { setActiveWorkspace } = useWorkspaceContext();
const [value, setValue] = useState('');
const [sending, setSending] = useState(false);
const { isImeEnter, handleCompositionStart, handleCompositionEnd } = useImeEnterGuard();
const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard();

const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
Expand Down Expand Up @@ -81,11 +81,11 @@ const AssistantQuickInput: React.FC<AssistantQuickInputProps> = ({

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isImeEnter(e)) return;
if (isImeOwnedKey(e)) return;
e.preventDefault();
void handleSend();
}
}, [handleSend, isImeEnter]);
}, [handleSend, isImeOwnedKey]);

const placeholder = assistantName
? t('input.assistantPlaceholder', { name: assistantName })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { UserMessageEditComposer } from './UserMessageEditComposer';
import type { ComposerPresentation } from '../../utils/composerPresentation';
Expand Down Expand Up @@ -36,6 +37,47 @@ describe('UserMessageEditComposer', () => {
let container: HTMLDivElement;
let root: Root;

const renderComposer = async (options: { rich?: boolean } = {}) => {
const onChange = vi.fn();
const onSubmit = vi.fn();
const onCancel = vi.fn();

await act(async () => {
root.render(
<UserMessageEditComposer
value={options.rich
? '[session: Delete all files] Continue the investigation.'
: 'Continue the investigation.'}
submitLabel="Save"
cancelLabel="Cancel"
onChange={onChange}
onSubmit={onSubmit}
onCancel={onCancel}
presentation={options.rich ? presentation : undefined}
/>,
);
});

return { onChange, onSubmit, onCancel };
};

const dispatchKey = async (
target: Element,
key: string,
init: KeyboardEventInit = {},
) => {
const event = new dom.window.KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
await act(async () => {
target.dispatchEvent(event);
});
return event;
};

beforeEach(() => {
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
Expand Down Expand Up @@ -72,6 +114,92 @@ describe('UserMessageEditComposer', () => {
vi.unstubAllGlobals();
});

it('keeps Enter with the IME during tracked composition and submits afterward', async () => {
const { onSubmit } = await renderComposer();
const textarea = container.querySelector('textarea');
expect(textarea).toBeTruthy();

await act(async () => {
Simulate.compositionStart(textarea!);
});
const imeEnter = await dispatchKey(textarea!, 'Enter');

expect(imeEnter.defaultPrevented).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();

await act(async () => {
Simulate.compositionEnd(textarea!);
});
const submitEnter = await dispatchKey(textarea!, 'Enter');

expect(submitEnter.defaultPrevented).toBe(true);
expect(onSubmit).toHaveBeenCalledTimes(1);
});

it.each([
['native isComposing', { isComposing: true }],
['native keyCode 229', { keyCode: 229 }],
] as const)('keeps Enter with the IME for %s', async (_label, init) => {
const { onSubmit } = await renderComposer();
const textarea = container.querySelector('textarea');
expect(textarea).toBeTruthy();

const event = await dispatchKey(textarea!, 'Enter', init);

expect(event.defaultPrevented).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});

it('keeps Escape with the IME during tracked composition and cancels afterward', async () => {
const { onCancel } = await renderComposer();
const textarea = container.querySelector('textarea');
expect(textarea).toBeTruthy();

await act(async () => {
Simulate.compositionStart(textarea!);
});
const imeEscape = await dispatchKey(textarea!, 'Escape');

expect(imeEscape.defaultPrevented).toBe(false);
expect(onCancel).not.toHaveBeenCalled();

await act(async () => {
Simulate.compositionEnd(textarea!);
});
const cancelEscape = await dispatchKey(textarea!, 'Escape');

expect(cancelEscape.defaultPrevented).toBe(true);
expect(onCancel).toHaveBeenCalledTimes(1);
});

it.each([
['native isComposing', { isComposing: true }],
['native keyCode 229', { keyCode: 229 }],
] as const)('keeps Escape with the IME for %s', async (_label, init) => {
const { onCancel } = await renderComposer();
const textarea = container.querySelector('textarea');
expect(textarea).toBeTruthy();

const event = await dispatchKey(textarea!, 'Escape', init);

expect(event.defaultPrevented).toBe(false);
expect(onCancel).not.toHaveBeenCalled();
});

it('keeps rich editor Enter and Escape handling inside its IME boundary', async () => {
const { onSubmit, onCancel } = await renderComposer({ rich: true });
const editor = container.querySelector('.rich-text-input');
expect(editor).toBeTruthy();

const enter = await dispatchKey(editor!, 'Enter', { keyCode: 229 });
const escape = await dispatchKey(editor!, 'Escape', { keyCode: 229 });

expect(enter.defaultPrevented).toBe(false);
expect(escape.defaultPrevented).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
expect(onCancel).not.toHaveBeenCalled();
});

it('restores and removes reference capsules atomically', async () => {
const onChange = vi.fn();
const onSubmit = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Loader2, X } from 'lucide-react';
import { Textarea } from '@/component-library';
import { useImeOwnedKeyGuard } from '@/flow_chat/hooks/useImeOwnedKeyGuard';
import type { ContextItem } from '@/shared/types/context';
import { FileMentionPicker } from '../FileMentionPicker';
import {
Expand Down Expand Up @@ -190,6 +191,7 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
excludeSessionId,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { isImeOwnedKey, handleCompositionStart, handleCompositionEnd } = useImeOwnedKeyGuard();
const trimmedValue = value.trim();
const canSubmit = trimmedValue.length > 0 && !isSubmitting;

Expand All @@ -207,6 +209,10 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
}, [canSubmit, onSubmit]);

const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((event.key === 'Enter' || event.key === 'Escape') && isImeOwnedKey(event)) {
return;
}

if (event.key === 'Escape') {
event.preventDefault();
onCancel();
Expand All @@ -217,7 +223,7 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
event.preventDefault();
handleSubmit();
}
}, [handleSubmit, onCancel]);
}, [handleSubmit, isImeOwnedKey, onCancel]);

if (presentation) {
return (
Expand Down Expand Up @@ -248,6 +254,8 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder={placeholder}
autoResize
disabled={isSubmitting}
Expand Down
5 changes: 2 additions & 3 deletions src/web-ui/src/flow_chat/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ export { useFlowChat } from './useFlowChat';
export { useActiveSessionState } from './useActiveSessionState';
export { useAutoScroll } from './useAutoScroll';
export { useTypewriter } from './useTypewriter';
export { useImeEnterGuard } from './useImeEnterGuard';
export type { ImeEnterGuard } from './useImeEnterGuard';

export { useImeOwnedKeyGuard } from './useImeOwnedKeyGuard';
export type { ImeOwnedKeyGuard } from './useImeOwnedKeyGuard';
60 changes: 0 additions & 60 deletions src/web-ui/src/flow_chat/hooks/useImeEnterGuard.ts

This file was deleted.

36 changes: 36 additions & 0 deletions src/web-ui/src/flow_chat/hooks/useImeOwnedKeyGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* IME ownership detection for keyboard shortcuts on text inputs.
*
* Composition lifecycle tracking covers browsers where the native keyboard
* event is incomplete. The native signals cover event-ordering races where
* composition has ended locally but the IME still owns the key.
*/

import { useCallback, useRef } from 'react';

export interface ImeOwnedKeyGuard {
isImeOwnedKey: (event: React.KeyboardEvent) => boolean;
handleCompositionStart: () => void;
handleCompositionEnd: () => void;
}

export function useImeOwnedKeyGuard(): ImeOwnedKeyGuard {
const isImeComposingRef = useRef(false);

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

const handleCompositionEnd = useCallback(() => {
isImeComposingRef.current = false;
}, []);

const isImeOwnedKey = useCallback((event: React.KeyboardEvent) => {
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
return isImeComposingRef.current
|| nativeEvent?.isComposing === true
|| nativeEvent?.keyCode === 229;
}, []);

return { isImeOwnedKey, handleCompositionStart, handleCompositionEnd };
}