mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
feat(desktop): strict per-thread drafts on decoupled composer
Keyed draft stash (Map + localStorage mirror) behind the live composer: switching threads stashes the departing draft and restores the entering one; empty threads show an empty box. Session lifecycle never clears composer state — the scope swap is the only coupling. Co-authored-by: mollusk <roger@roger.local> Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
This commit is contained in:
parent
292192f7d7
commit
d7d281fa37
4 changed files with 169 additions and 69 deletions
|
|
@ -27,10 +27,10 @@ import { cn } from '@/lib/utils'
|
|||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearPersistedComposerDraft,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
readPersistedComposerDraft,
|
||||
writePersistedComposerDraft
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
|
|
@ -182,7 +182,9 @@ export function ChatBar({
|
|||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<string | null>(null)
|
||||
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)
|
||||
|
||||
|
|
@ -194,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)
|
||||
|
|
@ -1109,47 +1113,59 @@ export function ChatBar({
|
|||
}
|
||||
}
|
||||
|
||||
// The composer sits above the thread and does NOT react to session
|
||||
// switches; the only restore is a one-shot on mount so drafts survive
|
||||
// app reloads.
|
||||
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 persisted = readPersistedComposerDraft()
|
||||
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||
loadIntoComposer(text, attachments)
|
||||
|
||||
if (persisted && !draftRef.current.trim()) {
|
||||
loadIntoComposer(persisted, $composerAttachments.get())
|
||||
return () => {
|
||||
const editing = queueEditRef.current
|
||||
|
||||
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||
} else if (!isBrowsingHistory(sessionId)) {
|
||||
stashAt(activeQueueSessionKey)
|
||||
}
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced draft persistence. Skipped for programmatically-loaded text
|
||||
// (history browsing, queued-prompt edits) so recalled text never clobbers
|
||||
// the genuine in-progress draft.
|
||||
// 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 = draft
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(draft)
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [draft, queueEdit, sessionId])
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// Flush any pending debounced write on unmount or unload. The pagehide
|
||||
// listener is load-bearing: React does NOT run effect cleanups on a page
|
||||
// reload, so without it a Cmd+R inside the debounce window drops the
|
||||
// trailing keystrokes.
|
||||
// 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 !== null) {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(pending)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(pending.scope, pending.text)
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||
|
|
@ -1362,30 +1378,35 @@ 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
|
||||
|
||||
// Submit, restoring the composer (and its persisted draft) if the gateway
|
||||
// rejects or the submit throws — typed text is never lost to a failed send.
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
const submittedScope = activeQueueSessionKeyRef.current
|
||||
const submittedAttachments = attachments ?? []
|
||||
|
||||
const restore = () => {
|
||||
loadIntoComposer(text, attachments ?? [])
|
||||
writePersistedComposerDraft(text)
|
||||
loadIntoComposer(text, submittedAttachments)
|
||||
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||
}
|
||||
|
||||
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||
.then(accepted => void (accepted === false ? restore() : clearPersistedComposerDraft()))
|
||||
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -328,8 +328,7 @@ export function useSessionActions({
|
|||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
// Never clear the composer here: it sits above the thread and its
|
||||
// contents (text + attachments) follow the user across session changes.
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearPersistedComposerDraft,
|
||||
COMPOSER_DRAFT_STORAGE_KEY,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
readPersistedComposerDraft,
|
||||
removeComposerAttachment,
|
||||
updateComposerAttachment,
|
||||
writePersistedComposerDraft
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft,
|
||||
updateComposerAttachment
|
||||
} from './composer'
|
||||
|
||||
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
|
||||
|
|
@ -46,30 +46,61 @@ describe('updateComposerAttachment', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('persisted composer draft', () => {
|
||||
describe('session drafts', () => {
|
||||
afterEach(() => {
|
||||
for (const scope of ['session-a', 'session-b', null]) {
|
||||
clearSessionDraft(scope)
|
||||
}
|
||||
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('stores and restores the draft', () => {
|
||||
writePersistedComposerDraft('almost submitted prompt')
|
||||
it('keeps drafts isolated per session scope', () => {
|
||||
stashSessionDraft('session-a', 'draft a', [])
|
||||
stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
|
||||
|
||||
expect(readPersistedComposerDraft()).toBe('almost submitted prompt')
|
||||
expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('almost submitted prompt')
|
||||
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('removes empty drafts instead of leaving stale text behind', () => {
|
||||
writePersistedComposerDraft('saved')
|
||||
writePersistedComposerDraft('')
|
||||
it('scopes the unsaved new-session draft separately from real sessions', () => {
|
||||
stashSessionDraft(null, 'new chat draft', [])
|
||||
stashSessionDraft('session-a', 'session draft', [])
|
||||
|
||||
expect(readPersistedComposerDraft()).toBe('')
|
||||
expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBeNull()
|
||||
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('can explicitly clear a saved draft after submit', () => {
|
||||
writePersistedComposerDraft('saved')
|
||||
clearPersistedComposerDraft()
|
||||
it('persists draft text (not attachments) to localStorage', () => {
|
||||
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
|
||||
|
||||
expect(readPersistedComposerDraft()).toBe('')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,34 +21,83 @@ export const $composerDraft = atom('')
|
|||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
// The composer is a single global surface that sits ABOVE the thread: its
|
||||
// contents follow the user across session switches and are never touched by
|
||||
// session lifecycle. One storage key makes the draft survive app reloads.
|
||||
export const COMPOSER_DRAFT_STORAGE_KEY = 'hermes:composer-draft:v2'
|
||||
// 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'
|
||||
|
||||
export function readPersistedComposerDraft(): string {
|
||||
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 {
|
||||
return window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? ''
|
||||
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 ''
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Empty drafts remove the key. Persistence is a safety net only — storage
|
||||
// errors (quota, private mode) must never break typing or submission.
|
||||
export function writePersistedComposerDraft(value: string) {
|
||||
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
|
||||
|
||||
function persistDraftTexts() {
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value)
|
||||
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.removeItem(COMPOSER_DRAFT_STORAGE_KEY)
|
||||
window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
// Best-effort only — quota/private-mode must never break typing.
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPersistedComposerDraft = () => writePersistedComposerDraft('')
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue