From 65ddc7c4a1dc32a1ad5e9e732618c5a0f3ef3bf4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 22:41:34 -0500 Subject: [PATCH] fix(desktop): retain composer attachments per session scope + guard programmatic drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/composer/index.tsx | 28 +++++++++++- apps/desktop/src/store/composer.test.ts | 48 ++++++++++++++++++++ apps/desktop/src/store/composer.ts | 32 +++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5530ffb60ae..169c22236b8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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) diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 7bb44c74bd0..05ecef22558 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -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([]) + }) +}) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 5af98a49e3b..ba78ac43e22 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -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() + +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) }