Skip to content

Stop chat drafts from dropping attachments or listing deleted uploads #267

Description

@HMarzban

Problem

A composer draft keeps its ready attachments in IndexedDB for up to 120 days after its last write. Here an attachment is the composer entry, and its upload is the file in the media bucket. Three paths make the draft's attachment list and its uploads differ. Two delete uploads that the draft still lists. One drops an attachment from the draft while its upload still exists.

Paths are under apps/webapp/src/components/chatroom/components/MessageComposer/ unless shown in full.

Channel switch. A channel switch mounts a new composer, because ChatroomProvider is keyed by headingId (apps/webapp/src/components/chatroom/Chatroom.tsx:38). On mount, the new composer disposes every other store key (hooks/useComposerAttachments.ts:100-104). disposeComposerAttachmentsForKey deletes the uploads by default (apps/webapp/src/components/chatroom/stores/composerAttachmentsStore.ts:105-113). It does not touch the draft. The attachment writer had already saved those uploads to the draft (hooks/useComposerAttachmentDraft.ts:100).

Orphan cleanup. internal.cleanup_orphan_chat_media deletes uploads in the media bucket that no live message lists in messages.medias, once they are older than 24 hours (packages/supabase/scripts/10-3-func-message.sql:394-430). A pg_cron job runs it daily at 03:30 (packages/supabase/scripts/16-cron-jobs.sql:28-32). The rules say this daily reclaim of pre-send uploads is intended (packages/supabase/CLAUDE.md:53). A message never lists a draft upload. The job runs once a day, so the upload is gone 24 to 48 hours after it was made. The draft is kept for up to 120 days after its last write (apps/webapp/src/db/messageComposerDB.ts:63).

Two draft writers race. The text writer and the attachment writer both call syncComposerDraft. It saves the draft row through one lodash/debounce writer: trailing only, with 500 ms of delay and a 2 s maxWait (apps/webapp/src/db/messageComposerDB.ts:218-232, :250-263). The text writer first reads the saved draft, then passes that old attachment list on (hooks/useTiptapEditor.ts:191-197). If the user types within about half a second of an upload finishing, the read misses the new attachment. The text writer's call then replaces the pending write, so the draft loses the attachment. If the text is empty at that moment, syncComposerDraft cancels the pending write and deletes the row.

What the user sees on return after the first two paths:

  1. The draft restores the tiles as ready and not persisted (hooks/useComposerAttachmentDraft.ts:104-115). A restored tile says "Ready to send".
  2. Send probes storage and finds nothing.
  3. An error toast says "Attachments are still uploading. Wait a moment and try again." (hooks/useComposerSubmit.ts:145). It shows this on every Send, because the uploads no longer exist.

The chatroom docs say: "Per-key store isolation on channel switch — do not blanket-clear attachments" (apps/webapp/src/components/chatroom/CLAUDE.md:89). The store stays keyed, but the dispose deletes the uploads of every key it drops. The rule does not cover uploads.

Steps to reproduce

  1. In channel A, attach an image. Wait for the upload to finish. Do not send.
  2. Open channel B, then return to channel A.
  3. See the tile restored from the draft with "Ready to send".
  4. Send. See the "Attachments are still uploading" toast, and no message.

Acceptance criteria

  • Switching channels and back keeps a draft's uploads, and Send works.
  • A draft restored after an upload is gone marks that tile as expired, and says so. Send still sends the text and the attachments that exist.
  • Removing a tile from a draft still deletes its upload.
  • The orphan cleanup still deletes an upload that no message lists.
  • Typing right after an upload finishes keeps that attachment in the saved draft.
  • The chatroom rule on per-key isolation says what happens to draft uploads on a channel switch.

Agent Brief

Category: bug
Summary: A channel switch must not delete draft uploads. A restored draft must mark missing uploads, and the two draft writers must not overwrite each other.

Current behavior:
A channel switch deletes the uploads of the channel left behind, but that channel's saved draft keeps listing them. The server orphan cleanup deletes any unlisted upload after 24 to 48 hours, while drafts are kept for up to 120 days. A restored draft then blocks Send with a message that asks the user to wait. The text writer can also overwrite a newer attachment list with a stale one.

Desired behavior:
A channel switch never deletes uploads that a draft lists. A restored draft checks that its uploads still exist, and marks each missing one as expired on its tile. An upload that no longer exists never blocks Send. Text and attachments reach the saved draft without one overwriting the other.

Key interfaces:

  • disposeComposerAttachmentsForKey(key, { deleteStorage }) — deletes the uploads by default.
  • hydrateComposerAttachmentsFromDraft() — restores draft rows as ready.
  • ComposerAttachmentDraft rows in the composer IndexedDB store.
  • syncComposerDraft() — the one debounced function that both writers call: the attachment writer in useComposerAttachmentDraft() and the composer onUpdate text writer.
  • internal.cleanup_orphan_chat_media(interval) — the server cleanup.
  • ensureOutboundStorageReady(), which calls ensureChatMediaInsertReady() — the probe behind the "still uploading" message.

Out of scope

Notes

Present since 7b6622687 (2026-06-24).

One more gap in the same writers: the pagehide flush flushes only the debounced writer (apps/webapp/src/db/messageComposerDB.ts:272-274). A text write that still waits in the 150 ms timer, or in its saved-draft read, is dropped when the page unloads. The unmount cleanup cancels that 150 ms timer instead of flushing it, although its comment says flush (hooks/useTiptapEditor.ts:267-275). So text typed just before a channel switch or a tab close can be lost.

Coordinate with #277 and #296. Both add a mode check to the same text writer.

The evidence is a code trace on 14ab7f9c1. The channel-switch path needs one device check. The cleanup path can be checked on a local stack by running the cleanup function with a short interval. The race path needs a timing test against the composer draft store.

Activity

  1. added
    bugSomething isn't working
    ChatRelated to chat features
    on Sep 14, 2026
  2. added a commit that references this issue on Sep 22, 2026
    875017d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ChatRelated to chat featuresbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions