diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index bf948834837..c18313a7386 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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, + clearSessionDraft, + type ComposerAttachment, + stashSessionDraft, + takeSessionDraft +} from '@/store/composer' import { browseBackward, browseForward, @@ -130,6 +137,10 @@ interface QueueEditState { const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) +// Quiet period after the last keystroke before persisting the draft; +// unmount/pagehide flushes bypass it. +const DRAFT_PERSIST_DEBOUNCE_MS = 400 + export function ChatBar({ busy, cwd, @@ -171,6 +182,9 @@ export function ChatBar({ const editorRef = useRef(null) const draftRef = useRef(draft) const previousBusyRef = useRef(busy) + const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) + const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) + activeQueueSessionKeyRef.current = activeQueueSessionKey const drainingQueueRef = useRef(false) const urlInputRef = useRef(null) @@ -182,6 +196,8 @@ export function ChatBar({ const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(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(null) @@ -1097,6 +1113,69 @@ export function ChatBar({ } } + 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 { attachments, text } = takeSessionDraft(activeQueueSessionKey) + loadIntoComposer(text, attachments) + + return () => { + const editing = queueEditRef.current + + if (editing?.sessionKey === activeQueueSessionKey) { + stashAt(activeQueueSessionKey, editing.draft, editing.attachments) + } else if (!isBrowsingHistory(sessionId)) { + stashAt(activeQueueSessionKey) + } + } + }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps + + // 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 = { scope: activeQueueSessionKey, text: draft } + + const handle = window.setTimeout(() => { + pendingDraftPersistRef.current = null + stashAt(activeQueueSessionKey, draft) + }, DRAFT_PERSIST_DEBOUNCE_MS) + + return () => window.clearTimeout(handle) + }, [activeQueueSessionKey, draft, queueEdit, sessionId]) + + // 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) { + return + } + + pendingDraftPersistRef.current = null + stashAt(pending.scope, pending.text) + } + + window.addEventListener('pagehide', flushPendingDraftPersist) + + return () => { + window.removeEventListener('pagehide', flushPendingDraftPersist) + flushPendingDraftPersist() + } + }, []) + const beginQueuedEdit = (entry: QueuedPromptEntry) => { if (!activeQueueSessionKey || queueEdit) { return @@ -1299,20 +1378,38 @@ 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 + const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const submittedScope = activeQueueSessionKeyRef.current + const submittedAttachments = attachments ?? [] + + const restore = () => { + loadIntoComposer(text, submittedAttachments) + stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + } + + void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) + .catch(restore) + } + const submitDraft = () => { // Source the text from the DOM editor, not React state. The AUI composer // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a @@ -1323,8 +1420,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) @@ -1345,10 +1444,9 @@ export function ChatBar({ // /send directives). Queuing them would make every slash command wait // for the current turn to finish, which is how the TUI never behaves. if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { - const submitted = text triggerHaptic('submit') clearDraft() - void onSubmit(submitted) + dispatchSubmit(text) } else if (payloadPresent) { queueCurrentDraft() } else { @@ -1360,12 +1458,12 @@ export function ChatBar({ } else if (!payloadPresent && queuedPrompts.length > 0) { void drainNextQueued() } else if (payloadPresent) { - const submitted = text + const submittedAttachments = cloneAttachments(attachments) triggerHaptic('submit') resetBrowseState(sessionId) clearDraft() clearComposerAttachments() - void onSubmit(submitted, { attachments }) + dispatchSubmit(text, submittedAttachments) } focusInput() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 51ee90924ae..5077226aade 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' import { setSessionYolo } from '@/lib/yolo-session' -import { clearComposerAttachments, clearComposerDraft } from '@/store/composer' import { clearQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' @@ -19,7 +18,6 @@ import { $messages, $sessions, $yoloActive, - workspaceCwdForNewSession, sessionPinId, setActiveSessionId, setAwaitingResponse, @@ -41,7 +39,8 @@ import { setSessionStartedAt, setSessionsTotal, setTurnStartedAt, - setYoloActive + setYoloActive, + workspaceCwdForNewSession } from '@/store/session' import { reportBackendContract } from '@/store/updates' import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes' @@ -329,8 +328,7 @@ export function useSessionActions({ setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') - clearComposerDraft() - clearComposerAttachments() + // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) }, [activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef] @@ -352,11 +350,13 @@ export function useSessionActions({ // Pass the owning profile so a new chat under a non-launch profile (global // remote mode) builds its agent + persists against THAT profile's home/db. const newChatProfile = $newChatProfile.get() + const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }), ...(newChatProfile ? { profile: newChatProfile } : {}) }) + const stored = created.stored_session_id ?? null if ( @@ -475,8 +475,6 @@ export function useSessionActions({ setCurrentCwd(cachedState.cwd) setCurrentBranch(cachedState.branch) setSessionStartedAt(Date.now()) - clearComposerDraft() - clearComposerAttachments() try { const usage = await requestGateway('session.usage', { session_id: cachedRuntimeId }) @@ -606,8 +604,6 @@ export function useSessionActions({ }), storedSessionId ) - clearComposerDraft() - clearComposerAttachments() } catch (err) { if (!isCurrentResume()) { return @@ -730,8 +726,6 @@ export function useSessionActions({ selectedStoredSessionIdRef.current = routedSessionId navigate(sessionRoute(routedSessionId)) - clearComposerDraft() - clearComposerAttachments() const runtimeInfo = applyRuntimeInfo(branched.info) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index 83f0a3feb96..08bbb391c95 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { $composerAttachments, addComposerAttachment, + clearSessionDraft, type ComposerAttachment, removeComposerAttachment, + SESSION_DRAFTS_STORAGE_KEY, + stashSessionDraft, + takeSessionDraft, updateComposerAttachment } from './composer' @@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => { expect($composerAttachments.get()).toHaveLength(0) }) }) + +describe('session drafts', () => { + afterEach(() => { + for (const scope of ['session-a', 'session-b', null]) { + clearSessionDraft(scope) + } + + window.localStorage.clear() + }) + + it('keeps drafts isolated per session scope', () => { + stashSessionDraft('session-a', 'draft a', []) + stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })]) + + 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('scopes the unsaved new-session draft separately from real sessions', () => { + stashSessionDraft(null, 'new chat draft', []) + stashSessionDraft('session-a', 'session draft', []) + + 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('persists draft text (not attachments) to localStorage', () => { + stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })]) + + const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record + + 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') + }) +}) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 6b2b58ccb8d..c40cf867735 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,84 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +// 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' + +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 { + const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) + + if (!raw) { + return [] + } + + return Object.entries(JSON.parse(raw) as Record).map(([key, text]) => [ + key, + { attachments: [], text } + ]) + } catch { + return [] + } +} + +const draftsBySession = new Map(loadPersistedDraftTexts()) + +function persistDraftTexts() { + try { + 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.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))) + } + } catch { + // Best-effort only — quota/private-mode must never break typing. + } +} + +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) } diff --git a/scripts/release.py b/scripts/release.py index 40bc154d0fb..16b8bd7cdb8 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -76,6 +76,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", "mharris@parallel.ai": "NormallyGaussian", + "roger@roger.local": "mollusk", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666",