From fa145dbf4270d20ed5cfb576436f71054c6a1bdc Mon Sep 17 00:00:00 2001 From: Chihiro Yamaguchi Date: Wed, 22 Jul 2026 01:44:45 +0900 Subject: [PATCH] Fix duplicate chat submits from Enter races and IME composition. Guard submit with a synchronous localTurn ref and ignore Enter while an IME composition is active so one keypress cannot enqueue two agent turns. Co-authored-by: Cursor --- app/components/chat-composer.tsx | 9 +++++---- app/components/use-chat-controller.ts | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/components/chat-composer.tsx b/app/components/chat-composer.tsx index ea4237d..fd43196 100644 --- a/app/components/chat-composer.tsx +++ b/app/components/chat-composer.tsx @@ -227,10 +227,11 @@ export function ChatComposer({ value={input} onChange={(event) => onInputChange(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - submit(); - } + if (event.key !== "Enter" || event.shiftKey) return; + // Japanese/other IME: Enter confirms composition; don't send yet. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); + submit(); }} placeholder="Hey Aithy" className="max-h-60 min-h-11 overflow-y-auto rounded-none border-0 bg-transparent px-0 py-2.5 text-base leading-6 shadow-none placeholder:text-[rgb(var(--muted-foreground))]/75 focus:border-0" diff --git a/app/components/use-chat-controller.ts b/app/components/use-chat-controller.ts index 7561abe..976b7d0 100644 --- a/app/components/use-chat-controller.ts +++ b/app/components/use-chat-controller.ts @@ -148,6 +148,7 @@ export function useChatController({ setStreamingDraft(null); const turnToComplete = localTurnRef.current; if (assistantCompletesLocalTurn(turnToComplete, event.conversationId, message.createdAt)) { + localTurnRef.current = IDLE_LOCAL_CHAT_TURN; setLocalTurn(IDLE_LOCAL_CHAT_TURN); } } @@ -173,7 +174,10 @@ export function useChatController({ const useSelectedSkills = options.useSelectedSkills ?? true; const skillsForTurn = options.selectedSkillsOverride ?? (useSelectedSkills ? selectedSkills : []); - if (turn.busy && activeSessionId && !options.pendingMessage) { + // Use the ref as well as React state: two Enter/clicks can race before setState + // re-renders, which used to POST the same chat turn twice. + const syncBusy = turn.busy || localTurnRef.current.phase !== "idle"; + if (syncBusy && activeSessionId && !options.pendingMessage) { enqueuePending({ conversationId: activeSessionId, text, @@ -194,6 +198,7 @@ export function useChatController({ taskId: null, phase: "submitting", }; + localTurnRef.current = nextLocalTurn; setLocalTurn(nextLocalTurn); if (wasDraft) setActiveSessionId(conversationId); if (!isCommand) { @@ -211,9 +216,11 @@ export function useChatController({ }); const taskId = taskIdFromResult(result); const queued = Boolean(result.queued && !isCommand); - setLocalTurn(queued - ? { ...nextLocalTurn, taskId, phase: "awaiting_reply" } - : IDLE_LOCAL_CHAT_TURN); + const settledTurn = queued + ? { ...nextLocalTurn, taskId, phase: "awaiting_reply" as const } + : IDLE_LOCAL_CHAT_TURN; + localTurnRef.current = settledTurn; + setLocalTurn(settledTurn); if (!isCommand && useSelectedSkills && !options.pendingMessage) setSelectedSkills([]); if (result.activeSessionId !== conversationId) { setActiveSessionId(result.activeSessionId); @@ -222,6 +229,7 @@ export function useChatController({ await navigateToSession(conversationId); } } catch (error) { + localTurnRef.current = IDLE_LOCAL_CHAT_TURN; setLocalTurn(IDLE_LOCAL_CHAT_TURN); if (options.pendingMessage) restorePending(options.pendingMessage); throw error; @@ -255,6 +263,7 @@ export function useChatController({ }, [input, submitText]); const stop = useCallback(() => { + localTurnRef.current = IDLE_LOCAL_CHAT_TURN; setLocalTurn(IDLE_LOCAL_CHAT_TURN); setStreamingDraft(null); if (activeSessionId) void stopChatMessage({ data: { conversationId: activeSessionId } });