mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Merge pull request #43959 from NousResearch/hermes/salvage-composer-drafts
fix(desktop): per-thread composer drafts on decoupled lifecycle (salvage #43660, supersedes #43939)
This commit is contained in:
commit
4d22b82933
5 changed files with 254 additions and 20 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ AUTHOR_MAP = {
|
|||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"mharris@parallel.ai": "NormallyGaussian",
|
||||
"roger@roger.local": "mollusk",
|
||||
"ted.malone@outlook.com": "temalo",
|
||||
"adityamalik2833@gmail.com": "alarcritty",
|
||||
"islam666@users.noreply.github.com": "islam666",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue