From d7d281fa37e417895580a16163f591fc08c0464b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 11 Jun 2026 00:01:06 -0500 Subject: [PATCH] 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)