From 18d61bd06e062049f524d6b1d3a51956db2cf1ea Mon Sep 17 00:00:00 2001 From: Roger Date: Wed, 10 Jun 2026 13:00:45 -0400 Subject: [PATCH 1/7] 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) } From 3d14f01fd674ab2add062d3a302c30a6b457b791 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:57:09 -0700 Subject: [PATCH 2/7] fix(desktop): debounce per-keystroke draft persistence writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged draft-persistence effect wrote to localStorage on every keystroke — the composer's per-keystroke path was deliberately slimmed down previously, so debounce the write (400ms) and flush pending text on scope change/unmount so a fast session switch can't drop trailing keystrokes. Also add AUTHOR_MAP entry for the salvaged commit. --- apps/desktop/src/app/chat/composer/index.tsx | 33 +++++++++++++++++++- scripts/release.py | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 05fa4a451bc..5530ffb60ae 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -137,6 +137,10 @@ interface QueueEditState { const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) +// How long the composer waits after the last keystroke before persisting the +// draft to localStorage. Scope-change/unmount flushes bypass the delay. +const DRAFT_PERSIST_DEBOUNCE_MS = 400 + export function ChatBar({ busy, cwd, @@ -180,6 +184,7 @@ export function ChatBar({ const draftRef = useRef(draft) const previousBusyRef = useRef(busy) const skipNextDraftPersistScopeRef = useRef(null) + const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1119,9 +1124,35 @@ export function ChatBar({ return } - writePersistedComposerDraft(draftPersistenceScope, draft) + // 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 + // on scope change / unmount so a fast session switch can't drop the + // trailing keystrokes. + pendingDraftPersistRef.current = { scope: draftPersistenceScope, value: draft } + + const handle = window.setTimeout(() => { + pendingDraftPersistRef.current = null + writePersistedComposerDraft(draftPersistenceScope, draft) + }, DRAFT_PERSIST_DEBOUNCE_MS) + + return () => window.clearTimeout(handle) }, [draft, draftPersistenceScope]) + // Flush any pending debounced draft write when leaving a session scope or + // unmounting, so the departing session's latest text is always persisted. + useEffect( + () => () => { + const pending = pendingDraftPersistRef.current + + if (pending) { + pendingDraftPersistRef.current = null + writePersistedComposerDraft(pending.scope, pending.value) + } + }, + [draftPersistenceScope] + ) + const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return diff --git a/scripts/release.py b/scripts/release.py index 68ad134d6cd..10ed7b658a2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -76,6 +76,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", "mharris@parallel.ai": "NormallyGaussian", + "roger@roger.local": "mollusk", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666", From 65ddc7c4a1dc32a1ad5e9e732618c5a0f3ef3bf4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 22:41:34 -0500 Subject: [PATCH 3/7] 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) } From fdc0d1956636d0c90cb53192f8acebf2e5459ee1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 22:58:50 -0500 Subject: [PATCH 4/7] =?UTF-8?q?fix(desktop):=20make=20draft=20persistence?= =?UTF-8?q?=20actually=20fire=20=E2=80=94=20new-chat=20sentinel,=20reload?= =?UTF-8?q?=20flush,=20session-switch=20clears?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/composer/index.tsx | 31 +++++++++++++------ .../app/session/hooks/use-session-actions.ts | 19 +++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 169c22236b8..e55e5019205 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -186,7 +186,11 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) - const skipNextDraftPersistScopeRef = useRef(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(undefined) const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(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) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 51ee90924ae..f049223b1f4 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -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('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('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) From c710868fbca9bdf7adb155c68ecbdd74858ede9f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 23:39:35 -0500 Subject: [PATCH 5/7] refactor(desktop): decouple composer from session lifecycle entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer is a single global surface that sits ABOVE the thread: its contents follow the user across session switches and are never touched by session lifecycle. Switching threads doesn't change the render. Replaces the per-scope draft choreography (scoped storage keys, attachment stash map, skip-sentinel, restore-on-scope-change effect) with: - one global localStorage key so an unsent draft survives app reloads - a one-shot restore on mount - nothing else — session switches simply don't touch the composer Verified E2E via CDP with real sidebar clicks + real keystrokes: typed draft survives A->B->A switching and a full page reload. --- apps/desktop/src/app/chat/composer/index.tsx | 78 +++++++------------ apps/desktop/src/store/composer.test.ts | 82 ++++---------------- apps/desktop/src/store/composer.ts | 64 +++------------ 3 files changed, 50 insertions(+), 174 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e55e5019205..511713d086f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -28,11 +28,8 @@ import { $composerAttachments, clearComposerAttachments, clearPersistedComposerDraft, - clearStashedComposerAttachments, type ComposerAttachment, readPersistedComposerDraft, - stashComposerAttachments, - takeComposerAttachments, writePersistedComposerDraft } from '@/store/composer' import { @@ -174,7 +171,6 @@ 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] ?? []) : []), @@ -186,12 +182,7 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) - // `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(undefined) - const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null) + const pendingDraftPersistRef = useRef(null) const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -1118,65 +1109,50 @@ 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. + // The composer deliberately does NOT react to session switches: it sits + // above the thread and its contents follow the user. The only restore is a + // one-shot on mount so an unsent draft survives an app reload. useEffect(() => { - const persisted = readPersistedComposerDraft(draftPersistenceScope) - const restoredAttachments = takeComposerAttachments(draftPersistenceScope) - skipNextDraftPersistScopeRef.current = draftPersistenceScope - loadIntoComposer(persisted, restoredAttachments) + const persisted = readPersistedComposerDraft() - return () => { - stashComposerAttachments(draftPersistenceScope, $composerAttachments.get()) + if (persisted && !draftRef.current.trim()) { + loadIntoComposer(persisted, $composerAttachments.get()) } - }, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps + }, []) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) { - skipNextDraftPersistScopeRef.current = undefined - - 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. + // history keeps in its own snapshot and restores on the way back). 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 - // on scope change / unmount so a fast session switch can't drop the - // trailing keystrokes. - pendingDraftPersistRef.current = { scope: draftPersistenceScope, value: draft } + // don't touch storage on every keypress. + pendingDraftPersistRef.current = draft const handle = window.setTimeout(() => { pendingDraftPersistRef.current = null - writePersistedComposerDraft(draftPersistenceScope, draft) + writePersistedComposerDraft(draft) }, DRAFT_PERSIST_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [draft, draftPersistenceScope, queueEdit, sessionId]) + }, [draft, queueEdit, sessionId]) - // 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. + // Flush any pending debounced write on unmount or window unload. 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 would + // silently drop everything typed in the last 400ms. useEffect(() => { const flushPendingDraftPersist = () => { const pending = pendingDraftPersistRef.current - if (pending) { + if (pending !== null) { pendingDraftPersistRef.current = null - writePersistedComposerDraft(pending.scope, pending.value) + writePersistedComposerDraft(pending) } } @@ -1186,7 +1162,7 @@ export function ChatBar({ window.removeEventListener('pagehide', flushPendingDraftPersist) flushPendingDraftPersist() } - }, [draftPersistenceScope]) + }, []) const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { @@ -1444,14 +1420,13 @@ export function ChatBar({ void Promise.resolve(onSubmit(submitted)).then(accepted => { if (accepted === false) { loadIntoComposer(submitted, []) - writePersistedComposerDraft(draftPersistenceScope, submitted) + writePersistedComposerDraft(submitted) } else { - clearPersistedComposerDraft(draftPersistenceScope) - clearStashedComposerAttachments(draftPersistenceScope) + clearPersistedComposerDraft() } }).catch(() => { loadIntoComposer(submitted, []) - writePersistedComposerDraft(draftPersistenceScope, submitted) + writePersistedComposerDraft(submitted) }) } else if (payloadPresent) { queueCurrentDraft() @@ -1473,14 +1448,13 @@ export function ChatBar({ void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => { if (accepted === false) { loadIntoComposer(submitted, submittedAttachments) - writePersistedComposerDraft(draftPersistenceScope, submitted) + writePersistedComposerDraft(submitted) } else { - clearPersistedComposerDraft(draftPersistenceScope) - clearStashedComposerAttachments(draftPersistenceScope) + clearPersistedComposerDraft() } }).catch(() => { loadIntoComposer(submitted, submittedAttachments) - writePersistedComposerDraft(draftPersistenceScope, submitted) + writePersistedComposerDraft(submitted) }) } diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 05ecef22558..efa9fcba4db 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -4,13 +4,10 @@ import { $composerAttachments, addComposerAttachment, clearPersistedComposerDraft, - clearStashedComposerAttachments, + COMPOSER_DRAFT_STORAGE_KEY, type ComposerAttachment, - composerDraftStorageKey, readPersistedComposerDraft, removeComposerAttachment, - stashComposerAttachments, - takeComposerAttachments, updateComposerAttachment, writePersistedComposerDraft } from './composer' @@ -49,83 +46,30 @@ describe('updateComposerAttachment', () => { }) }) -describe('persisted composer drafts', () => { +describe('persisted composer draft', () => { afterEach(() => { window.localStorage.clear() }) - it('stores and restores text drafts per session scope', () => { - writePersistedComposerDraft('session-a', 'almost submitted prompt') - writePersistedComposerDraft('session-b', 'other draft') + it('stores and restores the draft', () => { + writePersistedComposerDraft('almost submitted prompt') - 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') + expect(readPersistedComposerDraft()).toBe('almost submitted prompt') + expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('almost submitted prompt') }) it('removes empty drafts instead of leaving stale text behind', () => { - writePersistedComposerDraft('session-a', 'saved') - writePersistedComposerDraft('session-a', '') + writePersistedComposerDraft('saved') + writePersistedComposerDraft('') - expect(readPersistedComposerDraft('session-a')).toBe('') - expect(window.localStorage.getItem(composerDraftStorageKey('session-a'))).toBeNull() + expect(readPersistedComposerDraft()).toBe('') + expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBeNull() }) it('can explicitly clear a saved draft after submit', () => { - writePersistedComposerDraft('session-a', 'saved') - clearPersistedComposerDraft('session-a') + writePersistedComposerDraft('saved') + clearPersistedComposerDraft() - 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([]) + expect(readPersistedComposerDraft()).toBe('') }) }) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index ba78ac43e22..35d372521cc 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,14 +21,10 @@ 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 -} +// The composer is a single global surface that sits ABOVE the thread: its +// contents follow the user across session switches and are never touched by +// session lifecycle. One storage key makes the draft survive app reloads. +export const COMPOSER_DRAFT_STORAGE_KEY = 'hermes:composer-draft:v2' function browserStorage(): Storage | null { if (typeof window === 'undefined') { @@ -42,19 +38,15 @@ function browserStorage(): Storage | 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 { +export function readPersistedComposerDraft(): string { try { - return browserStorage()?.getItem(composerDraftStorageKey(scope)) ?? '' + return browserStorage()?.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? '' } catch { return '' } } -export function writePersistedComposerDraft(scope: string | null | undefined, value: string) { +export function writePersistedComposerDraft(value: string) { try { const storage = browserStorage() @@ -62,12 +54,10 @@ export function writePersistedComposerDraft(scope: string | null | undefined, va return } - const key = composerDraftStorageKey(scope) - if (value.length === 0) { - storage.removeItem(key) + storage.removeItem(COMPOSER_DRAFT_STORAGE_KEY) } else { - storage.setItem(key, value) + storage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value) } } catch { // Draft persistence is a safety net only; storage quota/private-mode errors @@ -75,46 +65,14 @@ export function writePersistedComposerDraft(scope: string | null | undefined, va } } -export function clearPersistedComposerDraft(scope: string | null | undefined) { +export function clearPersistedComposerDraft() { try { - browserStorage()?.removeItem(composerDraftStorageKey(scope)) + browserStorage()?.removeItem(COMPOSER_DRAFT_STORAGE_KEY) } catch { // Best-effort only. } } -// 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) } From 292192f7d7263ab429ce57dce754e34c83902de3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 23:47:32 -0500 Subject: [PATCH 6/7] refactor(desktop): tidy composer draft persistence - DRY the duplicated submit-restore blocks into dispatchSubmit() - inline localStorage access (drop browserStorage indirection); clearPersistedComposerDraft delegates to write('') - drop stale per-scope-stash comment in use-session-actions --- apps/desktop/src/app/chat/composer/index.tsx | 67 ++++++++----------- .../app/session/hooks/use-session-actions.ts | 6 +- apps/desktop/src/store/composer.ts | 39 +++-------- 3 files changed, 37 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 511713d086f..ae4f78099cb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -137,8 +137,8 @@ interface QueueEditState { const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) -// How long the composer waits after the last keystroke before persisting the -// draft to localStorage. Scope-change/unmount flushes bypass the delay. +// Quiet period after the last keystroke before persisting the draft; +// unmount/pagehide flushes bypass it. const DRAFT_PERSIST_DEBOUNCE_MS = 400 export function ChatBar({ @@ -1109,9 +1109,9 @@ export function ChatBar({ } } - // The composer deliberately does NOT react to session switches: it sits - // above the thread and its contents follow the user. The only restore is a - // one-shot on mount so an unsent draft survives an app reload. + // The composer sits above the thread and does NOT react to session + // switches; the only restore is a one-shot on mount so drafts survive + // app reloads. useEffect(() => { const persisted = readPersistedComposerDraft() @@ -1120,18 +1120,14 @@ export function ChatBar({ } }, []) // eslint-disable-line react-hooks/exhaustive-deps + // Debounced draft persistence. Skipped for programmatically-loaded text + // (history browsing, queued-prompt edits) so recalled text never clobbers + // the genuine in-progress draft. useEffect(() => { - // 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). 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. pendingDraftPersistRef.current = draft const handle = window.setTimeout(() => { @@ -1142,10 +1138,10 @@ export function ChatBar({ return () => window.clearTimeout(handle) }, [draft, queueEdit, sessionId]) - // Flush any pending debounced write on unmount or window unload. 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 would - // silently drop everything typed in the last 400ms. + // Flush any pending debounced write on unmount or unload. 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 drops the + // trailing keystrokes. useEffect(() => { const flushPendingDraftPersist = () => { const pending = pendingDraftPersistRef.current @@ -1380,6 +1376,19 @@ export function ChatBar({ setQueueEdit(null) }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps + // Submit, restoring the composer (and its persisted draft) if the gateway + // rejects or the submit throws — typed text is never lost to a failed send. + const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const restore = () => { + loadIntoComposer(text, attachments ?? []) + writePersistedComposerDraft(text) + } + + void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + .then(accepted => void (accepted === false ? restore() : clearPersistedComposerDraft())) + .catch(restore) + } + const submitDraft = () => { // Source the text from the DOM editor, not React state. The AUI composer // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a @@ -1414,20 +1423,9 @@ export function ChatBar({ // /send directives). Queuing them would make every slash command wait // for the current turn to finish, which is how the TUI never behaves. if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { - const submitted = text triggerHaptic('submit') clearDraft() - void Promise.resolve(onSubmit(submitted)).then(accepted => { - if (accepted === false) { - loadIntoComposer(submitted, []) - writePersistedComposerDraft(submitted) - } else { - clearPersistedComposerDraft() - } - }).catch(() => { - loadIntoComposer(submitted, []) - writePersistedComposerDraft(submitted) - }) + dispatchSubmit(text) } else if (payloadPresent) { queueCurrentDraft() } else { @@ -1439,23 +1437,12 @@ export function ChatBar({ } else if (!payloadPresent && queuedPrompts.length > 0) { void drainNextQueued() } else if (payloadPresent) { - const submitted = text const submittedAttachments = cloneAttachments(attachments) triggerHaptic('submit') resetBrowseState(sessionId) clearDraft() clearComposerAttachments() - void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => { - if (accepted === false) { - loadIntoComposer(submitted, submittedAttachments) - writePersistedComposerDraft(submitted) - } else { - clearPersistedComposerDraft() - } - }).catch(() => { - loadIntoComposer(submitted, submittedAttachments) - writePersistedComposerDraft(submitted) - }) + dispatchSubmit(text, submittedAttachments) } focusInput() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index f049223b1f4..e2e4b28ff3c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -328,10 +328,8 @@ export function useSessionActions({ setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') - // 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. + // Never clear the composer here: it sits above the thread and its + // contents (text + attachments) follow the user across session changes. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 35d372521cc..cb79cf540af 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -26,53 +26,30 @@ export const $composerTerminalSelections = atom>({}) // session lifecycle. One storage key makes the draft survive app reloads. export const COMPOSER_DRAFT_STORAGE_KEY = 'hermes:composer-draft:v2' -function browserStorage(): Storage | null { - if (typeof window === 'undefined') { - return null - } - - try { - return window.localStorage - } catch { - return null - } -} - export function readPersistedComposerDraft(): string { try { - return browserStorage()?.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? '' + return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? '' } catch { return '' } } +// Empty drafts remove the key. Persistence is a safety net only — storage +// errors (quota, private mode) must never break typing or submission. export function writePersistedComposerDraft(value: string) { try { - const storage = browserStorage() - - if (!storage) { - return - } - - if (value.length === 0) { - storage.removeItem(COMPOSER_DRAFT_STORAGE_KEY) + if (value) { + window.localStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value) } else { - storage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value) + window.localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY) } - } catch { - // Draft persistence is a safety net only; storage quota/private-mode errors - // must never break typing or submission. - } -} - -export function clearPersistedComposerDraft() { - try { - browserStorage()?.removeItem(COMPOSER_DRAFT_STORAGE_KEY) } catch { // Best-effort only. } } +export const clearPersistedComposerDraft = () => writePersistedComposerDraft('') + export function setComposerDraft(value: string) { $composerDraft.set(value) } From d7d281fa37e417895580a16163f591fc08c0464b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 11 Jun 2026 00:01:06 -0500 Subject: [PATCH 7/7] feat(desktop): strict per-thread drafts on decoupled composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyed draft stash (Map + localStorage mirror) behind the live composer: switching threads stashes the departing draft and restores the entering one; empty threads show an empty box. Session lifecycle never clears composer state — the scope swap is the only coupling. Co-authored-by: mollusk Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/src/app/chat/composer/index.tsx | 87 ++++++++++++------- .../app/session/hooks/use-session-actions.ts | 3 +- apps/desktop/src/store/composer.test.ts | 69 +++++++++++---- apps/desktop/src/store/composer.ts | 79 +++++++++++++---- 4 files changed, 169 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ae4f78099cb..c18313a7386 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -27,10 +27,10 @@ import { cn } from '@/lib/utils' import { $composerAttachments, clearComposerAttachments, - clearPersistedComposerDraft, + clearSessionDraft, type ComposerAttachment, - readPersistedComposerDraft, - writePersistedComposerDraft + stashSessionDraft, + takeSessionDraft } from '@/store/composer' import { browseBackward, @@ -182,7 +182,9 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) - const pendingDraftPersistRef = useRef(null) + const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) + const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) + activeQueueSessionKeyRef.current = activeQueueSessionKey const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -194,6 +196,8 @@ export function ChatBar({ const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) const [focusRequestId, setFocusRequestId] = useState(0) + const queueEditRef = useRef(queueEdit) + queueEditRef.current = queueEdit const dragDepthRef = useRef(0) const composingRef = useRef(false) // true during IME composition (CJK input) const lastSpokenIdRef = useRef(null) @@ -1109,47 +1113,59 @@ export function ChatBar({ } } - // The composer sits above the thread and does NOT react to session - // switches; the only restore is a one-shot on mount so drafts survive - // app reloads. + const stashAt = ( + scope: string | null, + text = draftRef.current, + attachments = $composerAttachments.get() + ) => stashSessionDraft(scope, text, attachments) + + // Per-thread draft swap — the composer's only session coupling. Lifecycle + // never clears composer state; this effect alone stashes on leave, restores + // on enter. Keyed writes are idempotent, so no skip-sentinel. useEffect(() => { - const persisted = readPersistedComposerDraft() + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) + loadIntoComposer(text, attachments) - if (persisted && !draftRef.current.trim()) { - loadIntoComposer(persisted, $composerAttachments.get()) + return () => { + const editing = queueEditRef.current + + if (editing?.sessionKey === activeQueueSessionKey) { + stashAt(activeQueueSessionKey, editing.draft, editing.attachments) + } else if (!isBrowsingHistory(sessionId)) { + stashAt(activeQueueSessionKey) + } } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps - // Debounced draft persistence. Skipped for programmatically-loaded text - // (history browsing, queued-prompt edits) so recalled text never clobbers - // the genuine in-progress draft. + // Debounced stash into the active scope. Skipped while browsing history or + // editing a queued prompt — recalled text must not clobber the real draft. useEffect(() => { if (isBrowsingHistory(sessionId) || queueEdit) { return } - pendingDraftPersistRef.current = draft + pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft } const handle = window.setTimeout(() => { pendingDraftPersistRef.current = null - writePersistedComposerDraft(draft) + stashAt(activeQueueSessionKey, draft) }, DRAFT_PERSIST_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [draft, queueEdit, sessionId]) + }, [activeQueueSessionKey, draft, queueEdit, sessionId]) - // Flush any pending debounced write on unmount or unload. 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 drops the - // trailing keystrokes. + // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R + // inside the debounce window would drop trailing keystrokes without this. useEffect(() => { const flushPendingDraftPersist = () => { const pending = pendingDraftPersistRef.current - if (pending !== null) { - pendingDraftPersistRef.current = null - writePersistedComposerDraft(pending) + if (!pending) { + return } + + pendingDraftPersistRef.current = null + stashAt(pending.scope, pending.text) } window.addEventListener('pagehide', flushPendingDraftPersist) @@ -1362,30 +1378,35 @@ export function ChatBar({ } }, [busy, drainNextQueued, queuedPrompts.length]) - // Clean up queue edit when its target disappears (session swap or external delete). + // Queue-edit cleanup: on session swap the scope effect already stashed the + // edit snapshot; only restore into the composer when still on the same scope. useEffect(() => { if (!queueEdit) { return } - if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) { - return + if (queueEdit.sessionKey === activeQueueSessionKey) { + if (editingQueuedPrompt) { + return + } + + loadIntoComposer(queueEdit.draft, queueEdit.attachments) } - loadIntoComposer(queueEdit.draft, queueEdit.attachments) setQueueEdit(null) }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps - // Submit, restoring the composer (and its persisted draft) if the gateway - // rejects or the submit throws — typed text is never lost to a failed send. const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const submittedScope = activeQueueSessionKeyRef.current + const submittedAttachments = attachments ?? [] + const restore = () => { - loadIntoComposer(text, attachments ?? []) - writePersistedComposerDraft(text) + loadIntoComposer(text, submittedAttachments) + stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) } void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) - .then(accepted => void (accepted === false ? restore() : clearPersistedComposerDraft())) + .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) .catch(restore) } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index e2e4b28ff3c..5077226aade 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -328,8 +328,7 @@ export function useSessionActions({ setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') - // Never clear the composer here: it sits above the thread and its - // contents (text + attachments) follow the user across session changes. + // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index efa9fcba4db..08bbb391c95 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -3,13 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest' import { $composerAttachments, addComposerAttachment, - clearPersistedComposerDraft, - COMPOSER_DRAFT_STORAGE_KEY, + clearSessionDraft, type ComposerAttachment, - readPersistedComposerDraft, removeComposerAttachment, - updateComposerAttachment, - writePersistedComposerDraft + SESSION_DRAFTS_STORAGE_KEY, + stashSessionDraft, + takeSessionDraft, + updateComposerAttachment } from './composer' function attachment(overrides: Partial & Pick): ComposerAttachment { @@ -46,30 +46,61 @@ describe('updateComposerAttachment', () => { }) }) -describe('persisted composer draft', () => { +describe('session drafts', () => { afterEach(() => { + for (const scope of ['session-a', 'session-b', null]) { + clearSessionDraft(scope) + } + window.localStorage.clear() }) - it('stores and restores the draft', () => { - writePersistedComposerDraft('almost submitted prompt') + it('keeps drafts isolated per session scope', () => { + stashSessionDraft('session-a', 'draft a', []) + stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })]) - expect(readPersistedComposerDraft()).toBe('almost submitted prompt') - expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('almost submitted prompt') + expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' }) + expect(takeSessionDraft('session-b').text).toBe('draft b') + expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b']) }) - it('removes empty drafts instead of leaving stale text behind', () => { - writePersistedComposerDraft('saved') - writePersistedComposerDraft('') + it('scopes the unsaved new-session draft separately from real sessions', () => { + stashSessionDraft(null, 'new chat draft', []) + stashSessionDraft('session-a', 'session draft', []) - expect(readPersistedComposerDraft()).toBe('') - expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBeNull() + expect(takeSessionDraft(null).text).toBe('new chat draft') + expect(takeSessionDraft(undefined).text).toBe('new chat draft') + expect(takeSessionDraft('session-a').text).toBe('session draft') }) - it('can explicitly clear a saved draft after submit', () => { - writePersistedComposerDraft('saved') - clearPersistedComposerDraft() + it('persists draft text (not attachments) to localStorage', () => { + stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })]) - expect(readPersistedComposerDraft()).toBe('') + const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record + + expect(persisted['session-a']).toBe('survives reload') + }) + + it('evicts empty drafts instead of leaving stale entries behind', () => { + stashSessionDraft('session-a', 'saved', []) + stashSessionDraft('session-a', ' ', []) + + expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' }) + }) + + it('clears a stashed draft after an accepted submit', () => { + stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })]) + clearSessionDraft('session-a') + + expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' }) + }) + + it('returns clones so callers cannot mutate the stash', () => { + stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })]) + + const taken = takeSessionDraft('session-a') + taken.attachments[0]!.label = 'mutated' + + expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf') }) }) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index cb79cf540af..c40cf867735 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,34 +21,83 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) -// The composer is a single global surface that sits ABOVE the thread: its -// contents follow the user across session switches and are never touched by -// session lifecycle. One storage key makes the draft survive app reloads. -export const COMPOSER_DRAFT_STORAGE_KEY = 'hermes:composer-draft:v2' +// Per-thread draft stash for the decoupled composer. Session lifecycle never +// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to +// localStorage; attachments are memory-only (blobs, upload state). +export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3' -export function readPersistedComposerDraft(): string { +const NEW_SESSION_DRAFT_KEY = '__new__' +const MAX_PERSISTED_DRAFTS = 50 +const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' } + +export interface SessionDraft { + attachments: ComposerAttachment[] + text: string +} + +const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY + +const cloneDraft = (draft: SessionDraft): SessionDraft => ({ + attachments: draft.attachments.map(attachment => ({ ...attachment })), + text: draft.text +}) + +function loadPersistedDraftTexts(): [string, SessionDraft][] { try { - return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? '' + const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) + + if (!raw) { + return [] + } + + return Object.entries(JSON.parse(raw) as Record).map(([key, text]) => [ + key, + { attachments: [], text } + ]) } catch { - return '' + return [] } } -// Empty drafts remove the key. Persistence is a safety net only — storage -// errors (quota, private mode) must never break typing or submission. -export function writePersistedComposerDraft(value: string) { +const draftsBySession = new Map(loadPersistedDraftTexts()) + +function persistDraftTexts() { try { - if (value) { - window.localStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value) + const entries = [...draftsBySession] + .filter(([, draft]) => draft.text) + .slice(-MAX_PERSISTED_DRAFTS) + .map(([key, draft]) => [key, draft.text] as const) + + if (entries.length === 0) { + window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY) } else { - window.localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY) + window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))) } } catch { - // Best-effort only. + // Best-effort only — quota/private-mode must never break typing. } } -export const clearPersistedComposerDraft = () => writePersistedComposerDraft('') +export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) { + const key = draftKey(scope) + + // Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction. + draftsBySession.delete(key) + + if (text.trim() || attachments.length > 0) { + draftsBySession.set(key, cloneDraft({ attachments, text })) + } + + persistDraftTexts() +} + +export function takeSessionDraft(scope: string | null | undefined): SessionDraft { + const stashed = draftsBySession.get(draftKey(scope)) + + return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT +} + +export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', []) export function setComposerDraft(value: string) { $composerDraft.set(value)