From 9998ff4cbebc9812aec66a7d0839665fa7fbfa36 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 04:32:39 -0500 Subject: [PATCH] fix(desktop): persist live composer draft before swap/reload Sync the contentEditable text before stash-on-scope-change and pagehide so pending rAF draft flushes cannot drop the newest keystrokes. --- .../chat/composer/hooks/use-composer-draft.ts | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 4943838e790..058155add2d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -176,6 +176,26 @@ export function useComposerDraft({ } }, [setComposerText]) + // Read the editor's current plain text into draftRef + composer state. This + // closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide + // persistence captures the latest keystrokes. + const syncDraftFromEditor = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return draftRef.current + } + + const text = composerPlainText(editor) + + if (text !== draftRef.current) { + draftRef.current = text + setComposerText(text) + } + + return text + }, [setComposerText]) + // Imperative draft sync — the spine of the "work only when work is to be // performed" model. Subscribing to the composer runtime directly (not // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of @@ -267,28 +287,31 @@ export function useComposerDraft({ loadIntoComposer(text, attachments) return () => { + const latestText = syncDraftFromEditor() const editing = queueEditRef.current if (editing?.sessionKey === activeQueueSessionKey) { stashAt(activeQueueSessionKey, editing.draft, editing.attachments) } else if (!isBrowsingHistory(sessionId)) { - stashAt(activeQueueSessionKey) + stashAt(activeQueueSessionKey, latestText) } } }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R - // inside the debounce window would drop trailing keystrokes without this. + // inside the debounce/rAF window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { - const pending = pendingDraftPersistRef.current + const scope = activeQueueSessionKeyRef.current + const editing = queueEditRef.current - if (!pending) { + if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { return } + const latestText = syncDraftFromEditor() pendingDraftPersistRef.current = null - stashAt(pending.scope, pending.text) + stashAt(scope, latestText) } window.addEventListener('pagehide', flushPendingDraftPersist) @@ -297,7 +320,7 @@ export function useComposerDraft({ window.removeEventListener('pagehide', flushPendingDraftPersist) flushPendingDraftPersist() } - }, []) + }, [queueEditRef, syncDraftFromEditor]) return { activeQueueSessionKeyRef,