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.
This commit is contained in:
committed by
Brooklyn Nicholson
parent
acd7932c0f
commit
18d61bd06e
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user