You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Keep the unsent draft when the user edits a message #277
Choosing Edit on a message throws away the unsent draft: the text the user typed and the attachments the user added. When the edit ends, the composer can then offer the edited message's text as a new message.
On 14ab7f9c1. Paths are under apps/webapp/src/components/chatroom/components/MessageComposer/.
Edit loads the message into the composer with setContent (hooks/useComposerDraft.ts:78-84). That replaces the unsent text in the editor. A message with no text, such as a media-only message, skips that load (hooks/useComposerDraft.ts:81). The editor then keeps the text typed before Edit, in any mode. Saving the edit makes that text the message's text.
In @tiptap/core 3.31.3, setContent emits an update by default (emitUpdate = true). So the composer's onUpdate runs (hooks/useTiptapEditor.ts:175).
onUpdate queues a draft write after 150 ms, with no edit-mode check (hooks/useTiptapEditor.ts:190-197). syncComposerDraft writes it about 500 ms later (apps/webapp/src/db/messageComposerDB.ts:218-263). The draft counts as hydrated during an edit (hooks/useComposerDraft.ts:36-38), so the write goes through. The channel's saved draft now holds the message being edited.
Edit also replaces the attachment list with the message's media (hooks/useComposerAttachmentLifecycle.ts:69-72 calls loadExistingAttachments, hooks/useComposerAttachments.ts:140-155). The unsent draft's ready attachments leave the list. When the edit ends, the attachment hydrate adds them back only if it has not loaded from the saved draft before.
When the edit ends, the unsent draft does not return:
Saving calls discardComposerDraft (hooks/useComposerSubmit.ts:92), so the composer ends empty.
Escape clears edit memory and the editor (hooks/useHandleEscKey.ts:47-50). Then the hydrate effect runs again and loads the saved draft (hooks/useComposerDraft.ts:31-76). The saved draft now holds the edited message, so the composer shows that text as a new message.
The Cancel edit button clears only edit memory (components/Context/EditContext.tsx:17-20). It never clears the editor. When no draft is saved, the hydrate effect leaves the editor as it is (hooks/useComposerDraft.ts:49). So after Cancel edit, the edited text stays even when the saved draft is empty.
Starting a reply, or a comment on the same heading, also ends the edit. Setting that memory clears the channel's edit memory (apps/webapp/src/stores/chat/workspaceSettingsStore.ts:93-103). The edited text stays in the editor. A comment on another heading switches the chat, and the first channel keeps its edit memory.
The desktop Close chatroom button ends the edit too, through CHAT_CLOSE (apps/webapp/src/services/eventsHub.ts:91). The Chat pane Close chat button, the pane grabber, Back, and the CHAT_OPEN toggle call only destroyChatRoom(). They keep edit memory, so reopening that chat resumes the edit.
Sending the text left in the composer posts a copy of the edited message.
A reload during an edit has the same result. The draft write lands about 650 ms after Edit, with no flush needed. A flush on pagehide or visibilitychange saves a write still waiting in the 500 ms debounce (apps/webapp/src/components/AppProviders.tsx:56-78). A write still inside the 150 ms timer is lost, because the flush reaches only the 500 ms debounce. Edit memory is not persisted, so after the reload the composer offers the edited message's text as a new message.
Steps to reproduce
In a channel, type draft text in the composer. Do not send.
Choose Edit on one of your earlier messages.
See the composer show the old message. draft text is gone. Wait one second.
Press Escape to cancel the edit.
See the composer still show the old message, now as a new message. Reload. The old message is still there, and draft text is gone.
An Escape within about 650 ms of Edit can bring back draft text, because the draft write has not landed yet.
Acceptance criteria
While an edit is open, the saved draft keeps the unsent text and ready attachments unchanged. It never takes the edited message's text or media.
Every end of an edit puts the unsent draft back in the composer, with its ready attachments. This covers a save, Escape, the Cancel edit button, and starting a reply or a comment.
With no unsent draft, ending an edit with Escape, the Cancel edit button, or a new reply or comment leaves the editor empty.
Edit on a message with no text opens with an empty editor. Text typed before Edit, in any mode, never becomes the edited message's text.
After a reload, or the desktop Close chatroom button, during an edit, the composer shows the unsent draft with its ready attachments. It does not show the edited message.
A chat close that keeps edit memory reopens that chat in edit mode. Ending that edit then restores the unsent draft with its ready attachments.
A send that is not an edit still clears the saved draft. Reopening the channel after that send shows an empty composer.
Agent Brief
Category: bug Summary: Edit mode must keep the unsent draft unchanged, and restore it when the edit ends.
Current behavior:
Edit loads the message with setContent, which fires the text writer. The unsent text and attachments are replaced. After a save, the saved draft is empty. After Escape, the composer reloads the saved draft and shows the edited message as a new message. The Cancel edit button does not clear the editor, so the edited message stays there.
Desired behavior:
While an edit is open, the unsent draft stays in the IndexedDB draft store, unchanged. Do not keep draft text in the chat store's mode memory or in the composer context. The text writer ignores edit content. Ending an edit never rewrites the saved draft's attachments from the edit's attachment list. An edit save does not delete the saved draft, but a send that is not an edit still deletes it. When the edit ends in any way, the unsent draft returns with its ready attachments. The ways include a save, Escape, the Cancel edit button, starting a reply or a comment, and the desktop Close chatroom button. A chat close that keeps edit memory leaves the edit open.
Key interfaces:
useComposerDraft() — hydrates the draft, and loads the message on edit.
The composer onUpdate text writer in useTiptapEditor().
syncComposerDraft(), discardComposerDraft(), and flushPendingWrites() in the IndexedDB draft store.
cleanupAfterSubmit() in useComposerSubmit() — deletes the saved draft after every send, an edit save included.
useComposerAttachmentLifecycle() — loads the message's media on Edit, and empties the attachment list when edit mode ends.
useComposerAttachmentDraft() — the attachment writer, and the attachment hydrate from the saved draft.
loadExistingAttachments() and cancelEditAttachments().
The window Escape handler useHandleEscKey() and the Cancel edit button, which end an edit without a save.
This narrows the chatroom rule "Post-send draft clear" (apps/webapp/src/components/chatroom/CLAUDE.md:70) to sends that are not an edit. Update that rule in the same change.
The attachment restore has two traps. The attachment hydrate loads from the saved draft at most once per channel mount. After its first load, hydratedAttachmentsRef blocks every later load (hooks/useComposerAttachmentDraft.ts:52-75). When edit mode ends, the attachment writer rewrites the saved draft from the emptied list (hooks/useComposerAttachmentDraft.ts:86-101).
The evidence is a code trace on 14ab7f9c1, the setContent default read from @tiptap/core 3.31.3, and scratch harnesses of the Escape and Cancel edit paths. They mounted the real composer draft hooks in happy-dom, and copied the calls that the window Escape handler and the Cancel edit button make. It was not reproduced in a browser.
Problem
Choosing Edit on a message throws away the unsent draft: the text the user typed and the attachments the user added. When the edit ends, the composer can then offer the edited message's text as a new message.
On
14ab7f9c1. Paths are underapps/webapp/src/components/chatroom/components/MessageComposer/.setContent(hooks/useComposerDraft.ts:78-84). That replaces the unsent text in the editor. A message with no text, such as a media-only message, skips that load (hooks/useComposerDraft.ts:81). The editor then keeps the text typed before Edit, in any mode. Saving the edit makes that text the message's text.@tiptap/core3.31.3,setContentemits an update by default (emitUpdate = true). So the composer'sonUpdateruns (hooks/useTiptapEditor.ts:175).onUpdatequeues a draft write after 150 ms, with no edit-mode check (hooks/useTiptapEditor.ts:190-197).syncComposerDraftwrites it about 500 ms later (apps/webapp/src/db/messageComposerDB.ts:218-263). The draft counts as hydrated during an edit (hooks/useComposerDraft.ts:36-38), so the write goes through. The channel's saved draft now holds the message being edited.hooks/useComposerAttachmentLifecycle.ts:69-72callsloadExistingAttachments,hooks/useComposerAttachments.ts:140-155). The unsent draft's ready attachments leave the list. When the edit ends, the attachment hydrate adds them back only if it has not loaded from the saved draft before.discardComposerDraft(hooks/useComposerSubmit.ts:92), so the composer ends empty.hooks/useHandleEscKey.ts:47-50). Then the hydrate effect runs again and loads the saved draft (hooks/useComposerDraft.ts:31-76). The saved draft now holds the edited message, so the composer shows that text as a new message.components/Context/EditContext.tsx:17-20). It never clears the editor. When no draft is saved, the hydrate effect leaves the editor as it is (hooks/useComposerDraft.ts:49). So after Cancel edit, the edited text stays even when the saved draft is empty.apps/webapp/src/stores/chat/workspaceSettingsStore.ts:93-103). The edited text stays in the editor. A comment on another heading switches the chat, and the first channel keeps its edit memory.CHAT_CLOSE(apps/webapp/src/services/eventsHub.ts:91). The Chat pane Close chat button, the pane grabber, Back, and theCHAT_OPENtoggle call onlydestroyChatRoom(). They keep edit memory, so reopening that chat resumes the edit.Sending the text left in the composer posts a copy of the edited message.
A reload during an edit has the same result. The draft write lands about 650 ms after Edit, with no flush needed. A flush on
pagehideorvisibilitychangesaves a write still waiting in the 500 ms debounce (apps/webapp/src/components/AppProviders.tsx:56-78). A write still inside the 150 ms timer is lost, because the flush reaches only the 500 ms debounce. Edit memory is not persisted, so after the reload the composer offers the edited message's text as a new message.Steps to reproduce
draft textin the composer. Do not send.draft textis gone. Wait one second.draft textis gone.An Escape within about 650 ms of Edit can bring back
draft text, because the draft write has not landed yet.Acceptance criteria
Agent Brief
Category: bug
Summary: Edit mode must keep the unsent draft unchanged, and restore it when the edit ends.
Current behavior:
Edit loads the message with
setContent, which fires the text writer. The unsent text and attachments are replaced. After a save, the saved draft is empty. After Escape, the composer reloads the saved draft and shows the edited message as a new message. The Cancel edit button does not clear the editor, so the edited message stays there.Desired behavior:
While an edit is open, the unsent draft stays in the IndexedDB draft store, unchanged. Do not keep draft text in the chat store's mode memory or in the composer context. The text writer ignores edit content. Ending an edit never rewrites the saved draft's attachments from the edit's attachment list. An edit save does not delete the saved draft, but a send that is not an edit still deletes it. When the edit ends in any way, the unsent draft returns with its ready attachments. The ways include a save, Escape, the Cancel edit button, starting a reply or a comment, and the desktop Close chatroom button. A chat close that keeps edit memory leaves the edit open.
Key interfaces:
useComposerDraft()— hydrates the draft, and loads the message on edit.onUpdatetext writer inuseTiptapEditor().syncComposerDraft(),discardComposerDraft(), andflushPendingWrites()in the IndexedDB draft store.cleanupAfterSubmit()inuseComposerSubmit()— deletes the saved draft after every send, an edit save included.useComposerAttachmentLifecycle()— loads the message's media on Edit, and empties the attachment list when edit mode ends.useComposerAttachmentDraft()— the attachment writer, and the attachment hydrate from the saved draft.loadExistingAttachments()andcancelEditAttachments().useHandleEscKey()and the Cancel edit button, which end an edit without a save.Out of scope
loadExistingAttachments()resets the upload runner, which aborts them.Notes
This narrows the chatroom rule "Post-send draft clear" (
apps/webapp/src/components/chatroom/CLAUDE.md:70) to sends that are not an edit. Update that rule in the same change.The attachment restore has two traps. The attachment hydrate loads from the saved draft at most once per channel mount. After its first load,
hydratedAttachmentsRefblocks every later load (hooks/useComposerAttachmentDraft.ts:52-75). When edit mode ends, the attachment writer rewrites the saved draft from the emptied list (hooks/useComposerAttachmentDraft.ts:86-101).The evidence is a code trace on
14ab7f9c1, thesetContentdefault read from@tiptap/core3.31.3, and scratch harnesses of the Escape and Cancel edit paths. They mounted the real composer draft hooks in happy-dom, and copied the calls that the window Escape handler and the Cancel edit button make. It was not reproduced in a browser.