Skip to content

Commit 9f03d77

Browse files
axin7123vivekr
authored andcommitted
fix(input): improve IME composition handling across input components
- Add IME composition state tracking to prevent premature submission during input composition - Handle onCompositionStart/End events in text inputs and textareas - Replace onKeyPress with onKeyDown to better handle IME interactions - Add proper IME detection in FloatingPromptInput for enhanced input handling - Fix issues with Enter key triggering actions during IME composition in: - AgentExecution task input - ClaudeCodeSession fork dialog - TimelineNavigator checkpoint creation - WebviewPreview URL input - FloatingPromptInput main textarea This ensures proper input behavior for users of CJK input methods and other IME systems.
1 parent 83096a3 commit 9f03d77

File tree

5 files changed

+157
-69
lines changed

5 files changed

+157
-69
lines changed

src/components/AgentExecution.tsx

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
107107

108108
// Hooks configuration state
109109
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
110+
111+
// IME composition state
112+
const isIMEComposingRef = useRef(false);
110113
const [activeHooksTab, setActiveHooksTab] = useState("project");
111114

112115
// Execution stats
@@ -413,62 +416,31 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
413416

414417
// Call the API to kill the agent session
415418
const success = await api.killAgentSession(runId);
416-
419+
417420
if (success) {
418421
console.log(`Successfully stopped agent session ${runId}`);
419422
} else {
420423
console.warn(`Failed to stop agent session ${runId} - it may have already finished`);
421424
}
422-
425+
423426
// Update UI state
424427
setIsRunning(false);
425428
setExecutionStartTime(null);
426-
// Update tab status to idle when stopped
427-
if (tabId) {
428-
updateTabStatus(tabId, 'idle');
429-
}
430-
431-
// Clean up listeners
432-
unlistenRefs.current.forEach(unlisten => unlisten());
433-
unlistenRefs.current = [];
434-
435-
// Add a message indicating execution was stopped
436-
setMessages(prev => [...prev, {
437-
type: "result",
438-
subtype: "error",
439-
is_error: true,
440-
result: "Execution stopped by user",
441-
duration_ms: elapsedTime * 1000,
442-
usage: {
443-
input_tokens: totalTokens,
444-
output_tokens: 0
445-
}
446-
}]);
447429
} catch (err) {
448430
console.error("Failed to stop agent:", err);
449-
// Still update UI state even if the backend call failed
450-
setIsRunning(false);
451-
setExecutionStartTime(null);
452-
// Update tab status to idle
453-
if (tabId) {
454-
updateTabStatus(tabId, 'idle');
455-
}
456-
457-
// Show error message
458-
setMessages(prev => [...prev, {
459-
type: "result",
460-
subtype: "error",
461-
is_error: true,
462-
result: `Failed to stop execution: ${err instanceof Error ? err.message : 'Unknown error'}`,
463-
duration_ms: elapsedTime * 1000,
464-
usage: {
465-
input_tokens: totalTokens,
466-
output_tokens: 0
467-
}
468-
}]);
469431
}
470432
};
471433

