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
parent 292192f7d7
commit d7d281fa37
4 changed files with 169 additions and 69 deletions

View file

@ -27,10 +27,10 @@ import { cn } from '@/lib/utils'
import {
$composerAttachments,
clearComposerAttachments,
clearPersistedComposerDraft,
clearSessionDraft,
type ComposerAttachment,
readPersistedComposerDraft,
writePersistedComposerDraft
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import {
browseBackward,
@ -182,7 +182,9 @@ export function ChatBar({
const editorRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const pendingDraftPersistRef = useRef<string | null>(null)
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)
@ -194,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)
@ -1109,47 +1113,59 @@ export function ChatBar({
}
}
// The composer sits above the thread and does NOT react to session
// switches; the only restore is a one-shot on mount so drafts survive
// app reloads.
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 persisted = readPersistedComposerDraft()
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
loadIntoComposer(text, attachments)
if (persisted && !draftRef.current.trim()) {
loadIntoComposer(persisted, $composerAttachments.get())
return () => {
const editing = queueEditRef.current
if (editing?.sessionKey === activeQueueSessionKey) {
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
} else if (!isBrowsingHistory(sessionId)) {
stashAt(activeQueueSessionKey)
}
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// Debounced draft persistence. Skipped for programmatically-loaded text
// (history browsing, queued-prompt edits) so recalled text never clobbers
// the genuine in-progress draft.
// 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 = draft
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
const handle = window.setTimeout(() => {
pendingDraftPersistRef.current = null
writePersistedComposerDraft(draft)
stashAt(activeQueueSessionKey, draft)
}, DRAFT_PERSIST_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [draft, queueEdit, sessionId])
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
// Flush any pending debounced write on unmount or unload. The pagehide
// listener is load-bearing: React does NOT run effect cleanups on a page
// reload, so without it a Cmd+R inside the debounce window drops the
// trailing keystrokes.
// 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 !== null) {
pendingDraftPersistRef.current = null
writePersistedComposerDraft(pending)
if (!pending) {
return
}
pendingDraftPersistRef.current = null
stashAt(pending.scope, pending.text)
}
window.addEventListener('pagehide', flushPendingDraftPersist)
@ -1362,30 +1378,35 @@ 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
// Submit, restoring the composer (and its persisted draft) if the gateway
// rejects or the submit throws — typed text is never lost to a failed send.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
const submittedScope = activeQueueSessionKeyRef.current
const submittedAttachments = attachments ?? []
const restore = () => {
loadIntoComposer(text, attachments ?? [])
writePersistedComposerDraft(text)
loadIntoComposer(text, submittedAttachments)
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
}
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
.then(accepted => void (accepted === false ? restore() : clearPersistedComposerDraft()))
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}

View file

@ -328,8 +328,7 @@ export function useSessionActions({
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
// Never clear the composer here: it sits above the thread and its
// contents (text + attachments) follow the user across session changes.
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]

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

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)