diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f70cb3992896..2babc64ff9f6 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -540,7 +540,20 @@ export const PromptInput: Component = (props) => { }) const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + // 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 + // 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 || performance.now() - lastCompositionEnd < 100 const handleBlur = () => { const cursor = currentCursor() @@ -548,14 +561,24 @@ 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() requestAnimationFrame(() => { if (composing()) return reconcile(prompt.current().filter((part) => part.type !== "image")) @@ -865,7 +888,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 +996,15 @@ 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") { + syncComposingText() + return + } const rawParts = parseFromDOM() const images = imageAttachments() const cursorPosition = getCursorPosition(editorRef) @@ -1527,6 +1561,7 @@ export const PromptInput: Component = (props) => { onInput={handleInput} onPaste={handlePaste} onCompositionStart={handleCompositionStart} + onCompositionUpdate={handleCompositionUpdate} onCompositionEnd={handleCompositionEnd} onFocus={handleFocus} onBlur={handleBlur} @@ -1543,7 +1578,7 @@ export const PromptInput: Component = (props) => {
{placeholder()}
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..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" @@ -52,6 +52,19 @@ 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) + } + // 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, @@ -66,6 +79,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,26 +175,48 @@ 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()) { + syncComposingText(event.currentTarget) + 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 && !event.isComposing) { + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault() if (event.repeat) return props.controller.submit() } }} - onKeyUp={updateCursor} - onPointerUp={updateCursor} + onCompositionStart={(event) => { + props.controller.onCompositionStart() + syncComposingText(event.currentTarget) + }} + onCompositionUpdate={(event) => syncComposingText(event.currentTarget)} + onCompositionEnd={(event) => { + props.controller.onCompositionEnd() + setComposingText(false) + 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" })} /> - +
+ 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 ( state.mode === "normal" && @@ -201,7 +217,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 +311,15 @@ export function createPromptInputV2Controller(input: { suggestions, dispatch, onKeyDown, + imeComposing, + imeActive, + onCompositionStart() { + composing = true + }, + onCompositionEnd() { + composing = false + lastCompositionEnd = performance.now() + }, value() { return draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") }, 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(),