From 1e31c9e350074dd947b0b4e25342cdfd115d140f Mon Sep 17 00:00:00 2001 From: JK Date: Tue, 10 Feb 2026 14:20:05 +0800 Subject: [PATCH] fix(app): implement input debounce and echo cancellation to reduce lag (#509) This commit addresses severe input lag in the Composer component when chat history is long. Changes: - Added 50ms debounce to to reduce frequency of Reflows and parent state updates. - Implemented robust Echo Cancellation using a Set to ignore stale prop updates from the parent, preventing cursor jumps and race conditions. Co-authored-by: shijc --- .../src/app/components/session/composer.tsx | 185 ++++++++++++------ 1 file changed, 120 insertions(+), 65 deletions(-) diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index 086616f60..323d6e32e 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -501,6 +501,73 @@ export default function Composer(props: ComposerProps) { setMentionIndex(0); }); + // Track recent emits to distinguish echoes from external updates + const recentEmits = new Set(); + recentEmits.add(props.prompt); // Initialize with current prop + + // Sync from props: ignore echoes of what we just sent + createEffect(() => { + if (!editorRef) return; + const value = props.prompt; + const current = normalizeText(editorRef.innerText); + + // Robust Echo Cancellation: + // If the incoming value matches ANY recently emitted text, it's a stale echo or confirmation. + // We ignore it to prevent overwriting the user's newer local state. + if (recentEmits.has(value)) { + // If we've converged (parent matches local), we can clean up the set to save memory, + // but keeping a few items is cheap and safer for race conditions. + if (value === current) { + recentEmits.clear(); + recentEmits.add(value); + } + return; + } + + // If we get here, 'value' is something we didn't send recently. + // It must be an external event (History Navigation, Clear, Agent Action, etc). + + if (suppressPromptSync) { + if (!value && current) { + setEditorText(""); + setAttachments([]); + setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); + setHistorySnapshot(null); + queueMicrotask(() => focusEditorEnd()); + } + return; + } + if (value === current) { + // Even if it matches current, make sure it's tracked as a valid base state + recentEmits.add(value); + return; + } + + // External update confirmed + if (value.startsWith("!") && mode() === "prompt") { + setMode("shell"); + setEditorText(value.slice(1).trimStart()); + recentEmits.add(value); + emitDraftChange(); + queueMicrotask(() => focusEditorEnd()); + return; + } + + recentEmits.add(value); // It's now the new baseline + setEditorText(value); + if (!value) { + setAttachments([]); + setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); + setHistorySnapshot(null); + } + + // We don't emitDraftChange here usually, to avoid loops, but if we changed text we might need to? + // Actually original code did emitDraftChange(). Let's keep it but be careful. + // If we emit, we add to Set again. + emitDraftChange(); + queueMicrotask(() => focusEditorEnd()); + }); + const syncHeight = () => { if (!editorRef) return; editorRef.style.height = "auto"; @@ -511,10 +578,34 @@ export default function Composer(props: ComposerProps) { editorRef.style.overflowY = editorRef.scrollHeight > 160 ? "auto" : "hidden"; }; + let emitTimer: number | null = null; const emitDraftChange = () => { + if (!editorRef) return; + syncHeight(); + + if (emitTimer) window.clearTimeout(emitTimer); + emitTimer = window.setTimeout(() => { + flushDraftChange(); + }, 50); + }; + + const flushDraftChange = () => { + if (emitTimer) { + window.clearTimeout(emitTimer); + emitTimer = null; + } if (!editorRef) return; const parts = buildPartsFromEditor(editorRef, pasteTextById); const text = normalizeText(partsToText(parts)); + + recentEmits.add(text); // Track that we sent this, expect an echo later + + // Limit Set size to prevent memory leak (though unlikely to grow huge) + if (recentEmits.size > 20) { + const it = recentEmits.values(); + recentEmits.delete(it.next().value); + } + const resolvedText = normalizeText(partsToResolvedText(parts)); suppressPromptSync = true; props.onDraftChange({ @@ -527,7 +618,6 @@ export default function Composer(props: ComposerProps) { queueMicrotask(() => { suppressPromptSync = false; }); - syncHeight(); }; const focusEditorEnd = () => { @@ -764,6 +854,9 @@ export default function Composer(props: ComposerProps) { }; const sendDraft = () => { + // Ensure any pending debounce updates are committed before sending + flushDraftChange(); + if (!editorRef) return; const parts = buildPartsFromEditor(editorRef, pasteTextById); const text = normalizeText(partsToText(parts)); @@ -1191,37 +1284,7 @@ export default function Composer(props: ComposerProps) { setSlashQuery(""); }); - createEffect(() => { - if (!editorRef) return; - const value = props.prompt; - const current = normalizeText(editorRef.innerText); - if (suppressPromptSync) { - if (!value && current) { - setEditorText(""); - setAttachments([]); - setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); - setHistorySnapshot(null); - queueMicrotask(() => focusEditorEnd()); - } - return; - } - if (value === current) return; - if (value.startsWith("!") && mode() === "prompt") { - setMode("shell"); - setEditorText(value.slice(1).trimStart()); - emitDraftChange(); - queueMicrotask(() => focusEditorEnd()); - return; - } - setEditorText(value); - if (!value) { - setAttachments([]); - setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 })); - setHistorySnapshot(null); - } - emitDraftChange(); - queueMicrotask(() => focusEditorEnd()); - }); + createEffect(() => { if (!variantMenuOpen()) return; @@ -1246,9 +1309,8 @@ export default function Composer(props: ComposerProps) {
{ if (attachmentsDisabled()) return; @@ -1270,9 +1332,8 @@ export default function Composer(props: ComposerProps) { return (
-
+
@@ -1493,9 +1553,8 @@ export default function Composer(props: ComposerProps) { />