From ba4e6d2e9549bbac648b1554353c224c08ea8e3c Mon Sep 17 00:00:00 2001 From: Seiji Kohara Date: Thu, 29 Jan 2026 01:30:50 +0900 Subject: [PATCH] fix(input): prevent IME confirmation Enter from triggering message submission - Add shared IME utilities (src/utils/ime.ts) as single source of truth - Add useIMESafeEnterSubmit custom hook for reusable IME handling - Fix justEndedRef reset condition from keyCode !== 229 to e.key !== "Enter" - Implement multi-layered IME detection: isComposing, composition events, keyCode 229 Supports all IME languages: Japanese, Chinese, Korean, Vietnamese, etc. --- src/components/AgentExecution.tsx | 40 +++--- src/components/ClaudeCodeSession.tsx | 36 +++-- src/components/FloatingPromptInput.tsx | 76 +++++------ src/components/TimelineNavigator.tsx | 40 +++--- src/components/WebviewPreview.tsx | 32 +++-- src/hooks/useIMESafeEnterSubmit.ts | 127 ++++++++++++++++++ src/utils/ime.ts | 178 +++++++++++++++++++++++++ 7 files changed, 431 insertions(+), 98 deletions(-) create mode 100644 src/hooks/useIMESafeEnterSubmit.ts create mode 100644 src/utils/ime.ts diff --git a/src/components/AgentExecution.tsx b/src/components/AgentExecution.tsx index 90f089ccd..842cb369d 100644 --- a/src/components/AgentExecution.tsx +++ b/src/components/AgentExecution.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { - ArrowLeft, - Play, - StopCircle, +import { + ArrowLeft, + Play, + StopCircle, Terminal, AlertCircle, Loader2, @@ -34,6 +34,11 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { HooksEditor } from "./HooksEditor"; import { useTrackEvent, useComponentMetrics, useFeatureAdoptionTracking } from "@/hooks"; import { useTabState } from "@/hooks/useTabState"; +import { + isImeComposingKeydown, + createCompositionHandlers, + type IMECompositionRefs, +} from "@/utils/ime"; interface AgentExecutionProps { /** @@ -109,7 +114,8 @@ export const AgentExecution: React.FC = ({ const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false); // IME composition state - const isIMEComposingRef = useRef(false); + const isComposingRef = useRef(false); + const justEndedRef = useRef(false); const [activeHooksTab, setActiveHooksTab] = useState("project"); // Execution stats @@ -431,15 +437,13 @@ export const AgentExecution: React.FC = ({ } }; - const handleCompositionStart = () => { - isIMEComposingRef.current = true; - }; - - const handleCompositionEnd = () => { - setTimeout(() => { - isIMEComposingRef.current = false; - }, 0); - }; + // IME composition handlers using shared utility + const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef }; + const { + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onBlur: handleBlur, + } = createCompositionHandlers(imeRefs); const handleBackWithConfirmation = () => { if (isRunning) { @@ -680,8 +684,13 @@ export const AgentExecution: React.FC = ({ disabled={isRunning} className="flex-1 h-9" onKeyDown={(e) => { + if (justEndedRef.current && e.key !== "Enter") { + justEndedRef.current = false; + } if (e.key === "Enter" && !isRunning && projectPath && task.trim()) { - if (e.nativeEvent.isComposing || isIMEComposingRef.current) { + const composing = isImeComposingKeydown(e, isComposingRef); + if (composing || justEndedRef.current) { + justEndedRef.current = false; return; } handleExecute(); @@ -689,6 +698,7 @@ export const AgentExecution: React.FC = ({ }} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} /> = ({ const isMountedRef = useRef(true); const isListeningRef = useRef(false); const sessionStartTime = useRef(Date.now()); - const isIMEComposingRef = useRef(false); + // Tracks IME composition state + const isComposingRef = useRef(false); + const justEndedRef = useRef(false); // Session metrics state for enhanced analytics const sessionMetrics = useRef({ @@ -1096,15 +1103,13 @@ export const ClaudeCodeSession: React.FC = ({ setShowForkDialog(true); }; - const handleCompositionStart = () => { - isIMEComposingRef.current = true; - }; - - const handleCompositionEnd = () => { - setTimeout(() => { - isIMEComposingRef.current = false; - }, 0); - }; + // IME composition handlers using shared utility + const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef }; + const { + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onBlur: handleBlur, + } = createCompositionHandlers(imeRefs); const handleConfirmFork = async () => { if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return; @@ -1695,8 +1700,14 @@ export const ClaudeCodeSession: React.FC = ({ value={forkSessionName} onChange={(e) => setForkSessionName(e.target.value)} onKeyDown={(e) => { + // Reset justEnded flag on non-Enter keys + if (justEndedRef.current && e.key !== "Enter") { + justEndedRef.current = false; + } if (e.key === "Enter" && !isLoading) { - if (e.nativeEvent.isComposing || isIMEComposingRef.current) { + const composing = isImeComposingKeydown(e, isComposingRef); + if (composing || justEndedRef.current) { + justEndedRef.current = false; return; } handleConfirmFork(); @@ -1704,6 +1715,7 @@ export const ClaudeCodeSession: React.FC = ({ }} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} /> diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..80c3fe071 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -12,7 +12,7 @@ import { Lightbulb, Cpu, Rocket, - + } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -23,6 +23,11 @@ import { FilePicker } from "./FilePicker"; import { SlashCommandPicker } from "./SlashCommandPicker"; import { ImagePreview } from "./ImagePreview"; import { type FileEntry, type SlashCommand } from "@/lib/api"; +import { + isImeComposingKeydown, + createCompositionHandlers, + type IMECompositionRefs, +} from "@/utils/ime"; // Conditional import for Tauri webview window let tauriGetCurrentWebviewWindow: any; @@ -242,7 +247,10 @@ const FloatingPromptInputInner = ( const expandedTextareaRef = useRef(null); const unlistenDragDropRef = useRef<(() => void) | null>(null); const [textareaHeight, setTextareaHeight] = useState(48); - const isIMEComposingRef = useRef(false); + // Tracks IME composition state (between compositionstart and compositionend) + const isComposingRef = useRef(false); + // Flag to skip the first Enter after compositionend (one-shot) + const justEndedRef = useRef(false); // Expose a method to add images programmatically React.useImperativeHandle( @@ -653,49 +661,17 @@ const FloatingPromptInputInner = ( }, 0); }; - const handleCompositionStart = () => { - isIMEComposingRef.current = true; - }; - - const handleCompositionEnd = () => { - setTimeout(() => { - isIMEComposingRef.current = false; - }, 0); - }; - - const isIMEInteraction = (event?: React.KeyboardEvent) => { - if (isIMEComposingRef.current) { - return true; - } - - if (!event) { - return false; - } - - const nativeEvent = event.nativeEvent; - - if (nativeEvent.isComposing) { - return true; - } - - const key = nativeEvent.key; - if (key === 'Process' || key === 'Unidentified') { - return true; - } - - const keyboardEvent = nativeEvent as unknown as KeyboardEvent; - const keyCode = keyboardEvent.keyCode ?? (keyboardEvent as unknown as { which?: number }).which; - if (keyCode === 229) { - return true; - } - - return false; - }; + // IME composition handlers using shared utility + const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef }; + const { + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onBlur: handleBlur, + } = createCompositionHandlers(imeRefs); const handleSend = () => { - if (isIMEInteraction()) { - return; - } + // handleSend is called from button click, no IME check needed + // keydown path checks in handleKeyDown if (prompt.trim() && !disabled) { let finalPrompt = prompt.trim(); @@ -714,6 +690,11 @@ const FloatingPromptInputInner = ( }; const handleKeyDown = (e: React.KeyboardEvent) => { + // Reset justEnded flag on non-Enter keys + if (justEndedRef.current && e.key !== "Enter") { + justEndedRef.current = false; + } + if (showFilePicker && e.key === 'Escape') { e.preventDefault(); setShowFilePicker(false); @@ -728,7 +709,7 @@ const FloatingPromptInputInner = ( return; } - // Add keyboard shortcut for expanding + // Keyboard shortcut for expanding if (e.key === 'e' && (e.ctrlKey || e.metaKey) && e.shiftKey) { e.preventDefault(); setIsExpanded(true); @@ -742,7 +723,10 @@ const FloatingPromptInputInner = ( !showFilePicker && !showSlashCommandPicker ) { - if (isIMEInteraction(e)) { + // Skip if IME composing or just ended composition + const composing = isImeComposingKeydown(e, isComposingRef); + if (composing || justEndedRef.current) { + justEndedRef.current = false; return; } e.preventDefault(); @@ -901,6 +885,7 @@ const FloatingPromptInputInner = ( onChange={handleTextChange} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} onPaste={handlePaste} placeholder="Type your message..." className="min-h-[200px] resize-none" @@ -1226,6 +1211,7 @@ const FloatingPromptInputInner = ( onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} onPaste={handlePaste} placeholder={ dragActive diff --git a/src/components/TimelineNavigator.tsx b/src/components/TimelineNavigator.tsx index 2cc0ce154..33fb4b1dd 100644 --- a/src/components/TimelineNavigator.tsx +++ b/src/components/TimelineNavigator.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; -import { - GitBranch, - Save, - RotateCcw, +import { + GitBranch, + Save, + RotateCcw, GitFork, AlertCircle, ChevronDown, @@ -23,6 +23,11 @@ import { api, type Checkpoint, type TimelineNode, type SessionTimeline, type Che import { cn } from "@/lib/utils"; import { formatDistanceToNow } from "date-fns"; import { useTrackEvent } from "@/hooks"; +import { + isImeComposingKeydown, + createCompositionHandlers, + type IMECompositionRefs, +} from "@/utils/ime"; interface TimelineNavigatorProps { sessionId: string; @@ -72,7 +77,8 @@ export const TimelineNavigator: React.FC = ({ const trackEvent = useTrackEvent(); // IME composition state - const isIMEComposingRef = React.useRef(false); + const isComposingRef = React.useRef(false); + const justEndedRef = React.useRef(false); // Load timeline on mount and whenever refreshVersion bumps useEffect(() => { @@ -196,15 +202,13 @@ export const TimelineNavigator: React.FC = ({ onFork(checkpoint.id); }; - const handleCompositionStart = () => { - isIMEComposingRef.current = true; - }; - - const handleCompositionEnd = () => { - setTimeout(() => { - isIMEComposingRef.current = false; - }, 0); - }; + // IME composition handlers using shared utility + const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef }; + const { + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onBlur: handleBlur, + } = createCompositionHandlers(imeRefs); const handleCompare = async (checkpoint: Checkpoint) => { if (!selectedCheckpoint) { @@ -495,8 +499,13 @@ export const TimelineNavigator: React.FC = ({ value={checkpointDescription} onChange={(e) => setCheckpointDescription(e.target.value)} onKeyDown={(e) => { + if (justEndedRef.current && e.key !== "Enter") { + justEndedRef.current = false; + } if (e.key === "Enter" && !isLoading) { - if (e.nativeEvent.isComposing || isIMEComposingRef.current) { + const composing = isImeComposingKeydown(e, isComposingRef); + if (composing || justEndedRef.current) { + justEndedRef.current = false; return; } handleCreateCheckpoint(); @@ -504,6 +513,7 @@ export const TimelineNavigator: React.FC = ({ }} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} /> diff --git a/src/components/WebviewPreview.tsx b/src/components/WebviewPreview.tsx index f99807f4b..161d96782 100644 --- a/src/components/WebviewPreview.tsx +++ b/src/components/WebviewPreview.tsx @@ -16,6 +16,11 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { + isImeComposingKeydown, + createCompositionHandlers, + type IMECompositionRefs, +} from "@/utils/ime"; interface WebviewPreviewProps { /** @@ -76,7 +81,8 @@ const WebviewPreviewComponent: React.FC = ({ const containerRef = useRef(null); const contentRef = useRef(null); // const previewId = useRef(`preview-${Date.now()}`); - const isIMEComposingRef = useRef(false); + const isComposingRef = useRef(false); + const justEndedRef = useRef(false); // Handle ESC key to exit full screen useEffect(() => { @@ -143,19 +149,22 @@ const WebviewPreviewComponent: React.FC = ({ } }; - const handleCompositionStart = () => { - isIMEComposingRef.current = true; - }; - - const handleCompositionEnd = () => { - setTimeout(() => { - isIMEComposingRef.current = false; - }, 0); - }; + // IME composition handlers using shared utility + const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef }; + const { + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + onBlur: handleBlur, + } = createCompositionHandlers(imeRefs); const handleKeyDown = (e: React.KeyboardEvent) => { + if (justEndedRef.current && e.key !== "Enter") { + justEndedRef.current = false; + } if (e.key === 'Enter') { - if (e.nativeEvent.isComposing || isIMEComposingRef.current) { + const composing = isImeComposingKeydown(e, isComposingRef); + if (composing || justEndedRef.current) { + justEndedRef.current = false; return; } handleNavigate(); @@ -286,6 +295,7 @@ const WebviewPreviewComponent: React.FC = ({ onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onBlur={handleBlur} placeholder="Enter URL..." className="pr-10 h-8 text-sm font-mono" /> diff --git a/src/hooks/useIMESafeEnterSubmit.ts b/src/hooks/useIMESafeEnterSubmit.ts new file mode 100644 index 000000000..776a0603e --- /dev/null +++ b/src/hooks/useIMESafeEnterSubmit.ts @@ -0,0 +1,127 @@ +import { useCallback, useRef } from "react"; +import { + isImeComposingKeydown, + createCompositionHandlers, + type IMECompositionRefs, +} from "../utils/ime"; + +/** + * Custom hook for IME-safe Enter key submission + * + * Solves the common problem in chat interfaces where pressing Enter + * to confirm IME conversion (e.g., Japanese kanji selection) triggers + * unintended message submission. + * + * ## Problem + * When typing Japanese/Chinese/Korean with IME: + * 1. First Enter confirms the character conversion (e.g., hiragana → kanji) + * 2. Second Enter should send the message + * Without proper handling, both Enter presses send the message. + * + * ## Solution + * This hook combines multiple detection methods to reliably identify + * IME composition state across different browsers and WebViews: + * - isComposing property (React and native) + * - compositionstart/compositionend event tracking + * - keyCode 229 fallback for Safari/WKWebView + * + * ## Usage + * ```tsx + * const ime = useIMESafeEnterSubmit(handleSend); + * + *