refactor(desktop): decouple composer from session lifecycle entirely

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. Switching threads doesn't change the render.

Replaces the per-scope draft choreography (scoped storage keys, attachment
stash map, skip-sentinel, restore-on-scope-change effect) with:
- one global localStorage key so an unsent draft survives app reloads
- a one-shot restore on mount
- nothing else — session switches simply don't touch the composer

Verified E2E via CDP with real sidebar clicks + real keystrokes:
typed draft survives A->B->A switching and a full page reload.
This commit is contained in:
Brooklyn Nicholson
2026-06-10 23:39:35 -05:00
parent fdc0d19566
commit c710868fbc
3 changed files with 50 additions and 174 deletions
+13 -69
View File
@@ -4,13 +4,10 @@ import {
$composerAttachments,
addComposerAttachment,
clearPersistedComposerDraft,
clearStashedComposerAttachments,
COMPOSER_DRAFT_STORAGE_KEY,
type ComposerAttachment,
composerDraftStorageKey,
readPersistedComposerDraft,
removeComposerAttachment,
stashComposerAttachments,
takeComposerAttachments,
updateComposerAttachment,
writePersistedComposerDraft
} from './composer'
@@ -49,83 +46,30 @@ describe('updateComposerAttachment', () => {
})
})
describe('persisted composer drafts', () => {
describe('persisted composer draft', () => {
afterEach(() => {
window.localStorage.clear()
})
it('stores and restores text drafts per session scope', () => {
writePersistedComposerDraft('session-a', 'almost submitted prompt')
writePersistedComposerDraft('session-b', 'other draft')
it('stores and restores the draft', () => {
writePersistedComposerDraft('almost submitted prompt')
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')
expect(readPersistedComposerDraft()).toBe('almost submitted prompt')
expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBe('almost submitted prompt')
})
it('removes empty drafts instead of leaving stale text behind', () => {
writePersistedComposerDraft('session-a', 'saved')
writePersistedComposerDraft('session-a', '')
writePersistedComposerDraft('saved')
writePersistedComposerDraft('')
expect(readPersistedComposerDraft('session-a')).toBe('')
expect(window.localStorage.getItem(composerDraftStorageKey('session-a'))).toBeNull()
expect(readPersistedComposerDraft()).toBe('')
expect(window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY)).toBeNull()
})
it('can explicitly clear a saved draft after submit', () => {
writePersistedComposerDraft('session-a', 'saved')
clearPersistedComposerDraft('session-a')
writePersistedComposerDraft('saved')
clearPersistedComposerDraft()
expect(readPersistedComposerDraft('session-a')).toBe('')
})
})
describe('stashed composer attachments', () => {
afterEach(() => {
clearStashedComposerAttachments('session-a')
clearStashedComposerAttachments('session-b')
clearStashedComposerAttachments(null)
})
it('retains and restores attachments per session scope', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
stashComposerAttachments('session-b', [attachment({ id: 'image:b', kind: 'image' })])
expect(takeComposerAttachments('session-a').map(a => a.id)).toEqual(['file:a'])
expect(takeComposerAttachments('session-b').map(a => a.id)).toEqual(['image:b'])
})
it('shares a stable new-session scope with the text draft helpers', () => {
stashComposerAttachments(null, [attachment({ id: 'file:new' })])
expect(takeComposerAttachments(undefined).map(a => a.id)).toEqual(['file:new'])
})
it('returns cloned attachments so callers cannot mutate the stash', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a', label: 'orig.pdf' })])
const taken = takeComposerAttachments('session-a')
taken[0]!.label = 'mutated.pdf'
expect(takeComposerAttachments('session-a')[0]?.label).toBe('orig.pdf')
})
it('drops the scope entry when stashing an empty set', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
stashComposerAttachments('session-a', [])
expect(takeComposerAttachments('session-a')).toEqual([])
})
it('clears a scope explicitly after an accepted submit', () => {
stashComposerAttachments('session-a', [attachment({ id: 'file:a' })])
clearStashedComposerAttachments('session-a')
expect(takeComposerAttachments('session-a')).toEqual([])
expect(readPersistedComposerDraft()).toBe('')
})
})
+11 -53
View File
@@ -21,14 +21,10 @@ 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
}
// 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'
function browserStorage(): Storage | null {
if (typeof window === 'undefined') {
@@ -42,19 +38,15 @@ function browserStorage(): Storage | 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 {
export function readPersistedComposerDraft(): string {
try {
return browserStorage()?.getItem(composerDraftStorageKey(scope)) ?? ''
return browserStorage()?.getItem(COMPOSER_DRAFT_STORAGE_KEY) ?? ''
} catch {
return ''
}
}
export function writePersistedComposerDraft(scope: string | null | undefined, value: string) {
export function writePersistedComposerDraft(value: string) {
try {
const storage = browserStorage()
@@ -62,12 +54,10 @@ export function writePersistedComposerDraft(scope: string | null | undefined, va
return
}
const key = composerDraftStorageKey(scope)
if (value.length === 0) {
storage.removeItem(key)
storage.removeItem(COMPOSER_DRAFT_STORAGE_KEY)
} else {
storage.setItem(key, value)
storage.setItem(COMPOSER_DRAFT_STORAGE_KEY, value)
}
} catch {
// Draft persistence is a safety net only; storage quota/private-mode errors
@@ -75,46 +65,14 @@ export function writePersistedComposerDraft(scope: string | null | undefined, va
}
}
export function clearPersistedComposerDraft(scope: string | null | undefined) {
export function clearPersistedComposerDraft() {
try {
browserStorage()?.removeItem(composerDraftStorageKey(scope))
browserStorage()?.removeItem(COMPOSER_DRAFT_STORAGE_KEY)
} catch {
// Best-effort only.
}
}
// Attachments can't ride along in localStorage the way text does — they carry
// live blobs, object URLs, and in-flight upload state that don't serialize and
// are tied to the running app. So we retain them per scope in an in-memory map
// instead: a session switch restores the chips you'd staged, even though they
// (unlike text) cannot survive a full app reload.
const composerAttachmentsByScope = new Map<string, ComposerAttachment[]>()
const cloneComposerAttachments = (attachments: ComposerAttachment[]): ComposerAttachment[] =>
attachments.map(attachment => ({ ...attachment }))
export function stashComposerAttachments(scope: string | null | undefined, attachments: ComposerAttachment[]) {
const key = storageScope(scope)
if (attachments.length === 0) {
composerAttachmentsByScope.delete(key)
return
}
composerAttachmentsByScope.set(key, cloneComposerAttachments(attachments))
}
export function takeComposerAttachments(scope: string | null | undefined): ComposerAttachment[] {
const stashed = composerAttachmentsByScope.get(storageScope(scope))
return stashed ? cloneComposerAttachments(stashed) : []
}
export function clearStashedComposerAttachments(scope: string | null | undefined) {
composerAttachmentsByScope.delete(storageScope(scope))
}
export function setComposerDraft(value: string) {
$composerDraft.set(value)
}