fix(dashboard): defer IME fallback until xterm input settles
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user