{"repo":"NousResearch/hermes-agent","public_id":"38498","display_label":"Desktop composer draft lost when navigating away","summary":"Multiple bug reports and fix attempts addressing composer/prompt text being cleared when navigating to Settings or other screens in the desktop app. The cluster centers on persisting draft text across route changes and overlay navigation.","subsystem":"apps/desktop","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":43959},"resolution":{"state":"partially_resolved","by":"pr-43959"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T05:12:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17955555555555555},"fix_edges":[{"issue_number":38498,"pr_number":43959,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":41079,"pr_number":43959,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42401,"pr_number":43959,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":38498,"title":"Desktop: persist draft prompt text when navigating away from a new session","url":null,"state":"closed"},{"kind":"issue","number":42401,"title":"[Bug]: Prompts being discarded/disappearing when opening a new screen on the desktop app","url":null,"state":"closed"},{"kind":"issue","number":43737,"title":"Hermes Desktop: Prompt text lost when navigating to Settings before submit","url":null,"state":"closed"},{"kind":"issue","number":43825,"title":"[Bug]: In Hermes Desktop, opening the Settings windows clears the text in the prompt window.","url":null,"state":"closed"},{"kind":"pr","number":38621,"title":"fix(desktop): persist new-session drafts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fdc0d1956636d0c90cb53192f8acebf2e5459ee1","number":0},{"kind":"pr","value":"#38498","number":38498}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42438,"title":"fix(desktop): persist composer draft across non-overlay route changes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43959","number":43959}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43756,"title":"fix(desktop): preserve composer draft when navigating to Settings","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38400","number":38400},{"kind":"pr","value":"#38400","number":38400}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43833,"title":"fix(desktop): keep chat mounted under settings overlay","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43825","number":43825},{"kind":"pr","value":"#41146","number":41146}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43939,"title":"fix(desktop): persist composer drafts per session (salvage #43660)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#43959","number":43959},{"kind":"pr","value":"#43959","number":43959}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":43959,"title":"fix(desktop): per-thread composer drafts on decoupled lifecycle (salvage #43660, supersedes #43939)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/composer/index.tsx","apps/desktop/src/app/session/hooks/use-session-actions.ts","apps/desktop/src/store/composer.test.ts","apps/desktop/src/store/composer.ts","scripts/release.py"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/index.tsx","hunks":["@@ -24,7 +24,14 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'\n import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'\n import { triggerHaptic } from '@/lib/haptics'\n import { cn } from '@/lib/utils'\n-import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'\n+import {\n+  $composerAttachments,\n+  clearComposerAttachments,\n+  clearSessionDraft,\n+  type ComposerAttachment,\n+  stashSessionDraft,\n+  takeSessionDraft\n+} from '@/store/composer'\n import {\n   browseBackward,\n   browseForward,","@@ -130,6 +137,10 @@ interface QueueEditState {\n \n const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))\n \n+// Quiet period after the last keystroke before persisting the draft;\n+// unmount/pagehide flushes bypass it.\n+const DRAFT_PERSIST_DEBOUNCE_MS = 400\n+\n export function ChatBar({\n   busy,\n   cwd,","@@ -171,6 +182,9 @@ export function ChatBar({\n   const editorRef = useRef<HTMLDivElement | null>(null)\n   const draftRef = useRef(draft)\n   const previousBusyRef = useRef(busy)\n+  const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)\n+  const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)\n+  activeQueueSessionKeyRef.current = activeQueueSessionKey\n   const drainingQueueRef = useRef(false)\n   const urlInputRef = useRef<HTMLInputElement | null>(null)\n ","@@ -182,6 +196,8 @@ export function ChatBar({\n   const [dragActive, setDragActive] = useState(false)\n   const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)\n   const [focusRequestId, setFocusRequestId] = useState(0)\n+  const queueEditRef = useRef(queueEdit)\n+  queueEditRef.current = queueEdit\n   const dragDepthRef = useRef(0)\n   const composingRef = useRef(false) // true during IME composition (CJK input)\n   const lastSpokenIdRef = useRef<string | null>(null)","@@ -1097,6 +1113,69 @@ export function ChatBar({\n     }\n   }\n \n+  const stashAt = (\n+    scope: string | null,\n+    text = draftRef.current,\n+    attachments = $composerAttachments.get()\n+  ) => stashSessionDraft(scope, text, attachments)\n+\n+  // Per-thread draft swap — the composer's only session coupling. Lifecycle\n+  // never clears composer state; this effect alone stashes on leave, restores\n+  // on enter. Keyed writes are idempotent, so no skip-sentinel.\n+  useEffect(() => {\n+    const { attachments, text } = takeSessionDraft(activeQueueSessionKey)\n+    loadIntoComposer(text, attachments)\n+\n+    return () => {\n+      const editing = queueEditRef.current\n+\n+      if (editing?.sessionKey === activeQueueSessionKey) {\n+        stashAt(activeQueueSessionKey, editing.draft, editing.attachments)\n+      } else if (!isBrowsingHistory(sessionId)) {\n+        stashAt(activeQueueSessionKey)\n+      }\n+    }\n+  }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps\n+\n+  // Debounced stash into the active scope. Skipped while browsing history or\n+  // editing a queued prompt — recalled text must not clobber the real draft.\n+  useEffect(() => {\n+    if (isBrowsingHistory(sessionId) || queueEdit) {\n+      return\n+    }\n+\n+    pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }\n+\n+    const handle = window.setTimeout(() => {\n+      pendingDraftPersistRef.current = null\n+      stashAt(activeQueueSessionKey, draft)\n+    }, DRAFT_PERSIST_DEBOUNCE_MS)\n+\n+    return () => window.clearTimeout(handle)\n+  }, [activeQueueSessionKey, draft, queueEdit, sessionId])\n+\n+  // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R\n+  // inside the debounce window would drop trailing keystrokes without this.\n+  useEffect(() => {\n+    const flushPendingDraftPersist = () => {\n+      const pending = pendingDraftPersistRef.current\n+\n+      if (!pending) {\n+        return\n+      }\n+\n+      pendingDraftPersistRef.current = null\n+      stashAt(pending.scope, pending.text)\n+    }\n+\n+    window.addEventListener('pagehide', flushPendingDraftPersist)\n+\n+    return () => {\n+      window.removeEventListener('pagehide', flushPendingDraftPersist)\n+      flushPendingDraftPersist()\n+    }\n+  }, [])\n+\n   const beginQueuedEdit = (entry: QueuedPromptEntry) => {\n     if (!activeQueueSessionKey || queueEdit) {\n       return","@@ -1299,20 +1378,38 @@ export function ChatBar({\n     }\n   }, [busy, drainNextQueued, queuedPrompts.length])\n \n-  // Clean up queue edit when its target disappears (session swap or external delete).\n+  // Queue-edit cleanup: on session swap the scope effect already stashed the\n+  // edit snapshot; only restore into the composer when still on the same scope.\n   useEffect(() => {\n     if (!queueEdit) {\n       return\n     }\n \n-    if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {\n-      return\n+    if (queueEdit.sessionKey === activeQueueSessionKey) {\n+      if (editingQueuedPrompt) {\n+        return\n+      }\n+\n+      loadIntoComposer(queueEdit.draft, queueEdit.attachments)\n     }\n \n-    loadIntoComposer(queueEdit.draft, queueEdit.attachments)\n     setQueueEdit(null)\n   }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps\n \n+  const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {\n+    const submittedScope = activeQueueSessionKeyRef.current\n+    const submittedAttachments = attachments ?? []\n+\n+    const restore = () => {\n+      loadIntoComposer(text, submittedAttachments)\n+      stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)\n+    }\n+\n+    void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))\n+      .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))\n+      .catch(restore)\n+  }\n+\n   const submitDraft = () => {\n     // Source the text from the DOM editor, not React state. The AUI composer\n     // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a","@@ -1323,8 +1420,10 @@ export function ChatBar({\n     // input event; refresh it from the editor once more to also cover an\n     // in-flight keystroke that hasn't fired its input event yet.\n     const editor = editorRef.current\n+\n     if (editor) {\n       const domText = composerPlainText(editor)\n+\n       if (domText !== draftRef.current) {\n         draftRef.current = domText\n         aui.composer().setText(domText)","@@ -1345,10 +1444,9 @@ export function ChatBar({\n       // /send directives).  Queuing them would make every slash command wait\n       // for the current turn to finish, which is how the TUI never behaves.\n       if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {\n-        const submitted = text\n         triggerHaptic('submit')\n         clearDraft()\n-        void onSubmit(submitted)\n+        dispatchSubmit(text)\n       } else if (payloadPresent) {\n         queueCurrentDraft()\n       } else {","@@ -1360,12 +1458,12 @@ export function ChatBar({\n     } else if (!payloadPresent && queuedPrompts.length > 0) {\n       void drainNextQueued()\n     } else if (payloadPresent) {\n-      const submitted = text\n+      const submittedAttachments = cloneAttachments(attachments)\n       triggerHaptic('submit')\n       resetBrowseState(sessionId)\n       clearDraft()\n       clearComposerAttachments()\n-      void onSubmit(submitted, { attachments })\n+      dispatchSubmit(text, submittedAttachments)\n     }\n \n     focusInput()"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.ts","hunks":["@@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat\n import { normalizePersonalityValue } from '@/lib/chat-runtime'\n import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'\n import { setSessionYolo } from '@/lib/yolo-session'\n-import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'\n import { clearQueuedPrompts } from '@/store/composer-queue'\n import { $pinnedSessionIds } from '@/store/layout'\n import { clearNotifications, notify, notifyError } from '@/store/notifications'","@@ -19,7 +18,6 @@ import {\n   $messages,\n   $sessions,\n   $yoloActive,\n-  workspaceCwdForNewSession,\n   sessionPinId,\n   setActiveSessionId,\n   setAwaitingResponse,","@@ -41,7 +39,8 @@ import {\n   setSessionStartedAt,\n   setSessionsTotal,\n   setTurnStartedAt,\n-  setYoloActive\n+  setYoloActive,\n+  workspaceCwdForNewSession\n } from '@/store/session'\n import { reportBackendContract } from '@/store/updates'\n import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'","@@ -329,8 +328,7 @@ export function useSessionActions({\n       setYoloActive(false)\n       setCurrentCwd(workspaceCwdForNewSession())\n       setCurrentBranch('')\n-      clearComposerDraft()\n-      clearComposerAttachments()\n+      // Never clear the composer here — ChatBar's per-thread draft swap owns it.\n       setFreshDraftReady(true)\n     },\n     [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]","@@ -352,11 +350,13 @@ export function useSessionActions({\n         // Pass the owning profile so a new chat under a non-launch profile (global\n         // remote mode) builds its agent + persists against THAT profile's home/db.\n         const newChatProfile = $newChatProfile.get()\n+\n         const created = await requestGateway<SessionCreateResponse>('session.create', {\n           cols: 96,\n           ...(cwd && { cwd }),\n           ...(newChatProfile ? { profile: newChatProfile } : {})\n         })\n+\n         const stored = created.stored_session_id ?? null\n \n         if (","@@ -475,8 +475,6 @@ export function useSessionActions({\n         setCurrentCwd(cachedState.cwd)\n         setCurrentBranch(cachedState.branch)\n         setSessionStartedAt(Date.now())\n-        clearComposerDraft()\n-        clearComposerAttachments()\n \n         try {\n           const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })","@@ -606,8 +604,6 @@ export function useSessionActions({\n           }),\n           storedSessionId\n         )\n-        clearComposerDraft()\n-        clearComposerAttachments()\n       } catch (err) {\n         if (!isCurrentResume()) {\n           return","@@ -730,8 +726,6 @@ export function useSessionActions({\n         selectedStoredSessionIdRef.current = routedSessionId\n         navigate(sessionRoute(routedSessionId))\n \n-        clearComposerDraft()\n-        clearComposerAttachments()\n         const runtimeInfo = applyRuntimeInfo(branched.info)\n \n         patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)"]},{"file":"apps/desktop/src/store/composer.test.ts","hunks":["@@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'\n import {\n   $composerAttachments,\n   addComposerAttachment,\n+  clearSessionDraft,\n   type ComposerAttachment,\n   removeComposerAttachment,\n+  SESSION_DRAFTS_STORAGE_KEY,\n+  stashSessionDraft,\n+  takeSessionDraft,\n   updateComposerAttachment\n } from './composer'\n ","@@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => {\n     expect($composerAttachments.get()).toHaveLength(0)\n   })\n })\n+\n+describe('session drafts', () => {\n+  afterEach(() => {\n+    for (const scope of ['session-a', 'session-b', null]) {\n+      clearSessionDraft(scope)\n+    }\n+\n+    window.localStorage.clear()\n+  })\n+\n+  it('keeps drafts isolated per session scope', () => {\n+    stashSessionDraft('session-a', 'draft a', [])\n+    stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])\n+\n+    expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })\n+    expect(takeSessionDraft('session-b').text).toBe('draft b')\n+    expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])\n+  })\n+\n+  it('scopes the unsaved new-session draft separately from real sessions', () => {\n+    stashSessionDraft(null, 'new chat draft', [])\n+    stashSessionDraft('session-a', 'session draft', [])\n+\n+    expect(takeSessionDraft(null).text).toBe('new chat draft')\n+    expect(takeSessionDraft(undefined).text).toBe('new chat draft')\n+    expect(takeSessionDraft('session-a').text).toBe('session draft')\n+  })\n+\n+  it('persists draft text (not attachments) to localStorage', () => {\n+    stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])\n+\n+    const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<string, string>\n+\n+    expect(persisted['session-a']).toBe('survives reload')\n+  })\n+\n+  it('evicts empty drafts instead of leaving stale entries behind', () => {\n+    stashSessionDraft('session-a', 'saved', [])\n+    stashSessionDraft('session-a', '   ', [])\n+\n+    expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })\n+  })\n+\n+  it('clears a stashed draft after an accepted submit', () => {\n+    stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])\n+    clearSessionDraft('session-a')\n+\n+    expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })\n+  })\n+\n+  it('returns clones so callers cannot mutate the stash', () => {\n+    stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])\n+\n+    const taken = takeSessionDraft('session-a')\n+    taken.attachments[0]!.label = 'mutated'\n+\n+    expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')\n+  })\n+})"]},{"file":"apps/desktop/src/store/composer.ts","hunks":["@@ -21,6 +21,84 @@ export const $composerDraft = atom('')\n export const $composerAttachments = atom<ComposerAttachment[]>([])\n export const $composerTerminalSelections = atom<Record<string, string>>({})\n \n+// Per-thread draft stash for the decoupled composer. Session lifecycle never\n+// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to\n+// localStorage; attachments are memory-only (blobs, upload state).\n+export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'\n+\n+const NEW_SESSION_DRAFT_KEY = '__new__'\n+const MAX_PERSISTED_DRAFTS = 50\n+const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }\n+\n+export interface SessionDraft {\n+  attachments: ComposerAttachment[]\n+  text: string\n+}\n+\n+const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY\n+\n+const cloneDraft = (draft: SessionDraft): SessionDraft => ({\n+  attachments: draft.attachments.map(attachment => ({ ...attachment })),\n+  text: draft.text\n+})\n+\n+function loadPersistedDraftTexts(): [string, SessionDraft][] {\n+  try {\n+    const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)\n+\n+    if (!raw) {\n+      return []\n+    }\n+\n+    return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [\n+      key,\n+      { attachments: [], text }\n+    ])\n+  } catch {\n+    return []\n+  }\n+}\n+\n+const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())\n+\n+function persistDraftTexts() {\n+  try {\n+    const entries = [...draftsBySession]\n+      .filter(([, draft]) => draft.text)\n+      .slice(-MAX_PERSISTED_DRAFTS)\n+      .map(([key, draft]) => [key, draft.text] as const)\n+\n+    if (entries.length === 0) {\n+      window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)\n+    } else {\n+      window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))\n+    }\n+  } catch {\n+    // Best-effort only — quota/private-mode must never break typing.\n+  }\n+}\n+\n+export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {\n+  const key = draftKey(scope)\n+\n+  // Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.\n+  draftsBySession.delete(key)\n+\n+  if (text.trim() || attachments.length > 0) {\n+    draftsBySession.set(key, cloneDraft({ attachments, text }))\n+  }\n+\n+  persistDraftTexts()\n+}\n+\n+export function takeSessionDraft(scope: string | null | undefined): SessionDraft {\n+  const stashed = draftsBySession.get(draftKey(scope))\n+\n+  return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT\n+}\n+\n+export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])\n+\n export function setComposerDraft(value: string) {\n   $composerDraft.set(value)\n }"]},{"file":"scripts/release.py","hunks":["@@ -76,6 +76,7 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"dirtyren\",\n     \"[redacted:email]\": \"NormallyGaussian\",\n+    \"[redacted:email]\": \"mollusk\",\n     \"[redacted:email]\": \"temalo\",\n     \"[redacted:email]\": \"alarcritty\",\n     \"[redacted:email]\": \"islam666\",\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}