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
+48
View File
@@ -4,10 +4,13 @@ import {
$composerAttachments,
addComposerAttachment,
clearPersistedComposerDraft,
clearStashedComposerAttachments,
type ComposerAttachment,
composerDraftStorageKey,
readPersistedComposerDraft,
removeComposerAttachment,
stashComposerAttachments,
takeComposerAttachments,
updateComposerAttachment,
writePersistedComposerDraft
} from './composer'
@@ -81,3 +84,48 @@ describe('persisted composer drafts', () => {
expect(readPersistedComposerDraft('session-a')).toBe('')
})
})
describe('stashed composer attachments', () => {
afterEach(() => {
clearStashedComposerAttachments('session-a')
clearStashedComposerAttachments('session-b')
clearStashedComposerAttachments(null)
})
it('retains and restores attachments per session scope', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
stashComposerAttachments('session-b', [attachment({ id: 'image:b', kind: 'image' })])
expect(takeComposerAttachments('session-a').map(a => a.id)).toEqual(['file:a'])
expect(takeComposerAttachments('session-b').map(a => a.id)).toEqual(['image:b'])
})
it('shares a stable new-session scope with the text draft helpers', () => {
stashComposerAttachments(null, [attachment({ id: 'file:new' })])
expect(takeComposerAttachments(undefined).map(a => a.id)).toEqual(['file:new'])
})
it('returns cloned attachments so callers cannot mutate the stash', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a', label: 'orig.pdf' })])
const taken = takeComposerAttachments('session-a')
taken[0]!.label = 'mutated.pdf'
expect(takeComposerAttachments('session-a')[0]?.label).toBe('orig.pdf')
})
it('drops the scope entry when stashing an empty set', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
stashComposerAttachments('session-a', [])
expect(takeComposerAttachments('session-a')).toEqual([])
})
it('clears a scope explicitly after an accepted submit', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
clearStashedComposerAttachments('session-a')
expect(takeComposerAttachments('session-a')).toEqual([])
})
})
+32
View File
@@ -83,6 +83,38 @@ export function clearPersistedComposerDraft(scope: string | null | undefined) {
}
}
// Attachments can't ride along in localStorage the way text does — they carry
// live blobs, object URLs, and in-flight upload state that don't serialize and
// are tied to the running app. So we retain them per scope in an in-memory map
// instead: a session switch restores the chips you'd staged, even though they
// (unlike text) cannot survive a full app reload.
const composerAttachmentsByScope = new Map<string, ComposerAttachment[]>()
const cloneComposerAttachments = (attachments: ComposerAttachment[]): ComposerAttachment[] =>
attachments.map(attachment => ({ ...attachment }))
export function stashComposerAttachments(scope: string | null | undefined, attachments: ComposerAttachment[]) {
const key = storageScope(scope)
if (attachments.length === 0) {
composerAttachmentsByScope.delete(key)
return
}
composerAttachmentsByScope.set(key, cloneComposerAttachments(attachments))
}
export function takeComposerAttachments(scope: string | null | undefined): ComposerAttachment[] {
const stashed = composerAttachmentsByScope.get(storageScope(scope))
return stashed ? cloneComposerAttachments(stashed) : []
}
export function clearStashedComposerAttachments(scope: string | null | undefined) {
composerAttachmentsByScope.delete(storageScope(scope))
}
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}