mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-19 08:30:48 +08:00
Compare commits
4 Commits
austin/fix
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d59d6991b | ||
|
|
dea2d43b83 | ||
|
|
e419a360ae | ||
|
|
526991b13e |
@@ -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,
|
||||
clearPersistedComposerDraft,
|
||||
type ComposerAttachment,
|
||||
readPersistedComposerDraft,
|
||||
writePersistedComposerDraft
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
@@ -130,6 +137,10 @@ interface QueueEditState {
|
||||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// How long the composer waits after the last keystroke before persisting the
|
||||
// draft to localStorage. Scope-change/unmount flushes bypass the delay.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -160,6 +171,7 @@ export function ChatBar({
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
const draftPersistenceScope = activeQueueSessionKey || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
|
||||
@@ -171,6 +183,8 @@ export function ChatBar({
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const skipNextDraftPersistScopeRef = useRef<string | null>(null)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null)
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -1097,6 +1111,48 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = readPersistedComposerDraft(draftPersistenceScope)
|
||||
skipNextDraftPersistScopeRef.current = draftPersistenceScope
|
||||
loadIntoComposer(persisted, [])
|
||||
}, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) {
|
||||
skipNextDraftPersistScopeRef.current = null
|
||||
|
||||
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
|
||||
// on scope change / unmount so a fast session switch can't drop the
|
||||
// trailing keystrokes.
|
||||
pendingDraftPersistRef.current = { scope: draftPersistenceScope, value: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(draftPersistenceScope, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [draft, draftPersistenceScope])
|
||||
|
||||
// Flush any pending debounced draft write when leaving a session scope or
|
||||
// unmounting, so the departing session's latest text is always persisted.
|
||||
useEffect(
|
||||
() => () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (pending) {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(pending.scope, pending.value)
|
||||
}
|
||||
},
|
||||
[draftPersistenceScope]
|
||||
)
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
@@ -1323,8 +1379,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)
|
||||
@@ -1348,7 +1406,17 @@ export function ChatBar({
|
||||
const submitted = text
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
void Promise.resolve(onSubmit(submitted)).then(accepted => {
|
||||
if (accepted === false) {
|
||||
loadIntoComposer(submitted, [])
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, [])
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
})
|
||||
} else if (payloadPresent) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
@@ -1361,11 +1429,22 @@ export function ChatBar({
|
||||
void drainNextQueued()
|
||||
} else if (payloadPresent) {
|
||||
const submitted = text
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => {
|
||||
if (accepted === false) {
|
||||
loadIntoComposer(submitted, submittedAttachments)
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, submittedAttachments)
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
})
|
||||
}
|
||||
|
||||
focusInput()
|
||||
|
||||
@@ -3,9 +3,13 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearPersistedComposerDraft,
|
||||
type ComposerAttachment,
|
||||
composerDraftStorageKey,
|
||||
readPersistedComposerDraft,
|
||||
removeComposerAttachment,
|
||||
updateComposerAttachment
|
||||
updateComposerAttachment,
|
||||
writePersistedComposerDraft
|
||||
} from './composer'
|
||||
|
||||
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'id'>): ComposerAttachment {
|
||||
@@ -41,3 +45,39 @@ describe('updateComposerAttachment', () => {
|
||||
expect($composerAttachments.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persisted composer drafts', () => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('stores and restores text drafts per session scope', () => {
|
||||
writePersistedComposerDraft('session-a', 'almost submitted prompt')
|
||||
writePersistedComposerDraft('session-b', 'other draft')
|
||||
|
||||
expect(readPersistedComposerDraft('session-a')).toBe('almost submitted prompt')
|
||||
expect(readPersistedComposerDraft('session-b')).toBe('other draft')
|
||||
})
|
||||
|
||||
it('uses a stable new-session key when no session id exists yet', () => {
|
||||
writePersistedComposerDraft(null, 'first prompt draft')
|
||||
|
||||
expect(window.localStorage.getItem(composerDraftStorageKey(null))).toBe('first prompt draft')
|
||||
expect(readPersistedComposerDraft(undefined)).toBe('first prompt draft')
|
||||
})
|
||||
|
||||
it('removes empty drafts instead of leaving stale text behind', () => {
|
||||
writePersistedComposerDraft('session-a', 'saved')
|
||||
writePersistedComposerDraft('session-a', '')
|
||||
|
||||
expect(readPersistedComposerDraft('session-a')).toBe('')
|
||||
expect(window.localStorage.getItem(composerDraftStorageKey('session-a'))).toBeNull()
|
||||
})
|
||||
|
||||
it('can explicitly clear a saved draft after submit', () => {
|
||||
writePersistedComposerDraft('session-a', 'saved')
|
||||
clearPersistedComposerDraft('session-a')
|
||||
|
||||
expect(readPersistedComposerDraft('session-a')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,68 @@ export const $composerDraft = atom('')
|
||||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
const COMPOSER_DRAFT_STORAGE_PREFIX = 'hermes:composer-draft:v1:'
|
||||
const NEW_SESSION_DRAFT_SCOPE = '__new__'
|
||||
|
||||
function storageScope(scope: string | null | undefined): string {
|
||||
const trimmed = scope?.trim()
|
||||
|
||||
return trimmed || NEW_SESSION_DRAFT_SCOPE
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function composerDraftStorageKey(scope: string | null | undefined): string {
|
||||
return `${COMPOSER_DRAFT_STORAGE_PREFIX}${encodeURIComponent(storageScope(scope))}`
|
||||
}
|
||||
|
||||
export function readPersistedComposerDraft(scope: string | null | undefined): string {
|
||||
try {
|
||||
return browserStorage()?.getItem(composerDraftStorageKey(scope)) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function writePersistedComposerDraft(scope: string | null | undefined, value: string) {
|
||||
try {
|
||||
const storage = browserStorage()
|
||||
|
||||
if (!storage) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = composerDraftStorageKey(scope)
|
||||
|
||||
if (value.length === 0) {
|
||||
storage.removeItem(key)
|
||||
} else {
|
||||
storage.setItem(key, value)
|
||||
}
|
||||
} catch {
|
||||
// Draft persistence is a safety net only; storage quota/private-mode errors
|
||||
// must never break typing or submission.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPersistedComposerDraft(scope: string | null | undefined) {
|
||||
try {
|
||||
browserStorage()?.removeItem(composerDraftStorageKey(scope))
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ AUTHOR_MAP = {
|
||||
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"roger@roger.local": "mollusk",
|
||||
"mharris@parallel.ai": "NormallyGaussian",
|
||||
"ted.malone@outlook.com": "temalo",
|
||||
"adityamalik2833@gmail.com": "alarcritty",
|
||||
|
||||
Reference in New Issue
Block a user