mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
fix(desktop): retain composer attachments per session scope + guard programmatic drafts
The salvaged draft persistence scoped text per session but reset the composer's attachments to [] on every scope change, so a staged image or file was silently dropped when you switched sessions and never restored on return — inconsistent with the "drafts survive session switches" promise and a real paper-cut given remote staging cost. Retain attachments per scope in an in-memory map (keyed by the same scope as the text draft) since blobs / object URLs / live upload state can't be serialized to localStorage. Entering a scope restores its stashed chips; leaving stashes the current ones; an accepted submit clears the scope. This survives session switches (the case users hit) without pretending to survive a full reload, which attachments fundamentally can't. Also guard the debounced text write so browsing sent-message history or editing a queued prompt (both swap the composer to recalled text via loadIntoComposer) no longer clobbers the genuine in-progress draft in storage. Co-authored-by: mollusk <roger@roger.local> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
parent
3d14f01fd6
commit
65ddc7c4a1
3 changed files with 106 additions and 2 deletions
|
|
@ -28,8 +28,11 @@ import {
|
|||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearPersistedComposerDraft,
|
||||
clearStashedComposerAttachments,
|
||||
type ComposerAttachment,
|
||||
readPersistedComposerDraft,
|
||||
stashComposerAttachments,
|
||||
takeComposerAttachments,
|
||||
writePersistedComposerDraft
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
|
|
@ -1111,10 +1114,20 @@ export function ChatBar({
|
|||
}
|
||||
}
|
||||
|
||||
// Restore a scope's draft (persisted text + in-memory attachments) when we
|
||||
// enter it, and stash the attachments back when we leave. Text rides through
|
||||
// localStorage so it survives reloads; attachments carry live blobs/upload
|
||||
// state that can't serialize, so they're retained in memory only — enough to
|
||||
// survive a session switch, which is the case users actually hit.
|
||||
useEffect(() => {
|
||||
const persisted = readPersistedComposerDraft(draftPersistenceScope)
|
||||
const restoredAttachments = takeComposerAttachments(draftPersistenceScope)
|
||||
skipNextDraftPersistScopeRef.current = draftPersistenceScope
|
||||
loadIntoComposer(persisted, [])
|
||||
loadIntoComposer(persisted, restoredAttachments)
|
||||
|
||||
return () => {
|
||||
stashComposerAttachments(draftPersistenceScope, $composerAttachments.get())
|
||||
}
|
||||
}, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1124,6 +1137,15 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// 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). Leaving
|
||||
// the prior pending write untouched keeps the real draft in storage.
|
||||
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. The pending ref below is flushed
|
||||
|
|
@ -1137,7 +1159,7 @@ export function ChatBar({
|
|||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [draft, draftPersistenceScope])
|
||||
}, [draft, draftPersistenceScope, queueEdit, sessionId])
|
||||
|
||||
// Flush any pending debounced draft write when leaving a session scope or
|
||||
// unmounting, so the departing session's latest text is always persisted.
|
||||
|
|
@ -1412,6 +1434,7 @@ export function ChatBar({
|
|||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
clearStashedComposerAttachments(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, [])
|
||||
|
|
@ -1440,6 +1463,7 @@ export function ChatBar({
|
|||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
clearStashedComposerAttachments(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, submittedAttachments)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ import {
|
|||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearPersistedComposerDraft,
|
||||
clearStashedComposerAttachments,
|
||||
type ComposerAttachment,
|
||||
composerDraftStorageKey,
|
||||
readPersistedComposerDraft,
|
||||
removeComposerAttachment,
|
||||
stashComposerAttachments,
|
||||
takeComposerAttachments,
|
||||
updateComposerAttachment,
|
||||
writePersistedComposerDraft
|
||||
} from './composer'
|
||||
|
|
@ -81,3 +84,48 @@ describe('persisted composer drafts', () => {
|
|||
expect(readPersistedComposerDraft('session-a')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stashed composer attachments', () => {
|
||||
afterEach(() => {
|
||||
clearStashedComposerAttachments('session-a')
|
||||
clearStashedComposerAttachments('session-b')
|
||||
clearStashedComposerAttachments(null)
|
||||
})
|
||||
|
||||
it('retains and restores attachments per session scope', () => {
|
||||
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
|
||||
stashComposerAttachments('session-b', [attachment({ id: 'image:b', kind: 'image' })])
|
||||
|
||||
expect(takeComposerAttachments('session-a').map(a => a.id)).toEqual(['file:a'])
|
||||
expect(takeComposerAttachments('session-b').map(a => a.id)).toEqual(['image:b'])
|
||||
})
|
||||
|
||||
it('shares a stable new-session scope with the text draft helpers', () => {
|
||||
stashComposerAttachments(null, [attachment({ id: 'file:new' })])
|
||||
|
||||
expect(takeComposerAttachments(undefined).map(a => a.id)).toEqual(['file:new'])
|
||||
})
|
||||
|
||||
it('returns cloned attachments so callers cannot mutate the stash', () => {
|
||||
stashComposerAttachments('session-a', [attachment({ id: 'file:a', label: 'orig.pdf' })])
|
||||
|
||||
const taken = takeComposerAttachments('session-a')
|
||||
taken[0]!.label = 'mutated.pdf'
|
||||
|
||||
expect(takeComposerAttachments('session-a')[0]?.label).toBe('orig.pdf')
|
||||
})
|
||||
|
||||
it('drops the scope entry when stashing an empty set', () => {
|
||||
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
|
||||
stashComposerAttachments('session-a', [])
|
||||
|
||||
expect(takeComposerAttachments('session-a')).toEqual([])
|
||||
})
|
||||
|
||||
it('clears a scope explicitly after an accepted submit', () => {
|
||||
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
|
||||
clearStashedComposerAttachments('session-a')
|
||||
|
||||
expect(takeComposerAttachments('session-a')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -83,6 +83,38 @@ export function clearPersistedComposerDraft(scope: string | null | undefined) {
|
|||
}
|
||||
}
|
||||
|
||||
// Attachments can't ride along in localStorage the way text does — they carry
|
||||
// live blobs, object URLs, and in-flight upload state that don't serialize and
|
||||
// are tied to the running app. So we retain them per scope in an in-memory map
|
||||
// instead: a session switch restores the chips you'd staged, even though they
|
||||
// (unlike text) cannot survive a full app reload.
|
||||
const composerAttachmentsByScope = new Map<string, ComposerAttachment[]>()
|
||||
|
||||
const cloneComposerAttachments = (attachments: ComposerAttachment[]): ComposerAttachment[] =>
|
||||
attachments.map(attachment => ({ ...attachment }))
|
||||
|
||||
export function stashComposerAttachments(scope: string | null | undefined, attachments: ComposerAttachment[]) {
|
||||
const key = storageScope(scope)
|
||||
|
||||
if (attachments.length === 0) {
|
||||
composerAttachmentsByScope.delete(key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
composerAttachmentsByScope.set(key, cloneComposerAttachments(attachments))
|
||||
}
|
||||
|
||||
export function takeComposerAttachments(scope: string | null | undefined): ComposerAttachment[] {
|
||||
const stashed = composerAttachmentsByScope.get(storageScope(scope))
|
||||
|
||||
return stashed ? cloneComposerAttachments(stashed) : []
|
||||
}
|
||||
|
||||
export function clearStashedComposerAttachments(scope: string | null | undefined) {
|
||||
composerAttachmentsByScope.delete(storageScope(scope))
|
||||
}
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue