fix(desktop): make draft persistence actually fire — new-chat sentinel, reload flush, session-switch clears

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 <roger@roger.local>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-06-10 22:58:50 -05:00
parent 65ddc7c4a1
commit fdc0d19566
2 changed files with 30 additions and 20 deletions

View file

@ -186,7 +186,11 @@ export function ChatBar({
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const skipNextDraftPersistScopeRef = useRef<string | null>(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<string | null | undefined>(undefined)
const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null)
const drainingQueueRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(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) {

View file

@ -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<SessionCreateResponse>('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<UsageStats>('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)