From cddc15c3a89e96d86a41506fc58773f81fb789d6 Mon Sep 17 00:00:00 2001 From: powoct Date: Fri, 24 Jul 2026 20:18:10 +0900 Subject: [PATCH 1/7] fix(app): handle Safari IME composition in prompt input Co-authored-by: Cursor --- packages/app/src/components/prompt-input.tsx | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3842b087914e..fc0283e147f8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -540,7 +540,12 @@ export const PromptInput: Component = (props) => { }) const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + let lastCompositionEnd = 0 + // Safari fires compositionend BEFORE the confirming Enter keydown, so that + // event reports isComposing=false and keyCode=13. Treat any key event within + // 100ms of compositionend as part of the IME confirmation. + const isImeComposing = (event: KeyboardEvent) => + event.isComposing || composing() || event.keyCode === 229 || event.timeStamp - lastCompositionEnd < 100 const handleBlur = () => { const cursor = currentCursor() @@ -554,8 +559,11 @@ export const PromptInput: Component = (props) => { setComposing(true) } - const handleCompositionEnd = () => { + const handleCompositionEnd = (event: CompositionEvent) => { setComposing(false) + lastCompositionEnd = event.timeStamp + // Input events are ignored while composing, so sync DOM -> state once now. + handleInput() requestAnimationFrame(() => { if (composing()) return reconcile(prompt.current().filter((part) => part.type !== "image")) @@ -865,7 +873,10 @@ export const PromptInput: Component = (props) => { on( () => prompt.current(), (parts) => { - if (composing()) return + // The time window also covers the gap between compositionend and the + // next compositionstart when Japanese IMEs confirm segment by segment; + // a DOM rewrite landing there aborts the rest of the composition. + if (composing() || performance.now() - lastCompositionEnd < 100) return reconcile(parts.filter((part) => part.type !== "image")) }, ), @@ -970,7 +981,12 @@ export const PromptInput: Component = (props) => { return parts } - const handleInput = () => { + const handleInput = (event?: InputEvent) => { + // During IME composition, never sync DOM -> state -> DOM: any programmatic + // DOM/selection mutation makes Safari abort the composition mid-way. Check + // the event too because Safari fires the first input event BEFORE + // compositionstart, when composing() is still false. + if (composing() || event?.isComposing || event?.inputType === "insertCompositionText") return const rawParts = parseFromDOM() const images = imageAttachments() const cursorPosition = getCursorPosition(editorRef) From 0d84322220571c83ee451879892d8013d2d0197f Mon Sep 17 00:00:00 2001 From: powoct Date: Fri, 24 Jul 2026 20:18:10 +0900 Subject: [PATCH 2/7] fix(session-ui): guard v2 prompt Enter against Safari IME confirm Co-authored-by: Cursor --- .../src/v2/components/prompt-input/index.tsx | 4 +++- .../v2/components/prompt-input/interaction.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 9b011f03e9ef..c1aa7a836461 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -167,12 +167,14 @@ export function PromptInputV2(props: PromptInputV2Props) { }} onKeyDown={(event) => { if (props.controller.onKeyDown(event)) return - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + if (event.key === "Enter" && !event.shiftKey && !props.controller.imeComposing(event)) { event.preventDefault() if (event.repeat) return props.controller.submit() } }} + onCompositionStart={props.controller.onCompositionStart} + onCompositionEnd={props.controller.onCompositionEnd} onKeyUp={updateCursor} onPointerUp={updateCursor} onPaste={props.controller.onPaste} diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index f4a9fa74b7f2..9744a1f93515 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -185,6 +185,14 @@ export function createPromptInputV2Controller(input: { return result.handled } + let composing = false + let lastCompositionEnd = 0 + // Safari fires compositionend BEFORE the confirming Enter keydown, so that + // event reports isComposing=false and keyCode=13. Treat any key event within + // 100ms of compositionend as part of the IME confirmation. + const imeComposing = (event: KeyboardEvent) => + event.isComposing || composing || event.keyCode === 229 || event.timeStamp - lastCompositionEnd < 100 + const onKeyDown = (event: KeyboardEvent) => { if ( state.mode === "normal" && @@ -201,7 +209,7 @@ export function createPromptInputV2Controller(input: { type: "key.down", key: event.key, ctrl: event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey, - composing: event.isComposing, + composing: imeComposing(event), ids: suggestions().map((item) => item.id), empty: draft.state.prompt.every((part) => !("content" in part) || part.content.length === 0), }) @@ -295,6 +303,14 @@ export function createPromptInputV2Controller(input: { suggestions, dispatch, onKeyDown, + imeComposing, + onCompositionStart() { + composing = true + }, + onCompositionEnd(event: CompositionEvent) { + composing = false + lastCompositionEnd = event.timeStamp + }, value() { return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") }, From a7422a1a66a99160033cf798ac847f6003435bd1 Mon Sep 17 00:00:00 2001 From: powoct Date: Fri, 24 Jul 2026 22:16:55 +0900 Subject: [PATCH 3/7] fix(app): track IME composition end with performance.now Co-authored-by: Cursor --- packages/app/src/components/prompt-input.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index fc0283e147f8..6a0c1a4f0250 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -542,10 +542,12 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) let lastCompositionEnd = 0 // Safari fires compositionend BEFORE the confirming Enter keydown, so that - // event reports isComposing=false and keyCode=13. Treat any key event within - // 100ms of compositionend as part of the IME confirmation. + // keydown reports isComposing=false (keyCode 229 on Safari 26, 13 in older + // reports). Treat any key event within 100ms of compositionend as part of + // the IME confirmation. Compare with performance.now() rather than + // event.timeStamp so the guard never depends on the event clock source. const isImeComposing = (event: KeyboardEvent) => - event.isComposing || composing() || event.keyCode === 229 || event.timeStamp - lastCompositionEnd < 100 + event.isComposing || composing() || event.keyCode === 229 || performance.now() - lastCompositionEnd < 100 const handleBlur = () => { const cursor = currentCursor() @@ -559,9 +561,9 @@ export const PromptInput: Component = (props) => { setComposing(true) } - const handleCompositionEnd = (event: CompositionEvent) => { + const handleCompositionEnd = () => { setComposing(false) - lastCompositionEnd = event.timeStamp + lastCompositionEnd = performance.now() // Input events are ignored while composing, so sync DOM -> state once now. handleInput() requestAnimationFrame(() => { From 40b1a7c68b557011f6607b6aed9d929df75d9aca Mon Sep 17 00:00:00 2001 From: powoct Date: Fri, 24 Jul 2026 22:16:56 +0900 Subject: [PATCH 4/7] fix(session-ui): track IME composition end with performance.now Co-authored-by: Cursor --- .../src/v2/components/prompt-input/interaction.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index 9744a1f93515..b242a6551b42 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -188,10 +188,12 @@ export function createPromptInputV2Controller(input: { let composing = false let lastCompositionEnd = 0 // Safari fires compositionend BEFORE the confirming Enter keydown, so that - // event reports isComposing=false and keyCode=13. Treat any key event within - // 100ms of compositionend as part of the IME confirmation. + // keydown reports isComposing=false (keyCode 229 on Safari 26, 13 in older + // reports). Treat any key event within 100ms of compositionend as part of + // the IME confirmation. Compare with performance.now() rather than + // event.timeStamp so the guard never depends on the event clock source. const imeComposing = (event: KeyboardEvent) => - event.isComposing || composing || event.keyCode === 229 || event.timeStamp - lastCompositionEnd < 100 + event.isComposing || composing || event.keyCode === 229 || performance.now() - lastCompositionEnd < 100 const onKeyDown = (event: KeyboardEvent) => { if ( @@ -307,9 +309,9 @@ export function createPromptInputV2Controller(input: { onCompositionStart() { composing = true }, - onCompositionEnd(event: CompositionEvent) { + onCompositionEnd() { composing = false - lastCompositionEnd = event.timeStamp + lastCompositionEnd = performance.now() }, value() { return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") From a55ce21becf164071f9cf08df815a575b07e7d23 Mon Sep 17 00:00:00 2001 From: powoct Date: Sat, 25 Jul 2026 02:17:11 +0900 Subject: [PATCH 5/7] fix(session-ui): keep v2 editor inert during IME composition Production Safari testing showed the earlier Enter guard was not enough: every composition keystroke still went through onKeyDown dispatch and the onKeyUp/onPointerUp cursor sync, which makes Safari abort the composition and split it into per-letter micro compositions. The confirming Enter then arrives as a plain keyCode 13 long after compositionend and no guard can recognize it, so the raw pinyin gets submitted. Make the editor do nothing while a composition is active (and during the 100ms window after compositionend): skip keydown dispatch, cursor sync, input handling, and editor DOM rewrites, then reconcile state once from the DOM on compositionend. This keeps the composition as a single segment so the confirming Enter reports keyCode 229 and is caught by the guard. Co-authored-by: Cursor --- .../src/v2/components/prompt-input/index.tsx | 40 ++++++++++++++----- .../v2/components/prompt-input/interaction.ts | 7 ++++ .../components/prompt-input/machine.test.ts | 31 ++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index c1aa7a836461..f476df161cda 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -52,6 +52,13 @@ export function PromptInputV2(props: PromptInputV2Props) { if (!editor || !window.getSelection()?.isCollapsed) return props.controller.onCursor(promptInputV2Cursor(editor)) } + const syncEditor = (target: HTMLDivElement) => { + const cursor = promptInputV2Cursor(target) + const prompt = parsePromptInputV2Editor(target) + const images = props.controller.parts().filter((part) => part.type === "image") + localInput = true + props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + } const mode = createMemo(() => state.mode) const buttons = createMemo(() => ({ opacity: mode() === "normal" ? 1 : 0, @@ -66,6 +73,9 @@ export function PromptInputV2(props: PromptInputV2Props) { localInput = false return } + // Rewriting the editor DOM while an IME composition is active makes Safari + // cancel it mid-flight; the compositionend write-back reconciles state. + if (props.controller.imeActive()) return renderPromptInputV2Editor(editor, parts) }) @@ -159,24 +169,36 @@ export function PromptInputV2(props: PromptInputV2Props) { class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} onInput={(event) => { - const cursor = promptInputV2Cursor(event.currentTarget) - const prompt = parsePromptInputV2Editor(event.currentTarget) - const images = props.controller.parts().filter((part) => part.type === "image") - localInput = true - props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) + // Safari fires input (insertCompositionText) before compositionstart + // and on every composition keystroke; reacting here re-renders + // mid-composition and splits it into per-letter micro compositions. + // State is reconciled once by the compositionend write-back. + if (event.isComposing || props.controller.imeActive()) return + syncEditor(event.currentTarget) }} onKeyDown={(event) => { + // While composing (and shortly after compositionend) stay inert: + // dispatching here re-renders and makes Safari split the + // composition, and the confirming Enter must never submit. + if (props.controller.imeComposing(event)) return if (props.controller.onKeyDown(event)) return - if (event.key === "Enter" && !event.shiftKey && !props.controller.imeComposing(event)) { + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() if (event.repeat) return props.controller.submit() } }} onCompositionStart={props.controller.onCompositionStart} - onCompositionEnd={props.controller.onCompositionEnd} - onKeyUp={updateCursor} - onPointerUp={updateCursor} + onCompositionEnd={(event) => { + props.controller.onCompositionEnd() + syncEditor(event.currentTarget) + }} + onKeyUp={() => { + if (!props.controller.imeActive()) updateCursor() + }} + onPointerUp={() => { + if (!props.controller.imeActive()) updateCursor() + }} onPaste={props.controller.onPaste} onFocus={() => props.controller.dispatch({ type: "focus.editor" })} /> diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts index b242a6551b42..9a85a340ae8c 100644 --- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts +++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts @@ -194,6 +194,12 @@ export function createPromptInputV2Controller(input: { // event.timeStamp so the guard never depends on the event clock source. const imeComposing = (event: KeyboardEvent) => event.isComposing || composing || event.keyCode === 229 || performance.now() - lastCompositionEnd < 100 + // True while a composition is in flight or just ended. The editor must stay + // completely inert in this window: dispatching state updates or touching the + // selection makes Safari abort the composition and split it into per-letter + // micro compositions, after which the confirming Enter arrives as a plain + // keyCode 13 that no guard can recognize. + const imeActive = () => composing || performance.now() - lastCompositionEnd < 100 const onKeyDown = (event: KeyboardEvent) => { if ( @@ -306,6 +312,7 @@ export function createPromptInputV2Controller(input: { dispatch, onKeyDown, imeComposing, + imeActive, onCompositionStart() { composing = true }, diff --git a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts index e36982666e4d..f2b9065a0c69 100644 --- a/packages/session-ui/src/v2/components/prompt-input/machine.test.ts +++ b/packages/session-ui/src/v2/components/prompt-input/machine.test.ts @@ -147,6 +147,37 @@ describe("prompt input v2 interaction machine", () => { expect(selected.commands).toContainEqual({ type: "mention.add", item }) }) + test("ignores Enter while IME composition is active", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "first" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Enter", ctrl: false, composing: true, ids: ["first"] }, + persisted(), + ) + + expect(result.handled).toBeFalse() + expect(result.commands).toEqual([]) + expect(result.state.popover).toEqual({ type: "context", query: "", activeID: "first" }) + }) + + test("selects the active suggestion with Enter after composition ends", () => { + const state = { + ...createPromptInputV2InteractionState(), + popover: { type: "context" as const, query: "", activeID: "first" }, + } + const result = transitionPromptInputV2( + state, + { type: "key.down", key: "Enter", ctrl: false, composing: false, ids: ["first"] }, + persisted(), + ) + + expect(result.handled).toBeTrue() + expect(result.commands).toContainEqual({ type: "suggestion.select", id: "first" }) + }) + test("loops active popover items with arrow keys", () => { const state = { ...createPromptInputV2InteractionState(), From befc7a394d8d3cd37d34dc4cae11a69961b44d00 Mon Sep 17 00:00:00 2001 From: powoct Date: Sun, 26 Jul 2026 00:07:21 +0900 Subject: [PATCH 6/7] fix(app): hide prompt placeholder during IME composition Input events are ignored while a composition is in flight, so the prompt state stays empty and the placeholder kept rendering underneath the pre-edit text. Track the editor's own text content across the composition events and hide the placeholder while it is non-empty. Co-Authored-By: Claude Opus 5 --- packages/app/src/components/prompt-input.tsx | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6a0c1a4f0250..fbd30de8e19e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -540,6 +540,12 @@ export const PromptInput: Component = (props) => { }) const [composing, setComposing] = createSignal(false) + // The prompt state stays empty while a composition is in flight (input events + // are ignored until compositionend), so the placeholder has to watch the + // composing text in the editor itself or it renders under it. + const [composingText, setComposingText] = createSignal(false) + const syncComposingText = () => setComposingText(!!editorRef?.textContent?.replace(/[\n\u200B]/g, "")) + let lastCompositionEnd = 0 // Safari fires compositionend BEFORE the confirming Enter keydown, so that // keydown reports isComposing=false (keyCode 229 on Safari 26, 13 in older @@ -555,14 +561,21 @@ export const PromptInput: Component = (props) => { if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) + syncComposingText() } const handleCompositionStart = () => { setComposing(true) + syncComposingText() + } + + const handleCompositionUpdate = () => { + syncComposingText() } const handleCompositionEnd = () => { setComposing(false) + setComposingText(false) lastCompositionEnd = performance.now() // Input events are ignored while composing, so sync DOM -> state once now. handleInput() @@ -988,7 +1001,10 @@ export const PromptInput: Component = (props) => { // DOM/selection mutation makes Safari abort the composition mid-way. Check // the event too because Safari fires the first input event BEFORE // compositionstart, when composing() is still false. - if (composing() || event?.isComposing || event?.inputType === "insertCompositionText") return + if (composing() || event?.isComposing || event?.inputType === "insertCompositionText") { + syncComposingText() + return + } const rawParts = parseFromDOM() const images = imageAttachments() const cursorPosition = getCursorPosition(editorRef) @@ -1545,6 +1561,7 @@ export const PromptInput: Component = (props) => { onInput={handleInput} onPaste={handlePaste} onCompositionStart={handleCompositionStart} + onCompositionUpdate={handleCompositionUpdate} onCompositionEnd={handleCompositionEnd} onFocus={handleFocus} onBlur={handleBlur} @@ -1561,7 +1578,7 @@ export const PromptInput: Component = (props) => {
{placeholder()}
From 8b59620959271f15c1f7daecde095274793b08ee Mon Sep 17 00:00:00 2001 From: powoct Date: Sun, 26 Jul 2026 00:07:29 +0900 Subject: [PATCH 7/7] fix(session-ui): hide v2 prompt placeholder during IME composition The v2 editor stays inert while a composition is active, so the prompt state stays empty and the placeholder kept rendering underneath the pre-edit text. Track the editor's own text content across the composition events and hide the placeholder while it is non-empty; only textContent is read, so the editor DOM and selection stay untouched. Co-Authored-By: Claude Opus 5 --- .../src/v2/components/prompt-input/index.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index f476df161cda..9cf299871a8f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -59,6 +59,12 @@ export function PromptInputV2(props: PromptInputV2Props) { localInput = true props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) } + // The prompt state stays empty while an IME composition is in flight (input + // events are ignored until compositionend), so the placeholder has to watch + // the composing text in the editor itself or it renders under it. + const [composingText, setComposingText] = createSignal(false) + const syncComposingText = (target: HTMLDivElement) => + setComposingText(!!target.textContent?.replace(/[\n\u200B]/g, "")) const mode = createMemo(() => state.mode) const buttons = createMemo(() => ({ opacity: mode() === "normal" ? 1 : 0, @@ -173,7 +179,10 @@ export function PromptInputV2(props: PromptInputV2Props) { // and on every composition keystroke; reacting here re-renders // mid-composition and splits it into per-letter micro compositions. // State is reconciled once by the compositionend write-back. - if (event.isComposing || props.controller.imeActive()) return + if (event.isComposing || props.controller.imeActive()) { + syncComposingText(event.currentTarget) + return + } syncEditor(event.currentTarget) }} onKeyDown={(event) => { @@ -188,9 +197,14 @@ export function PromptInputV2(props: PromptInputV2Props) { props.controller.submit() } }} - onCompositionStart={props.controller.onCompositionStart} + onCompositionStart={(event) => { + props.controller.onCompositionStart() + syncComposingText(event.currentTarget) + }} + onCompositionUpdate={(event) => syncComposingText(event.currentTarget)} onCompositionEnd={(event) => { props.controller.onCompositionEnd() + setComposingText(false) syncEditor(event.currentTarget) }} onKeyUp={() => { @@ -202,7 +216,7 @@ export function PromptInputV2(props: PromptInputV2Props) { onPaste={props.controller.onPaste} onFocus={() => props.controller.dispatch({ type: "focus.editor" })} /> - +