Skip to content

Commit ba4e6d2

Browse files
committed
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.
1 parent 70c16d8 commit ba4e6d2

File tree

7 files changed

+431
-98
lines changed

7 files changed

+431
-98
lines changed

src/components/AgentExecution.tsx

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { useState, useEffect, useRef } from "react";
22
import { motion, AnimatePresence } from "framer-motion";
3-
import {
4-
ArrowLeft,
5-
Play,
6-
StopCircle,
3+
import {
4+
ArrowLeft,
5+
Play,
6+
StopCircle,
77
Terminal,
88
AlertCircle,
99
Loader2,
@@ -34,6 +34,11 @@ import { useVirtualizer } from "@tanstack/react-virtual";
3434
import { HooksEditor } from "./HooksEditor";
3535
import { useTrackEvent, useComponentMetrics, useFeatureAdoptionTracking } from "@/hooks";
3636
import { useTabState } from "@/hooks/useTabState";
37+
import {
38+
isImeComposingKeydown,
39+
createCompositionHandlers,
40+
type IMECompositionRefs,
41+
} from "@/utils/ime";
3742

3843
interface AgentExecutionProps {
3944
/**
@@ -109,7 +114,8 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
109114
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
110115

111116
// IME composition state
112-
const isIMEComposingRef = useRef(false);
117+
const isComposingRef = useRef(false);
118+
const justEndedRef = useRef(false);
113119
const [activeHooksTab, setActiveHooksTab] = useState("project");
114120

115121
// Execution stats
@@ -431,15 +437,13 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
431437
}
432438
};
433439

434-
const handleCompositionStart = () => {
435-
isIMEComposingRef.current = true;
436-
};
437-
438-
const handleCompositionEnd = () => {
439-
setTimeout(() => {
440-
isIMEComposingRef.current = false;
441-
}, 0);
442-
};
440+
// IME composition handlers using shared utility
441+
const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef };
442+
const {
443+
onCompositionStart: handleCompositionStart,
444+
onCompositionEnd: handleCompositionEnd,
445+
onBlur: handleBlur,
446+
} = createCompositionHandlers(imeRefs);
443447

444448
const handleBackWithConfirmation = () => {
445449
if (isRunning) {
@@ -680,15 +684,21 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
680684
disabled={isRunning}
681685
className="flex-1 h-9"
682686
onKeyDown={(e) => {
687+
if (justEndedRef.current && e.key !== "Enter") {
688+
justEndedRef.current = false;
689+
}
683690
if (e.key === "Enter" && !isRunning && projectPath && task.trim()) {
684-
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
691+
const composing = isImeComposingKeydown(e, isComposingRef);
692+
if (composing || justEndedRef.current) {
693+
justEndedRef.current = false;
685694
return;
686695
}
687696
handleExecute();
688697
}
689698
}}
690699
onCompositionStart={handleCompositionStart}
691700
onCompositionEnd={handleCompositionEnd}
701+
onBlur={handleBlur}
692702
/>
693703
<motion.div
694704
whileTap={{ scale: 0.97 }}

src/components/ClaudeCodeSession.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect, useRef, useMemo } from "react";
22
import { motion, AnimatePresence } from "framer-motion";
3-
import {
3+
import {
44
Copy,
55
ChevronDown,
66
GitBranch,
@@ -15,6 +15,11 @@ import { Label } from "@/components/ui/label";
1515
import { Popover } from "@/components/ui/popover";
1616
import { api, type Session } from "@/lib/api";
1717
import { cn } from "@/lib/utils";
18+
import {
19+
isImeComposingKeydown,
20+
createCompositionHandlers,
21+
type IMECompositionRefs,
22+
} from "@/utils/ime";
1823

1924
// Conditional imports for Tauri APIs
2025
let tauriListen: any;
@@ -145,7 +150,9 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
145150
const isMountedRef = useRef(true);
146151
const isListeningRef = useRef(false);
147152
const sessionStartTime = useRef<number>(Date.now());
148-
const isIMEComposingRef = useRef(false);
153+
// Tracks IME composition state
154+
const isComposingRef = useRef(false);
155+
const justEndedRef = useRef(false);
149156

150157
// Session metrics state for enhanced analytics
151158
const sessionMetrics = useRef({
@@ -1096,15 +1103,13 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
10961103
setShowForkDialog(true);
10971104
};
10981105

1099-
const handleCompositionStart = () => {
1100-
isIMEComposingRef.current = true;
1101-
};
1102-
1103-
const handleCompositionEnd = () => {
1104-
setTimeout(() => {
1105-
isIMEComposingRef.current = false;
1106-
}, 0);
1107-
};
1106+
// IME composition handlers using shared utility
1107+
const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef };
1108+
const {
1109+
onCompositionStart: handleCompositionStart,
1110+
onCompositionEnd: handleCompositionEnd,
1111+
onBlur: handleBlur,
1112+
} = createCompositionHandlers(imeRefs);
11081113

11091114
const handleConfirmFork = async () => {
11101115
if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return;
@@ -1695,15 +1700,22 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
16951700
value={forkSessionName}
16961701
onChange={(e) => setForkSessionName(e.target.value)}
16971702
onKeyDown={(e) => {
1703+
// Reset justEnded flag on non-Enter keys
1704+
if (justEndedRef.current && e.key !== "Enter") {
1705+
justEndedRef.current = false;
1706+
}
16981707
if (e.key === "Enter" && !isLoading) {
1699-
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
1708+
const composing = isImeComposingKeydown(e, isComposingRef);
1709+
if (composing || justEndedRef.current) {
1710+
justEndedRef.current = false;
17001711
return;
17011712
}
17021713
handleConfirmFork();
17031714
}
17041715
}}
17051716
onCompositionStart={handleCompositionStart}
17061717
onCompositionEnd={handleCompositionEnd}
1718+
onBlur={handleBlur}
17071719
/>
17081720
</div>
17091721
</div>

src/components/FloatingPromptInput.tsx

Lines changed: 31 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
Lightbulb,
1313
Cpu,
1414
Rocket,
15-
15+
1616
} from "lucide-react";
1717
import { cn } from "@/lib/utils";
1818
import { Button } from "@/components/ui/button";
@@ -23,6 +23,11 @@ import { FilePicker } from "./FilePicker";
2323
import { SlashCommandPicker } from "./SlashCommandPicker";
2424
import { ImagePreview } from "./ImagePreview";
2525
import { type FileEntry, type SlashCommand } from "@/lib/api";
26+
import {
27+
isImeComposingKeydown,
28+
createCompositionHandlers,
29+
type IMECompositionRefs,
30+
} from "@/utils/ime";
2631

2732
// Conditional import for Tauri webview window
2833
let tauriGetCurrentWebviewWindow: any;
@@ -242,7 +247,10 @@ const FloatingPromptInputInner = (
242247
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
243248
const unlistenDragDropRef = useRef<(() => void) | null>(null);
244249
const [textareaHeight, setTextareaHeight] = useState<number>(48);
245-
const isIMEComposingRef = useRef(false);
250+
// Tracks IME composition state (between compositionstart and compositionend)
251+
const isComposingRef = useRef(false);
252+
// Flag to skip the first Enter after compositionend (one-shot)
253+
const justEndedRef = useRef(false);
246254

247255
// Expose a method to add images programmatically
248256
React.useImperativeHandle(
@@ -653,49 +661,17 @@ const FloatingPromptInputInner = (
653661
}, 0);
654662
};
655663

656-
const handleCompositionStart = () => {
657-
isIMEComposingRef.current = true;
658-
};
659-
660-
const handleCompositionEnd = () => {
661-
setTimeout(() => {
662-
isIMEComposingRef.current = false;
663-
}, 0);
664-
};
665-
666-
const isIMEInteraction = (event?: React.KeyboardEvent) => {
667-
if (isIMEComposingRef.current) {
668-
return true;
669-
}
670-
671-
if (!event) {
672-
return false;
673-
}
674-
675-
const nativeEvent = event.nativeEvent;
676-
677-
if (nativeEvent.isComposing) {
678-
return true;
679-
}
680-
681-
const key = nativeEvent.key;
682-
if (key === 'Process' || key === 'Unidentified') {
683-
return true;
684-
}
685-
686-
const keyboardEvent = nativeEvent as unknown as KeyboardEvent;
687-
const keyCode = keyboardEvent.keyCode ?? (keyboardEvent as unknown as { which?: number }).which;
688-
if (keyCode === 229) {
689-
return true;
690-
}
691-
692-
return false;
693-
};
664+
// IME composition handlers using shared utility
665+
const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef };
666+
const {
667+
onCompositionStart: handleCompositionStart,
668+
onCompositionEnd: handleCompositionEnd,
669+
onBlur: handleBlur,
670+
} = createCompositionHandlers(imeRefs);
694671

695672
const handleSend = () => {
696-
if (isIMEInteraction()) {
697-
return;
698-
}
673+
// handleSend is called from button click, no IME check needed
674+
// keydown path checks in handleKeyDown
699675

700676
if (prompt.trim() && !disabled) {
701677
let finalPrompt = prompt.trim();
@@ -714,6 +690,11 @@ const FloatingPromptInputInner = (
714690
};
715691

716692
const handleKeyDown = (e: React.KeyboardEvent) => {
693+
// Reset justEnded flag on non-Enter keys
694+
if (justEndedRef.current && e.key !== "Enter") {
695+
justEndedRef.current = false;
696+
}
697+
717698
if (showFilePicker && e.key === 'Escape') {
718699
e.preventDefault();
719700
setShowFilePicker(false);
@@ -728,7 +709,7 @@ const FloatingPromptInputInner = (
728709
return;
729710
}
730711

731-
// Add keyboard shortcut for expanding
712+
// Keyboard shortcut for expanding
732713
if (e.key === 'e' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
733714
e.preventDefault();
734715
setIsExpanded(true);
@@ -742,7 +723,10 @@ const FloatingPromptInputInner = (
742723
!showFilePicker &&
743724
!showSlashCommandPicker
744725
) {
745-
if (isIMEInteraction(e)) {
726+
// Skip if IME composing or just ended composition
727+
const composing = isImeComposingKeydown(e, isComposingRef);
728+
if (composing || justEndedRef.current) {
729+
justEndedRef.current = false;
746730
return;
747731
}
748732
e.preventDefault();
@@ -901,6 +885,7 @@ const FloatingPromptInputInner = (
901885
onChange={handleTextChange}
902886
onCompositionStart={handleCompositionStart}
903887
onCompositionEnd={handleCompositionEnd}
888+
onBlur={handleBlur}
904889
onPaste={handlePaste}
905890
placeholder="Type your message..."
906891
className="min-h-[200px] resize-none"
@@ -1226,6 +1211,7 @@ const FloatingPromptInputInner = (
12261211
onKeyDown={handleKeyDown}
12271212
onCompositionStart={handleCompositionStart}
12281213
onCompositionEnd={handleCompositionEnd}
1214+
onBlur={handleBlur}
12291215
onPaste={handlePaste}
12301216
placeholder={
12311217
dragActive

src/components/TimelineNavigator.tsx

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { useState, useEffect } from "react";
22
import { motion } from "framer-motion";
3-
import {
4-
GitBranch,
5-
Save,
6-
RotateCcw,
3+
import {
4+
GitBranch,
5+
Save,
6+
RotateCcw,
77
GitFork,
88
AlertCircle,
99
ChevronDown,
@@ -23,6 +23,11 @@ import { api, type Checkpoint, type TimelineNode, type SessionTimeline, type Che
2323
import { cn } from "@/lib/utils";
2424
import { formatDistanceToNow } from "date-fns";
2525
import { useTrackEvent } from "@/hooks";
26+
import {
27+
isImeComposingKeydown,
28+
createCompositionHandlers,
29+
type IMECompositionRefs,
30+
} from "@/utils/ime";
2631

2732
interface TimelineNavigatorProps {
2833
sessionId: string;
@@ -72,7 +77,8 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
7277
const trackEvent = useTrackEvent();
7378

7479
// IME composition state
75-
const isIMEComposingRef = React.useRef(false);
80+
const isComposingRef = React.useRef(false);
81+
const justEndedRef = React.useRef(false);
7682

7783
// Load timeline on mount and whenever refreshVersion bumps
7884
useEffect(() => {
@@ -196,15 +202,13 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
196202
onFork(checkpoint.id);
197203
};
198204

199-
const handleCompositionStart = () => {
200-
isIMEComposingRef.current = true;
201-
};
202-
203-
const handleCompositionEnd = () => {
204-
setTimeout(() => {
205-
isIMEComposingRef.current = false;
206-
}, 0);
207-
};
205+
// IME composition handlers using shared utility
206+
const imeRefs: IMECompositionRefs = { isComposingRef, justEndedRef };
207+
const {
208+
onCompositionStart: handleCompositionStart,
209+
onCompositionEnd: handleCompositionEnd,
210+
onBlur: handleBlur,
211+
} = createCompositionHandlers(imeRefs);
208212

209213
const handleCompare = async (checkpoint: Checkpoint) => {
210214
if (!selectedCheckpoint) {
@@ -495,15 +499,21 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
495499
value={checkpointDescription}
496500
onChange={(e) => setCheckpointDescription(e.target.value)}
497501
onKeyDown={(e) => {
502+
if (justEndedRef.current && e.key !== "Enter") {
503+
justEndedRef.current = false;
504+
}
498505
if (e.key === "Enter" && !isLoading) {
499-
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
506+
const composing = isImeComposingKeydown(e, isComposingRef);
507+
if (composing || justEndedRef.current) {
508+
justEndedRef.current = false;
500509
return;
501510
}
502511
handleCreateCheckpoint();
503512
}
504513
}}
505514
onCompositionStart={handleCompositionStart}
506515
onCompositionEnd={handleCompositionEnd}
516+
onBlur={handleBlur}
507517
/>
508518
</div>
509519
</div>

0 commit comments

Comments
 (0)