From 292192f7d7263ab429ce57dce754e34c83902de3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 10 Jun 2026 23:47:32 -0500 Subject: [PATCH] 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) }