fix(desktop): retain composer attachments per session scope + guard programmatic drafts

The salvaged draft persistence scoped text per session but reset the
composer's attachments to [] on every scope change, so a staged image or
file was silently dropped when you switched sessions and never restored on
return — inconsistent with the "drafts survive session switches" promise
and a real paper-cut given remote staging cost.

Retain attachments per scope in an in-memory map (keyed by the same scope
as the text draft) since blobs / object URLs / live upload state can't be
serialized to localStorage. Entering a scope restores its stashed chips;
leaving stashes the current ones; an accepted submit clears the scope.
This survives session switches (the case users hit) without pretending to
survive a full reload, which attachments fundamentally can't.

Also guard the debounced text write so browsing sent-message history or
editing a queued prompt (both swap the composer to recalled text via
loadIntoComposer) no longer clobbers the genuine in-progress draft in
storage.

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:41:34 -05:00
co-authored by mollusk Teknium
parent 3d14f01fd6
commit 65ddc7c4a1
3 changed files with 106 additions and 2 deletions
+26 -2
View File
@@ -28,8 +28,11 @@ import {
$composerAttachments,
clearComposerAttachments,
clearPersistedComposerDraft,
clearStashedComposerAttachments,
type ComposerAttachment,
readPersistedComposerDraft,
stashComposerAttachments,
takeComposerAttachments,
writePersistedComposerDraft
} from '@/store/composer'
import {
@@ -1111,10 +1114,20 @@ export function ChatBar({
}
}
// Restore a scope's draft (persisted text + in-memory attachments) when we
// enter it, and stash the attachments back when we leave. Text rides through
// localStorage so it survives reloads; attachments carry live blobs/upload
// state that can't serialize, so they're retained in memory only — enough to
// survive a session switch, which is the case users actually hit.
useEffect(() => {
const persisted = readPersistedComposerDraft(draftPersistenceScope)
const restoredAttachments = takeComposerAttachments(draftPersistenceScope)
skipNextDraftPersistScopeRef.current = draftPersistenceScope
loadIntoComposer(persisted, [])
loadIntoComposer(persisted, restoredAttachments)
return () => {
stashComposerAttachments(draftPersistenceScope, $composerAttachments.get())
}
}, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@@ -1124,6 +1137,15 @@ export function ChatBar({
return
}
// Don't persist programmatically-loaded text: browsing sent-message
// history or editing a queued prompt swaps the composer to recalled text,
// and persisting that would clobber the genuine in-progress draft (which
// history keeps in its own snapshot and restores on the way back). Leaving
// the prior pending write untouched keeps the real draft in storage.
if (isBrowsingHistory(sessionId) || queueEdit) {
return
}
// Debounce the localStorage write: the composer's per-keystroke path was
// deliberately slimmed down (see the draftRef sync comment above), so we
// don't touch storage on every keypress. The pending ref below is flushed
@@ -1137,7 +1159,7 @@ export function ChatBar({
}, DRAFT_PERSIST_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [draft, draftPersistenceScope])
}, [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.
@@ -1412,6 +1434,7 @@ export function ChatBar({
writePersistedComposerDraft(draftPersistenceScope, submitted)
} else {
clearPersistedComposerDraft(draftPersistenceScope)
clearStashedComposerAttachments(draftPersistenceScope)
}
}).catch(() => {
loadIntoComposer(submitted, [])
@@ -1440,6 +1463,7 @@ export function ChatBar({
writePersistedComposerDraft(draftPersistenceScope, submitted)
} else {
clearPersistedComposerDraft(draftPersistenceScope)
clearStashedComposerAttachments(draftPersistenceScope)
}
}).catch(() => {
loadIntoComposer(submitted, submittedAttachments)