From fdc0d1956636d0c90cb53192f8acebf2e5459ee1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 22:58:50 -0500 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20make=20draft=20persistence=20ac?= =?UTF-8?q?tually=20fire=20=E2=80=94=20new-chat=20sentinel,=20reload=20flu?= =?UTF-8?q?sh,=20session-switch=20clears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual testing of the salvaged draft persistence showed none of it worked end-to-end. Three distinct bugs, all invisible to the store-level unit tests: 1. New-chat drafts were never written. The skip-one-persist sentinel was reset to null after consuming, but null IS a real scope (the unsaved new-session draft) — so in a new chat every persist run matched the "consumed" sentinel and bailed. This silently killed the headline #38498 fix. Use undefined as the no-skip sentinel, which can never collide with a scope. 2. Cmd+R inside the debounce window dropped the trailing text. React does not run effect cleanups on a page reload, so the flush-on-unmount never fired; with the 400ms debounce that meant type-then-reload lost the draft every time. Flush pending writes on pagehide. 3. Session switch/new/resume/branch paths in use-session-actions cleared the composer stores synchronously with the session-id updates. React batches those, so by the time ChatBar's scope-change cleanup ran to stash the departing session's attachments, the store was already empty — the stash recorded [] and the chips were lost anyway. The composer's per-scope restore now owns composer contents wholesale on scope change, so drop the upstream clears (clearComposerDraft only touched the vestigial $composerDraft atom nothing reads). Co-authored-by: mollusk Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/composer/index.tsx | 31 +++++++++++++------ .../app/session/hooks/use-session-actions.ts | 19 +++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 169c22236b8..e55e5019205 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -186,7 +186,11 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) - const skipNextDraftPersistScopeRef = useRef(null) + // `undefined` = no skip pending. The sentinel must be distinguishable from a + // real scope, and `null` IS a real scope (the unsaved-new-session draft): + // resetting to null made every persist run in a new chat match the consumed + // sentinel and bail, so new-chat drafts were never written at all. + const skipNextDraftPersistScopeRef = useRef(undefined) const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1132,7 +1136,7 @@ export function ChatBar({ useEffect(() => { if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) { - skipNextDraftPersistScopeRef.current = null + skipNextDraftPersistScopeRef.current = undefined return } @@ -1161,19 +1165,28 @@ export function ChatBar({ return () => window.clearTimeout(handle) }, [draft, draftPersistenceScope, queueEdit, sessionId]) - // Flush any pending debounced draft write when leaving a session scope or - // unmounting, so the departing session's latest text is always persisted. - useEffect( - () => () => { + // Flush any pending debounced draft write when leaving a session scope, + // unmounting, or the window unloading, so the latest text is always + // persisted. The pagehide listener is load-bearing: React does NOT run + // effect cleanups on a page reload, so without it a Cmd+R inside the + // debounce window silently dropped everything typed in the last 400ms. + useEffect(() => { + const flushPendingDraftPersist = () => { const pending = pendingDraftPersistRef.current if (pending) { pendingDraftPersistRef.current = null writePersistedComposerDraft(pending.scope, pending.value) } - }, - [draftPersistenceScope] - ) + } + + window.addEventListener('pagehide', flushPendingDraftPersist) + + return () => { + window.removeEventListener('pagehide', flushPendingDraftPersist) + flushPendingDraftPersist() + } + }, [draftPersistenceScope]) const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 51ee90924ae..f049223b1f4 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' import { setSessionYolo } from '@/lib/yolo-session' -import { clearComposerAttachments, clearComposerDraft } from '@/store/composer' import { clearQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' @@ -19,7 +18,6 @@ import { $messages, $sessions, $yoloActive, - workspaceCwdForNewSession, sessionPinId, setActiveSessionId, setAwaitingResponse, @@ -41,7 +39,8 @@ import { setSessionStartedAt, setSessionsTotal, setTurnStartedAt, - setYoloActive + setYoloActive, + workspaceCwdForNewSession } from '@/store/session' import { reportBackendContract } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' @@ -329,8 +328,10 @@ export function useSessionActions({ setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') - clearComposerDraft() - clearComposerAttachments() + // Composer contents are owned by ChatBar's per-scope draft persistence: + // the scope change triggered by the session-id updates above stashes the + // departing session's attachments and restores this scope's draft. + // Clearing here would wipe the departing stash before it's saved. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] @@ -352,11 +353,13 @@ export function useSessionActions({ // Pass the owning profile so a new chat under a non-launch profile (global // remote mode) builds its agent + persists against THAT profile's home/db. const newChatProfile = $newChatProfile.get() + const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }), ...(newChatProfile ? { profile: newChatProfile } : {}) }) + const stored = created.stored_session_id ?? null if ( @@ -475,8 +478,6 @@ export function useSessionActions({ setCurrentCwd(cachedState.cwd) setCurrentBranch(cachedState.branch) setSessionStartedAt(Date.now()) - clearComposerDraft() - clearComposerAttachments() try { const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId }) @@ -606,8 +607,6 @@ export function useSessionActions({ }), storedSessionId ) - clearComposerDraft() - clearComposerAttachments() } catch (err) { if (!isCurrentResume()) { return @@ -730,8 +729,6 @@ export function useSessionActions({ selectedStoredSessionIdRef.current = routedSessionId navigate(sessionRoute(routedSessionId)) - clearComposerDraft() - clearComposerAttachments() const runtimeInfo = applyRuntimeInfo(branched.info) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)