From 18d61bd06e062049f524d6b1d3a51956db2cf1ea Mon Sep 17 00:00:00 2001 From: Roger Date: Wed, 10 Jun 2026 13:00:45 -0400 Subject: [PATCH] fix(desktop): persist composer drafts across reloads Save in-progress composer text to browser localStorage per chat session and restore it when the desktop composer remounts. Keep the draft when submit is rejected or throws, and clear it only after the prompt is accepted. --- apps/desktop/src/app/chat/composer/index.tsx | 54 ++++++++++++++++- apps/desktop/src/store/composer.test.ts | 42 ++++++++++++- apps/desktop/src/store/composer.ts | 62 ++++++++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index bf948834837..05fa4a451bc 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -24,7 +24,14 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { + $composerAttachments, + clearComposerAttachments, + clearPersistedComposerDraft, + type ComposerAttachment, + readPersistedComposerDraft, + writePersistedComposerDraft +} from '@/store/composer' import { browseBackward, browseForward, @@ -160,6 +167,7 @@ export function ChatBar({ const scrolledUp = useStore($threadScrolledUp) const sessionMessages = useStore($messages) const activeQueueSessionKey = queueSessionKey || sessionId || null + const draftPersistenceScope = activeQueueSessionKey || null const queuedPrompts = useMemo( () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), @@ -171,6 +179,7 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) + const skipNextDraftPersistScopeRef = useRef(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1097,6 +1106,22 @@ export function ChatBar({ } } + useEffect(() => { + const persisted = readPersistedComposerDraft(draftPersistenceScope) + skipNextDraftPersistScopeRef.current = draftPersistenceScope + loadIntoComposer(persisted, []) + }, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) { + skipNextDraftPersistScopeRef.current = null + + return + } + + writePersistedComposerDraft(draftPersistenceScope, draft) + }, [draft, draftPersistenceScope]) + const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return @@ -1323,8 +1348,10 @@ export function ChatBar({ // input event; refresh it from the editor once more to also cover an // in-flight keystroke that hasn't fired its input event yet. const editor = editorRef.current + if (editor) { const domText = composerPlainText(editor) + if (domText !== draftRef.current) { draftRef.current = domText aui.composer().setText(domText) @@ -1348,7 +1375,17 @@ export function ChatBar({ const submitted = text triggerHaptic('submit') clearDraft() - void onSubmit(submitted) + void Promise.resolve(onSubmit(submitted)).then(accepted => { + if (accepted === false) { + loadIntoComposer(submitted, []) + writePersistedComposerDraft(draftPersistenceScope, submitted) + } else { + clearPersistedComposerDraft(draftPersistenceScope) + } + }).catch(() => { + loadIntoComposer(submitted, []) + writePersistedComposerDraft(draftPersistenceScope, submitted) + }) } else if (payloadPresent) { queueCurrentDraft() } else { @@ -1361,11 +1398,22 @@ export function ChatBar({ void drainNextQueued() } else if (payloadPresent) { const submitted = text + const submittedAttachments = cloneAttachments(attachments) triggerHaptic('submit') resetBrowseState(sessionId) clearDraft() clearComposerAttachments() - void onSubmit(submitted, { attachments }) + void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => { + if (accepted === false) { + loadIntoComposer(submitted, submittedAttachments) + writePersistedComposerDraft(draftPersistenceScope, submitted) + } else { + clearPersistedComposerDraft(draftPersistenceScope) + } + }).catch(() => { + loadIntoComposer(submitted, submittedAttachments) + writePersistedComposerDraft(draftPersistenceScope, submitted) + }) } focusInput() diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 83f0a3feb96..7bb44c74bd0 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -3,9 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { $composerAttachments, addComposerAttachment, + clearPersistedComposerDraft, type ComposerAttachment, + composerDraftStorageKey, + readPersistedComposerDraft, removeComposerAttachment, - updateComposerAttachment + updateComposerAttachment, + writePersistedComposerDraft } from './composer' function attachment(overrides: Partial & Pick): ComposerAttachment { @@ -41,3 +45,39 @@ describe('updateComposerAttachment', () => { expect($composerAttachments.get()).toHaveLength(0) }) }) + +describe('persisted composer drafts', () => { + afterEach(() => { + window.localStorage.clear() + }) + + it('stores and restores text drafts per session scope', () => { + writePersistedComposerDraft('session-a', 'almost submitted prompt') + writePersistedComposerDraft('session-b', 'other draft') + + expect(readPersistedComposerDraft('session-a')).toBe('almost submitted prompt') + expect(readPersistedComposerDraft('session-b')).toBe('other draft') + }) + + it('uses a stable new-session key when no session id exists yet', () => { + writePersistedComposerDraft(null, 'first prompt draft') + + expect(window.localStorage.getItem(composerDraftStorageKey(null))).toBe('first prompt draft') + expect(readPersistedComposerDraft(undefined)).toBe('first prompt draft') + }) + + it('removes empty drafts instead of leaving stale text behind', () => { + writePersistedComposerDraft('session-a', 'saved') + writePersistedComposerDraft('session-a', '') + + expect(readPersistedComposerDraft('session-a')).toBe('') + expect(window.localStorage.getItem(composerDraftStorageKey('session-a'))).toBeNull() + }) + + it('can explicitly clear a saved draft after submit', () => { + writePersistedComposerDraft('session-a', 'saved') + clearPersistedComposerDraft('session-a') + + expect(readPersistedComposerDraft('session-a')).toBe('') + }) +}) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 6b2b58ccb8d..5af98a49e3b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,68 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +const COMPOSER_DRAFT_STORAGE_PREFIX = 'hermes:composer-draft:v1:' +const NEW_SESSION_DRAFT_SCOPE = '__new__' + +function storageScope(scope: string | null | undefined): string { + const trimmed = scope?.trim() + + return trimmed || NEW_SESSION_DRAFT_SCOPE +} + +function browserStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.localStorage + } catch { + return null + } +} + +export function composerDraftStorageKey(scope: string | null | undefined): string { + return `${COMPOSER_DRAFT_STORAGE_PREFIX}${encodeURIComponent(storageScope(scope))}` +} + +export function readPersistedComposerDraft(scope: string | null | undefined): string { + try { + return browserStorage()?.getItem(composerDraftStorageKey(scope)) ?? '' + } catch { + return '' + } +} + +export function writePersistedComposerDraft(scope: string | null | undefined, value: string) { + try { + const storage = browserStorage() + + if (!storage) { + return + } + + const key = composerDraftStorageKey(scope) + + if (value.length === 0) { + storage.removeItem(key) + } else { + storage.setItem(key, value) + } + } catch { + // Draft persistence is a safety net only; storage quota/private-mode errors + // must never break typing or submission. + } +} + +export function clearPersistedComposerDraft(scope: string | null | undefined) { + try { + browserStorage()?.removeItem(composerDraftStorageKey(scope)) + } catch { + // Best-effort only. + } +} + export function setComposerDraft(value: string) { $composerDraft.set(value) }