Desktop composer draft lost when navigating away

NousResearch/hermes-agent · cluster 38498 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #43959. Check it covers your case.

The short answer

0open issues
0open PRs
10items grouped
Known?
Yes. 10 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #43959. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #43959. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #43959 fix(desktop): per-thread composer drafts on decoupled lifecycle (salvage #43660, supersedes #43939)

    merged to main

    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

    Show the diff
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -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,
      @@ -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,
      @@ -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)
       
      @@ -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)
      @@ -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
      @@ -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
      @@ -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)
      @@ -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 {
      @@ -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
      @@ -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'
      @@ -19,7 +18,6 @@ import {
         $messages,
         $sessions,
         $yoloActive,
      -  workspaceCwdForNewSession,
         sessionPinId,
         setActiveSessionId,
         setAwaitingResponse,
      @@ -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'
      @@ -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]
      @@ -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 (
      @@ -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 })
      @@ -606,8 +604,6 @@ export function useSessionActions({
                 }),
                 storedSessionId
               )
      -        clearComposerDraft()
      -        clearComposerAttachments()
             } catch (err) {
               if (!isCurrentResume()) {
                 return
      @@ -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
      @@ -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'
       
      @@ -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
      @@ -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
      @@ -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",

5 PRs closed without merging

From the close comments: superseded by another PR 2 · done elsewhere 1 · merged via another change 1 · other 1

  • #38621 fix(desktop): persist new-session drafts · done elsewhere → fdc0d19, #38498
  • #42438 fix(desktop): persist composer draft across non-overlay route changes · superseded by another PR → #43959
  • #43756 fix(desktop): preserve composer draft when navigating to Settings · superseded by another PR → #38400
  • #43833 fix(desktop): keep chat mounted under settings overlay · other → #43825, #41146
  • #43939 fix(desktop): persist composer drafts per session (salvage #43660) · merged via another change → #43959
Closed issues · 4
  • Closed#38498Desktop: persist draft prompt text when navigating away from a new session
  • Closed#42401[Bug]: Prompts being discarded/disappearing when opening a new screen on the desktop app
  • Closed#43737Hermes Desktop: Prompt text lost when navigating to Settings before submit
  • Closed#43825[Bug]: In Hermes Desktop, opening the Settings windows clears the text in the prompt window.
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#43959
Closed issues
4
Attempts
5 PRs closed without merging
Fix link checked
11 Jun 2026

For your agent

The same facts, typed.