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:
Brooklyn Nicholson
2026-06-11 00:01:06 -05:00
co-authored by mollusk Teknium
parent 292192f7d7
commit d7d281fa37
4 changed files with 169 additions and 69 deletions
+50 -19
View File
@@ -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')
})
})
+64 -15
View File
@@ -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)