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 <shijincheng0223@yahoo.com>
This commit is contained in:
JK
2026-02-10 14:20:05 +08:00
committed by GitHub
parent d2f095fe6b
commit 1e31c9e350

View File

@@ -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<string>();
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) {
<div class="px-4 pb-4 pt-0 bg-dls-surface sticky bottom-0 z-20">
<div class="max-w-3xl mx-auto">
<div
class={`bg-dls-surface border border-dls-border rounded-2xl overflow-visible transition-all relative group/input ${
mentionOpen() || slashOpen() ? "rounded-t-none border-t-transparent shadow-none" : "shadow-xl"
}`}
class={`bg-dls-surface border border-dls-border rounded-2xl overflow-visible transition-all relative group/input ${mentionOpen() || slashOpen() ? "rounded-t-none border-t-transparent shadow-none" : "shadow-xl"
}`}
onDrop={handleDrop}
onDragOver={(event: DragEvent) => {
if (attachmentsDisabled()) return;
@@ -1270,9 +1332,8 @@ export default function Composer(props: ComposerProps) {
return (
<button
type="button"
class={`w-full flex items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors ${
active() ? "bg-dls-active text-dls-text" : "text-dls-text hover:bg-dls-hover"
}`}
class={`w-full flex items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors ${active() ? "bg-dls-active text-dls-text" : "text-dls-text hover:bg-dls-hover"
}`}
onMouseDown={(event: MouseEvent) => {
event.preventDefault();
insertMention(option);
@@ -1335,9 +1396,8 @@ export default function Composer(props: ComposerProps) {
return (
<button
type="button"
class={`w-full flex items-center justify-between gap-4 rounded-xl px-3 py-2 text-left transition-colors ${
active() ? "bg-dls-active text-dls-text" : "text-dls-text hover:bg-dls-hover"
}`}
class={`w-full flex items-center justify-between gap-4 rounded-xl px-3 py-2 text-left transition-colors ${active() ? "bg-dls-active text-dls-text" : "text-dls-text hover:bg-dls-hover"
}`}
onMouseDown={(event: MouseEvent) => {
event.preventDefault();
handleSlashSelect(cmd);
@@ -1415,7 +1475,7 @@ export default function Composer(props: ComposerProps) {
</div>
</Show>
<div class="relative min-h-[120px]">
<div class="relative min-h-[120px]">
<Show when={props.toast}>
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-dls-border bg-dls-surface px-3 py-2 text-xs text-dls-secondary shadow-lg backdrop-blur-md">
<div class="flex items-center gap-3">
@@ -1493,9 +1553,8 @@ export default function Composer(props: ComposerProps) {
/>
<button
type="button"
class={`p-1.5 hover:bg-dls-hover rounded-md text-dls-secondary transition-colors ${
attachmentsDisabled() ? "cursor-not-allowed" : ""
}`}
class={`p-1.5 hover:bg-dls-hover rounded-md text-dls-secondary transition-colors ${attachmentsDisabled() ? "cursor-not-allowed" : ""
}`}
onClick={() => {
if (attachmentsDisabled()) return;
fileInputRef?.click();
@@ -1540,11 +1599,10 @@ export default function Composer(props: ComposerProps) {
<Show when={!props.agentPickerError}>
<button
type="button"
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
!props.selectedAgent
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${!props.selectedAgent
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
onMouseDown={(event: MouseEvent) => {
event.preventDefault();
props.onSelectAgent(null);
@@ -1562,11 +1620,10 @@ export default function Composer(props: ComposerProps) {
return (
<button
type="button"
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
active()
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${active()
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
onMouseDown={(event: MouseEvent) => {
event.preventDefault();
props.onSelectAgent(agent.name);
@@ -1624,11 +1681,10 @@ export default function Composer(props: ComposerProps) {
{(option) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${
activeVariant() === option.value
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
class={`w-full flex items-center justify-between rounded-lg px-3 py-2 text-left text-xs transition-colors ${activeVariant() === option.value
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:bg-dls-hover"
}`}
onClick={() => {
props.onModelVariantChange(option.value);
setVariantMenuOpen(false);
@@ -1651,11 +1707,10 @@ export default function Composer(props: ComposerProps) {
type="button"
disabled={!props.prompt.trim() && !attachments().length}
onClick={sendDraft}
class={`p-1.5 rounded-full ${
!props.prompt.trim() && !attachments().length
? "bg-dls-active text-dls-secondary"
: "bg-dls-accent text-white"
}`}
class={`p-1.5 rounded-full ${!props.prompt.trim() && !attachments().length
? "bg-dls-active text-dls-secondary"
: "bg-dls-accent text-white"
}`}
title="Send"
>
<ArrowUp size={18} />