Compare commits

...

4 Commits

Author SHA1 Message Date
Teknium
9d59d6991b chore: retrigger PR sync 2026-06-10 21:13:55 -07:00
Teknium
dea2d43b83 Merge remote-tracking branch 'origin/main' into hermes/hermes-4e3ec235
# Conflicts:
#	scripts/release.py
2026-06-10 21:04:03 -07:00
Teknium
e419a360ae fix(desktop): debounce per-keystroke draft persistence writes
The salvaged draft-persistence effect wrote to localStorage on every
keystroke — the composer's per-keystroke path was deliberately slimmed
down previously, so debounce the write (400ms) and flush pending text on
scope change/unmount so a fast session switch can't drop trailing
keystrokes. Also add AUTHOR_MAP entry for the salvaged commit.
2026-06-10 19:57:09 -07:00
Roger
526991b13e fix(desktop): persist composer drafts across reloads
Save in-progress composer text to browser localStorage per chat session and restore it when the desktop composer remounts. Keep the draft when submit is rejected or throws, and clear it only after the prompt is accepted.
2026-06-10 19:53:57 -07:00
4 changed files with 186 additions and 4 deletions

View File

@@ -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()

View File

@@ -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('')
})
})

View File

@@ -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)
}

View File

@@ -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",