fix(dashboard): defer IME fallback until xterm input settles

This commit is contained in:
Denis H
2026-08-01 17:29:20 +02:00
committed by Teknium
parent 6ece2da738
commit 0924e33369
3 changed files with 57 additions and 26 deletions
+20 -4
View File
@@ -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();
});
+25 -13
View File
@@ -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<typeof setTimeout> | 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,
};
}
+12 -9
View File
@@ -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);