From 4079e483a4de59fd033d1f4b19f60bb28384b53e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 21 Apr 2026 15:29:15 -0600 Subject: [PATCH] fix(desktop): persist chat drafts across section navigation (#381) Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/features/messages/lib/useDrafts.ts | 31 +++++++++++----- .../features/messages/ui/MessageComposer.tsx | 36 ++++++++++--------- .../messages/ui/MessageThreadPanel.tsx | 1 + 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 04d6ec02b..7846a2523 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -8,31 +8,46 @@ export type DraftState = { selectionEnd: number; }; -export function useDrafts() { - const draftsRef = React.useRef(new Map()); +const sharedDrafts = new Map(); +export function useDrafts() { const saveDraft = React.useCallback( (channelId: string, draft: DraftState) => { if (draft.content.trim().length === 0) { return; } - const drafts = draftsRef.current; - drafts.set(channelId, draft); - trimMapToSize(drafts, 50); + sharedDrafts.set(channelId, draft); + trimMapToSize(sharedDrafts, 50); }, [], ); const loadDraft = React.useCallback( (channelId: string): DraftState | undefined => { - return draftsRef.current.get(channelId); + return sharedDrafts.get(channelId); }, [], ); const clearDraft = React.useCallback((channelId: string) => { - draftsRef.current.delete(channelId); + sharedDrafts.delete(channelId); }, []); - return { saveDraft, loadDraft, clearDraft }; + /** Save draft if content is non-empty, otherwise clear it. */ + const persistDraft = React.useCallback( + (channelId: string, content: string) => { + if (content.trim().length > 0) { + saveDraft(channelId, { + content, + selectionEnd: content.length, + selectionStart: content.length, + }); + } else { + clearDraft(channelId); + } + }, + [saveDraft, clearDraft], + ); + + return { saveDraft, loadDraft, clearDraft, persistDraft }; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index c96ac5122..06bad1f23 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -30,6 +30,7 @@ type MessageComposerProps = { channelId?: string | null; channelName: string; disabled?: boolean; + draftKey?: string; editTarget?: { author: string; body: string; @@ -58,6 +59,7 @@ export function MessageComposer({ channelId = null, channelName, disabled = false, + draftKey, editTarget = null, isSending = false, onCancelEdit, @@ -83,7 +85,8 @@ export function MessageComposer({ }, []); const drafts = useDrafts(); - const previousChannelIdRef = React.useRef(null); + const effectiveDraftKey = draftKey ?? channelId; + const previousDraftKeyRef = React.useRef(null); const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -151,24 +154,17 @@ export function MessageComposer({ }); // ── Channel switching: save/restore drafts ────────────────────────── - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { - const prevId = previousChannelIdRef.current; - if (prevId) { - const currentContent = contentRef.current; - if (currentContent.trim().length > 0) { - drafts.saveDraft(prevId, { - content: currentContent, - selectionEnd: currentContent.length, - selectionStart: currentContent.length, - }); - } else { - drafts.clearDraft(prevId); - } + const prevKey = previousDraftKeyRef.current; + if (prevKey) { + drafts.persistDraft(prevKey, contentRef.current); } - previousChannelIdRef.current = channelId; + previousDraftKeyRef.current = effectiveDraftKey; - const saved = channelId ? drafts.loadDraft(channelId) : undefined; + const saved = effectiveDraftKey + ? drafts.loadDraft(effectiveDraftKey) + : undefined; if (saved) { setContent(saved.content); contentRef.current = saved.content; @@ -184,7 +180,13 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); mentions.clearMentions(); channelLinks.clearChannels(); - }, [channelId]); + + return () => { + if (effectiveDraftKey) { + drafts.persistDraft(effectiveDraftKey, contentRef.current); + } + }; + }, [effectiveDraftKey]); // ── Edit mode: pre-fill content ───────────────────────────────────── // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index f5a473616..32ffa7e26 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -258,6 +258,7 @@ export function MessageThreadPanel({ channelId={channelId} channelName={channelName} disabled={disabled || isSending || !channelId} + draftKey={`thread:${threadHead.id}`} isSending={isSending} onCancelReply={composerReplyTarget ? onCancelReply : undefined} onSend={onSend}