Composer draft loss and settings navigation bugs

NousResearch/hermes-agent · cluster 41079 · 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
5items grouped
Known?
Yes. 5 related items grouped here, 2 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 #86766. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address bugs where navigating to Settings (via hotkey or button) destroys unsaved composer drafts, including a related issue where Chinese punctuation input triggers unintended settings navigation. Several fixes attempt to preserve draft state across navigation and improve overall composer lifecycle behavior.

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",
  • #86766 fix(desktop): composer behavior rollup — width, collapse, popout, latch, bounded previews, draft preservation (salvages #69509 #68148 #70515 #71102 #68744/#85274 #44622 #53434 #73946)

    merged to main

    files: apps/desktop/src/app/chat/composer/attachments.test.tsx, apps/desktop/src/app/chat/composer/attachments.tsx, apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx, apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts, apps/desktop/src/app/chat/composer/hooks/use-composer-popout.test.tsx, apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts, apps/desktop/src/app/chat/composer/hooks/use-popout-drag.test.tsx, apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts, apps/desktop/src/app/chat/composer/inline-refs.ts, apps/desktop/src/app/chat/composer/status-stack/status-row.tsx, apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts, apps/desktop/src/app/chat/hooks/use-composer-actions.ts, apps/desktop/src/app/chat/index.tsx, apps/desktop/src/app/chat/route-session-state.test.ts, apps/desktop/src/app/chat/route-session-state.ts, apps/desktop/src/app/chat/session-tile-actions.ts, apps/desktop/src/app/chat/session-tile-attachments.test.tsx, apps/desktop/src/app/chat/session-tile.tsx, apps/desktop/src/app/hooks/use-keybinds.ts, apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx (+24 more)

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #41146 fix(desktop): preserve drafts across settings navigation · superseded by another PR → #43959
Closed issues · 2
  • Closed#41079[Bug][Critical] Navigating to Settings destroys unsent draft text in composer (hotkey Ctrl+, / clicking Settings button)
  • Closed#49326[Bug] 中文輸入逗號(,)句號(。)會跳到設定畫面
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.34: 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, #86766
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
15 Aug 2026

For your agent

The same facts, typed.