From 33d91029b26680a466cf057844df2fcb5fb87b15 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:55:37 -0500 Subject: [PATCH] perf+fix(desktop): coalesce composer paste/input flush; scope dock glow to thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two composer fixes: - **Paste/input lag** — `flushEditorToDraft` serializes the whole editor (`composerPlainText` is O(n)); running it on every event during a burst (holding a key, or holding Cmd+V into a growing editor) was O(n²). Coalesce the input/paste path to one flush per animation frame. Lossless: the contentEditable DOM is the source of truth and submit + the compositionend / keydown paths re-read it synchronously (those stay immediate). - **Detached-composer dock glow** — was `fixed inset-x-0` (full viewport, spilled under the sessions sidebar). Switched to `absolute inset-x-0`, so it anchors to the chat-column root the docked composer centers in — the glow now spans only the thread area, matching the actual dock target. Verified: typecheck clean, 0 lint errors, composer DOM repro tests pass. --- apps/desktop/src/app/chat/composer/index.tsx | 52 +++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c6464e890ed..7dfada7ec61 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -21,10 +21,7 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { - $composerAttachments, - clearComposerAttachments -} from '@/store/composer' +import { $composerAttachments, clearComposerAttachments } from '@/store/composer' import { browseBackward, browseForward, @@ -427,7 +424,20 @@ export function ChatBar({ // Pull the live contentEditable text into draftRef + the AUI composer state // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. + // A pending coalesced flush (rAF id). `composerPlainText` serializes the whole + // editor (O(n)), so running it on every event during a burst — holding a key, + // or holding Cmd+V into a growing editor — is O(n²) across the burst. The + // contentEditable DOM is the source of truth (submit + the compositionend / + // keydown paths re-read it synchronously), so collapsing the input/paste + // flushes to one per paint is lossless. + const flushRafRef = useRef(undefined) + const flushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + flushRafRef.current = undefined + } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -440,6 +450,29 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } + // Coalesce the high-frequency input/paste flushes to one per frame. Immediate + // paths (compositionend, Enter/keydown, submit) keep calling + // flushEditorToDraft directly, which cancels any pending coalesced run first. + const scheduleFlushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + return + } + + flushRafRef.current = window.requestAnimationFrame(() => { + flushRafRef.current = undefined + flushEditorToDraft(editor) + }) + } + + useEffect( + () => () => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + } + }, + [] + ) + const handleEditorInput = (event: FormEvent) => { // During IME composition the DOM contains uncommitted preedit text // mixed with real content. Skip state writes — compositionend flushes @@ -448,7 +481,7 @@ export function ChatBar({ return } - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const handlePaste = (event: ClipboardEvent) => { @@ -498,7 +531,7 @@ export function ChatBar({ event.preventDefault() insertPlainTextAtCaret(event.currentTarget, pastedText) - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const triggerAdapter: Unstable_TriggerAdapter | null = @@ -1217,7 +1250,12 @@ export function ChatBar({ {dragging && poppedOut && (