From 0924e33369074fb2eab2fb28d0bb3db28d7f2800 Mon Sep 17 00:00:00 2001 From: Denis H <79399355+dplush@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:29:20 +0200 Subject: [PATCH] fix(dashboard): defer IME fallback until xterm input settles --- web/src/lib/pty-composition.test.ts | 24 +++++++++++++++--- web/src/lib/pty-composition.ts | 38 +++++++++++++++++++---------- web/src/pages/ChatPage.tsx | 21 +++++++++------- 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/web/src/lib/pty-composition.test.ts b/web/src/lib/pty-composition.test.ts index f9c68698a5..773c8be9d8 100644 --- a/web/src/lib/pty-composition.test.ts +++ b/web/src/lib/pty-composition.test.ts @@ -1,24 +1,40 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createPtyCompositionForwarder } from "./pty-composition"; describe("createPtyCompositionForwarder", () => { - it("forwards committed dead-key text and suppresses xterm's duplicate onData", () => { + afterEach(() => vi.useRealTimers()); + + it("forwards committed dead-key text when xterm emits no onData", () => { + vi.useFakeTimers(); const send = vi.fn(); const forwarder = createPtyCompositionForwarder(send); forwarder.onCompositionEnd("ä"); + vi.runAllTimers(); expect(send).toHaveBeenCalledExactlyOnceWith("ä"); - expect(forwarder.shouldForwardTerminalData("ä")).toBe(false); - expect(forwarder.shouldForwardTerminalData("x")).toBe(true); + }); + + it("leaves xterm's committed input alone when it arrives before the fallback", () => { + vi.useFakeTimers(); + const send = vi.fn(); + const forwarder = createPtyCompositionForwarder(send); + + forwarder.onCompositionEnd("ä"); + forwarder.noteTerminalData("äx"); + vi.runAllTimers(); + + expect(send).not.toHaveBeenCalled(); }); it("does not send an empty cancelled composition", () => { + vi.useFakeTimers(); const send = vi.fn(); const forwarder = createPtyCompositionForwarder(send); forwarder.onCompositionEnd(""); + vi.runAllTimers(); expect(send).not.toHaveBeenCalled(); }); diff --git a/web/src/lib/pty-composition.ts b/web/src/lib/pty-composition.ts index b44758a797..329cb4c173 100644 --- a/web/src/lib/pty-composition.ts +++ b/web/src/lib/pty-composition.ts @@ -1,25 +1,37 @@ /** - * Sends committed IME/dead-key text when xterm does not emit onData. + * Delays an IME/dead-key commit just long enough for xterm to emit onData. * - * Some browser/layout combinations leave xterm's CompositionHelper without an - * onData callback. The DOM compositionend event is the authoritative commit. - * If xterm does emit the same bytes afterwards, consume that one duplicate. + * xterm is authoritative when it emits the commit. Browsers/layouts where it + * does not emit onData still forward the compositionend text on the next turn. */ export function createPtyCompositionForwarder(send: (data: string) => void) { - let pendingDuplicate: string | null = null; + let pending: string | null = null; + let timer: ReturnType | null = null; + + const clearPending = () => { + pending = null; + if (timer) { + clearTimeout(timer); + timer = null; + } + }; return { - onCompositionEnd(data: string) { + onCompositionEnd(data: string | null) { if (!data) return; - pendingDuplicate = data; - send(data); + clearPending(); + pending = data; + timer = setTimeout(() => { + const committed = pending; + clearPending(); + if (committed) send(committed); + }, 0); }, - shouldForwardTerminalData(data: string) { - if (data === pendingDuplicate) { - pendingDuplicate = null; - return false; + noteTerminalData(data: string) { + if (pending && data.startsWith(pending)) { + clearPending(); } - return true; }, + dispose: clearPending, }; } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 1078b5feca..b53f4086d6 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -36,8 +36,8 @@ import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; import { latchChatActivation } from "@/lib/chat-activation"; -import { createPtyCompositionForwarder } from "@/lib/pty-composition"; import { normalizeSessionTitle } from "@/lib/chat-title"; +import { createPtyCompositionForwarder } from "@/lib/pty-composition"; import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer"; import { PTY_CONNECTING_TIMEOUT_MS, @@ -801,9 +801,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS; } }; - const markCompositionEnd = (ev: Event) => { + const markCompositionEnd = (ev: CompositionEvent) => { mobileReplacementInputUntilRef.current = Date.now() + MOBILE_REPLACEMENT_WINDOW_MS; - compositionForwarder.onCompositionEnd((ev as CompositionEvent).data); + compositionForwarder.onCompositionEnd(ev.data); }; textarea.addEventListener("beforeinput", markReplacementInput, true); @@ -1278,7 +1278,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // behave normally. // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; - const forwardPtyData = (data: string) => { + const forwardPtyData = (data: string, useMobileReplacement = true) => { // Mouse reports (scroll wheel etc.) are not typed input — swallow // them before the blocked-input check so scrolling a disconnected // terminal doesn't trip the "reconnecting" notice. @@ -1302,7 +1302,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const normalized = normalizePtyMobileInput( data, ptyInputLineRef.current, - Date.now() <= mobileReplacementInputUntilRef.current, + useMobileReplacement && Date.now() <= mobileReplacementInputUntilRef.current, ); ptyInputLineRef.current = normalized.nextLine; if (normalized.normalized) { @@ -1310,11 +1310,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } ws.send(normalized.data); }; - sendComposedText = forwardPtyData; + // The deferred composition fallback is already committed text, so it + // must not consume the mobile replacement window intended for xterm's + // normal onData path. + sendComposedText = (data) => forwardPtyData(data, false); onDataDisposable = term.onData((data) => { - if (compositionForwarder.shouldForwardTerminalData(data)) { - forwardPtyData(data); - } + compositionForwarder.noteTerminalData(data); + forwardPtyData(data); }); onResizeDisposable = term.onResize(({ cols, rows }) => { @@ -1344,6 +1346,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { onResizeDisposable?.dispose(); onScrollDisposable?.dispose(); mobileInputCleanup?.(); + compositionForwarder.dispose(); host.removeEventListener("paste", handleBrowserPaste, true); host.removeEventListener("dragover", handleBrowserDragOver, true); host.removeEventListener("drop", handleBrowserDrop, true);