434+
const handleCompositionStart = () => {
435+
isIMEComposingRef.current = true;
436+
};
437+
438+
const handleCompositionEnd = () => {
439+
setTimeout(() => {
440+
isIMEComposingRef.current = false;
441+
}, 0);
442+
};
443+
472444
const handleBackWithConfirmation = () => {
473445
if (isRunning) {
474446
// Show confirmation dialog before navigating away during execution
@@ -707,11 +679,16 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
707679
placeholder="What would you like the agent to do?"
708680
disabled={isRunning}
709681
className="flex-1 h-9"
710-
onKeyPress={(e) => {
682+
onKeyDown={(e) => {
711683
if (e.key === "Enter" && !isRunning && projectPath && task.trim()) {
684+
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
685+
return;
686+
}
712687
handleExecute();
713688
}
714689
}}
690+
onCompositionStart={handleCompositionStart}
691+
onCompositionEnd={handleCompositionEnd}
715692
/>
716693
<motion.div
717694
whileTap={{ scale: 0.97 }}

src/components/ClaudeCodeSession.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
105105

106106
// Add collapsed state for queued prompts
107107
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
108-
108+
109109
const parentRef = useRef<HTMLDivElement>(null);
110110
const unlistenRefs = useRef<UnlistenFn[]>([]);
111111
const hasActiveSessionRef = useRef(false);
@@ -114,6 +114,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
114114
const isMountedRef = useRef(true);
115115
const isListeningRef = useRef(false);
116116
const sessionStartTime = useRef<number>(Date.now());
117+
const isIMEComposingRef = useRef(false);
117118

118119
// Session metrics state for enhanced analytics
119120
const sessionMetrics = useRef({
@@ -1051,6 +1052,16 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
10511052
setShowForkDialog(true);
10521053
};
10531054

1055+
const handleCompositionStart = () => {
1056+
isIMEComposingRef.current = true;
1057+
};
1058+
1059+
const handleCompositionEnd = () => {
1060+
setTimeout(() => {
1061+
isIMEComposingRef.current = false;
1062+
}, 0);
1063+
};
1064+
10541065
const handleConfirmFork = async () => {
10551066
if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return;
10561067

@@ -1639,11 +1650,16 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
16391650
placeholder="e.g., Alternative approach"
16401651
value={forkSessionName}
16411652
onChange={(e) => setForkSessionName(e.target.value)}
1642-
onKeyPress={(e) => {
1653+
onKeyDown={(e) => {
16431654
if (e.key === "Enter" && !isLoading) {
1655+
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
1656+
return;
1657+
}
16441658
handleConfirmFork();
16451659
}
16461660
}}
1661+
onCompositionStart={handleCompositionStart}
1662+
onCompositionEnd={handleCompositionEnd}
16471663
/>
16481664
</div>
16491665
</div>

src/components/FloatingPromptInput.tsx

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ const FloatingPromptInputInner = (
230230
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
231231
const unlistenDragDropRef = useRef<(() => void) | null>(null);
232232
const [textareaHeight, setTextareaHeight] = useState<number>(48);
233+
const isIMEComposingRef = useRef(false);
233234

234235
// Expose a method to add images programmatically
235236
React.useImperativeHandle(
@@ -432,23 +433,6 @@ const FloatingPromptInputInner = (
432433
}
433434
}, [isExpanded]);
434435

435-
const handleSend = () => {
436-
if (prompt.trim() && !disabled) {
437-
let finalPrompt = prompt.trim();
438-
439-
// Append thinking phrase if not auto mode
440-
const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode);
441-
if (thinkingMode && thinkingMode.phrase) {
442-
finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`;
443-
}
444-
445-
onSend(finalPrompt, selectedModel);
446-
setPrompt("");
447-
setEmbeddedImages([]);
448-
setTextareaHeight(48); // Reset height after sending
449-
}
450-
};
451-
452436
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
453437
const newValue = e.target.value;
454438
const newCursorPosition = e.target.selectionStart || 0;
@@ -657,6 +641,66 @@ const FloatingPromptInputInner = (
657641
}, 0);
658642
};
659643

644+
const handleCompositionStart = () => {
645+
isIMEComposingRef.current = true;
646+
};
647+
648+
const handleCompositionEnd = () => {
649+
setTimeout(() => {
650+
isIMEComposingRef.current = false;
651+
}, 0);
652+
};
653+
654+
const isIMEInteraction = (event?: React.KeyboardEvent) => {
655+
if (isIMEComposingRef.current) {
656+
return true;
657+
}
658+
659+
if (!event) {
660+
return false;
661+
}
662+
663+
const nativeEvent = event.nativeEvent;
664+
665+
if (nativeEvent.isComposing) {
666+
return true;
667+
}
668+
669+
const key = nativeEvent.key;
670+
if (key === 'Process' || key === 'Unidentified') {
671+
return true;
672+
}
673+
674+
const keyboardEvent = nativeEvent as unknown as KeyboardEvent;
675+
const keyCode = keyboardEvent.keyCode ?? (keyboardEvent as unknown as { which?: number }).which;
676+
if (keyCode === 229) {
677+
return true;
678+
}
679+
680+
return false;
681+
};
682+
683+
const handleSend = () => {
684+
if (isIMEInteraction()) {
685+
return;
686+
}
687+
688+
if (prompt.trim() && !disabled) {
689+
let finalPrompt = prompt.trim();
690+
691+
// Append thinking phrase if not auto mode
692+
const thinkingMode = THINKING_MODES.find(m => m.id === selectedThinkingMode);
693+
if (thinkingMode && thinkingMode.phrase) {
694+
finalPrompt = `${finalPrompt}.\n\n${thinkingMode.phrase}.`;
695+
}
696+
697+
onSend(finalPrompt, selectedModel);
698+
setPrompt("");
699+
setEmbeddedImages([]);
700+
setTextareaHeight(48); // Reset height after sending
701+
}
702+
};
703+
660704
const handleKeyDown = (e: React.KeyboardEvent) => {
661705
if (showFilePicker && e.key === 'Escape') {
662706
e.preventDefault();
@@ -679,7 +723,16 @@ const FloatingPromptInputInner = (
679723
return;
680724
}
681725

682-
if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker && !showSlashCommandPicker) {
726+
if (
727+
e.key === "Enter" &&
728+
!e.shiftKey &&
729+
!isExpanded &&
730+
!showFilePicker &&
731+
!showSlashCommandPicker
732+
) {
733+
if (isIMEInteraction(e)) {
734+
return;
735+
}
683736
e.preventDefault();
684737
handleSend();
685738
}
@@ -834,6 +887,8 @@ const FloatingPromptInputInner = (
834887
ref={expandedTextareaRef}
835888
value={prompt}
836889
onChange={handleTextChange}
890+
onCompositionStart={handleCompositionStart}
891+
onCompositionEnd={handleCompositionEnd}
837892
onPaste={handlePaste}
838893
placeholder="Type your message..."
839894
className="min-h-[200px] resize-none"
@@ -1157,15 +1212,21 @@ const FloatingPromptInputInner = (
11571212
value={prompt}
11581213
onChange={handleTextChange}
11591214
onKeyDown={handleKeyDown}
1215+
onCompositionStart={handleCompositionStart}
1216+
onCompositionEnd={handleCompositionEnd}
11601217
onPaste={handlePaste}
1161-
placeholder={dragActive ? "Drop images here..." : "Message Claude (@ for files, / for commands)..."}
1218+
placeholder={
1219+
dragActive
1220+
? "Drop images here..."
1221+
: "Message Claude (@ for files, / for commands)..."
1222+
}
11621223
disabled={disabled}
11631224
className={cn(
11641225
"resize-none pr-20 pl-3 py-2.5 transition-all duration-150",
11651226
dragActive && "border-primary",
11661227
textareaHeight >= 240 && "overflow-y-auto scrollbar-thin"
11671228
)}
1168-
style={{
1229+
style={{
11691230
height: `${textareaHeight}px`,
11701231
overflowY: textareaHeight >= 240 ? 'auto' : 'hidden'
11711232
}}

src/components/TimelineNavigator.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,13 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
6767
const [error, setError] = useState<string | null>(null);
6868
const [diff, setDiff] = useState<CheckpointDiff | null>(null);
6969
const [compareCheckpoint, setCompareCheckpoint] = useState<Checkpoint | null>(null);
70-
70+
7171
// Analytics tracking
7272
const trackEvent = useTrackEvent();
7373

74+
// IME composition state
75+
const isIMEComposingRef = React.useRef(false);
76+
7477
// Load timeline on mount and whenever refreshVersion bumps
7578
useEffect(() => {
7679
loadTimeline();
@@ -193,6 +196,16 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
193196
onFork(checkpoint.id);
194197
};
195198

199+
const handleCompositionStart = () => {
200+
isIMEComposingRef.current = true;
201+
};
202+
203+
const handleCompositionEnd = () => {
204+
setTimeout(() => {
205+
isIMEComposingRef.current = false;
206+
}, 0);
207+
};
208+
196209
const handleCompare = async (checkpoint: Checkpoint) => {
197210
if (!selectedCheckpoint) {
198211
setSelectedCheckpoint(checkpoint);
@@ -481,11 +494,16 @@ export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
481494
placeholder="e.g., Before major refactoring"
482495
value={checkpointDescription}
483496
onChange={(e) => setCheckpointDescription(e.target.value)}
484-
onKeyPress={(e) => {
497+
onKeyDown={(e) => {
485498
if (e.key === "Enter" && !isLoading) {
499+
if (e.nativeEvent.isComposing || isIMEComposingRef.current) {
500+
return;
501+
}
486502
handleCreateCheckpoint();
487503
}
488504
}}
505+
onCompositionStart={handleCompositionStart}
506+
onCompositionEnd={handleCompositionEnd}
489507
/>
490508
</div>
491509
</div>

0 commit comments

Comments
 (0)