# NousResearch/hermes-agent · Desktop composer draft lost when navigating away

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 10 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 1 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #43959. Verify it addresses your case.
- **Recommended:** read PR #43959 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-43959`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.18`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #38498: Desktop: persist draft prompt text when navigating away from a new session
- #42401: \[Bug\]: Prompts being discarded/disappearing when opening a new screen on the desktop app
- #43737: Hermes Desktop: Prompt text lost when navigating to Settings before submit
- #43825: \[Bug\]: In Hermes Desktop, opening the Settings windows clears the text in the prompt window.

## Prior & competing PRs

- **#43959: fix(desktop): per-thread composer drafts on decoupled lifecycle (salvage #43660, supersedes #43939)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `apps/desktop/src/app/chat/composer/index.tsx`

    ```diff
    @@ -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,
    +  clearSessionDraft,
    +  type ComposerAttachment,
    +  stashSessionDraft,
    +  takeSessionDraft
    +} from '@/store/composer'
     import {
       browseBackward,
       browseForward,
    ```

    ```diff
    @@ -130,6 +137,10 @@ interface QueueEditState {
     
     const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
     
    +// Quiet period after the last keystroke before persisting the draft;
    +// unmount/pagehide flushes bypass it.
    +const DRAFT_PERSIST_DEBOUNCE_MS = 400
    +
     export function ChatBar({
       busy,
       cwd,
    ```

    ```diff
    @@ -171,6 +182,9 @@ export function ChatBar({
       const editorRef = useRef<HTMLDivElement | null>(null)
       const draftRef = useRef(draft)
       const previousBusyRef = useRef(busy)
    +  const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
    +  const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
    +  activeQueueSessionKeyRef.current = activeQueueSessionKey
       const drainingQueueRef = useRef(false)
       const urlInputRef = useRef<HTMLInputElement | null>(null)
     
    ```

    ```diff
    @@ -182,6 +196,8 @@ export function ChatBar({
       const [dragActive, setDragActive] = useState(false)
       const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(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<string | null>(null)
    ```

    ```diff
    @@ -1097,6 +1113,69 @@ export function ChatBar({
         }
       }
     
    +  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 { attachments, text } = takeSessionDraft(activeQueueSessionKey)
    +    loadIntoComposer(text, attachments)
    +
    +    return () => {
    +      const editing = queueEditRef.current
    +
    +      if (editing?.sessionKey === activeQueueSessionKey) {
    +        stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
    +      } else if (!isBrowsingHistory(sessionId)) {
    +        stashAt(activeQueueSessionKey)
    +      }
    +    }
    +  }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
    +
    +  // 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 = { scope: activeQueueSessionKey, text: draft }
    +
    +    const handle = window.setTimeout(() => {
    +      pendingDraftPersistRef.current = null
    +      stashAt(activeQueueSessionKey, draft)
    +    }, DRAFT_PERSIST_DEBOUNCE_MS)
    +
    +    return () => window.clearTimeout(handle)
    +  }, [activeQueueSessionKey, draft, queueEdit, sessionId])
    +
    +  // 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) {
    +        return
    +      }
    +
    +      pendingDraftPersistRef.current = null
    +      stashAt(pending.scope, pending.text)
    +    }
    +
    +    window.addEventListener('pagehide', flushPendingDraftPersist)
    +
    +    return () => {
    +      window.removeEventListener('pagehide', flushPendingDraftPersist)
    +      flushPendingDraftPersist()
    +    }
    +  }, [])
    +
       const beginQueuedEdit = (entry: QueuedPromptEntry) => {
         if (!activeQueueSessionKey || queueEdit) {
           return
    ```

    ```diff
    @@ -1299,20 +1378,38 @@ 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
     
    +  const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
    +    const submittedScope = activeQueueSessionKeyRef.current
    +    const submittedAttachments = attachments ?? []
    +
    +    const restore = () => {
    +      loadIntoComposer(text, submittedAttachments)
    +      stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
    +    }
    +
    +    void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
    +      .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
    +      .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
    ```

    ```diff
    @@ -1323,8 +1420,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)
    ```

    ```diff
    @@ -1345,10 +1444,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 onSubmit(submitted)
    +        dispatchSubmit(text)
           } else if (payloadPresent) {
             queueCurrentDraft()
           } else {
    ```

    ```diff
    @@ -1360,12 +1458,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 onSubmit(submitted, { attachments })
    +      dispatchSubmit(text, submittedAttachments)
         }
     
         focusInput()
    ```
  - `apps/desktop/src/app/session/hooks/use-session-actions.ts`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -19,7 +18,6 @@ import {
       $messages,
       $sessions,
       $yoloActive,
    -  workspaceCwdForNewSession,
       sessionPinId,
       setActiveSessionId,
       setAwaitingResponse,
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -329,8 +328,7 @@ export function useSessionActions({
           setYoloActive(false)
           setCurrentCwd(workspaceCwdForNewSession())
           setCurrentBranch('')
    -      clearComposerDraft()
    -      clearComposerAttachments()
    +      // Never clear the composer here — ChatBar's per-thread draft swap owns it.
           setFreshDraftReady(true)
         },
         [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
    ```

    ```diff
    @@ -352,11 +350,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<SessionCreateResponse>('session.create', {
               cols: 96,
               ...(cwd && { cwd }),
               ...(newChatProfile ? { profile: newChatProfile } : {})
             })
    +
             const stored = created.stored_session_id ?? null
     
             if (
    ```

    ```diff
    @@ -475,8 +475,6 @@ export function useSessionActions({
             setCurrentCwd(cachedState.cwd)
             setCurrentBranch(cachedState.branch)
             setSessionStartedAt(Date.now())
    -        clearComposerDraft()
    -        clearComposerAttachments()
     
             try {
               const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
    ```

    ```diff
    @@ -606,8 +604,6 @@ export function useSessionActions({
               }),
               storedSessionId
             )
    -        clearComposerDraft()
    -        clearComposerAttachments()
           } catch (err) {
             if (!isCurrentResume()) {
               return
    ```

    ```diff
    @@ -730,8 +726,6 @@ export function useSessionActions({
             selectedStoredSessionIdRef.current = routedSessionId
             navigate(sessionRoute(routedSessionId))
     
    -        clearComposerDraft()
    -        clearComposerAttachments()
             const runtimeInfo = applyRuntimeInfo(branched.info)
     
             patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
    ```
  - `apps/desktop/src/store/composer.test.ts`

    ```diff
    @@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'
     import {
       $composerAttachments,
       addComposerAttachment,
    +  clearSessionDraft,
       type ComposerAttachment,
       removeComposerAttachment,
    +  SESSION_DRAFTS_STORAGE_KEY,
    +  stashSessionDraft,
    +  takeSessionDraft,
       updateComposerAttachment
     } from './composer'
     
    ```

    ```diff
    @@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => {
         expect($composerAttachments.get()).toHaveLength(0)
       })
     })
    +
    +describe('session drafts', () => {
    +  afterEach(() => {
    +    for (const scope of ['session-a', 'session-b', null]) {
    +      clearSessionDraft(scope)
    +    }
    +
    +    window.localStorage.clear()
    +  })
    +
    +  it('keeps drafts isolated per session scope', () => {
    +    stashSessionDraft('session-a', 'draft a', [])
    +    stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
    +
    +    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('scopes the unsaved new-session draft separately from real sessions', () => {
    +    stashSessionDraft(null, 'new chat draft', [])
    +    stashSessionDraft('session-a', 'session draft', [])
    +
    +    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('persists draft text (not attachments) to localStorage', () => {
    +    stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
    +
    +    const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<string, string>
    +
    +    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')
    +  })
    +})
    ```
  - `apps/desktop/src/store/composer.ts`

    ```diff
    @@ -21,6 +21,84 @@ export const $composerDraft = atom('')
     export const $composerAttachments = atom<ComposerAttachment[]>([])
     export const $composerTerminalSelections = atom<Record<string, string>>({})
     
    +// 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'
    +
    +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 {
    +    const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
    +
    +    if (!raw) {
    +      return []
    +    }
    +
    +    return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [
    +      key,
    +      { attachments: [], text }
    +    ])
    +  } catch {
    +    return []
    +  }
    +}
    +
    +const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
    +
    +function persistDraftTexts() {
    +  try {
    +    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.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
    +    }
    +  } catch {
    +    // Best-effort only — quota/private-mode must never break typing.
    +  }
    +}
    +
    +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)
     }
    ```
  - `scripts/release.py`

    ```diff
    @@ -76,6 +76,7 @@
         "[redacted:email]": "synapsesx",
         "[redacted:email]": "dirtyren",
         "[redacted:email]": "NormallyGaussian",
    +    "[redacted:email]": "mollusk",
         "[redacted:email]": "temalo",
         "[redacted:email]": "alarcritty",
         "[redacted:email]": "islam666",
    ```
- **#38621: fix(desktop): persist new-session drafts**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `fdc0d1956636d0c90cb53192f8acebf2e5459ee1`, `#38498`
- **#42438: fix(desktop): persist composer draft across non-overlay route changes**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#43959`
- **#43756: fix(desktop): preserve composer draft when navigating to Settings**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#38400`, `#38400`
- **#43833: fix(desktop): keep chat mounted under settings overlay**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#43825`, `#41146`
- **#43939: fix(desktop): persist composer drafts per session (salvage #43660)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#43959`, `#43959`

## Fix status

- Merged to main in PR #43959 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-11`.
- Subsystem: `apps/desktop`
