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
This commit is contained in:
Brooklyn Nicholson 2026-06-10 23:47:32 -05:00
parent c710868fbc
commit 292192f7d7
3 changed files with 37 additions and 75 deletions

View file

@ -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()

View file

@ -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]

View file

@ -26,53 +26,30 @@ export const $composerTerminalSelections = atom<Record<string, string>>({})
// 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)
}