diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index c1fd75a830..cbc9291798 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -433,12 +433,12 @@ test("initDraftStore_resets_cache_on_pubkey_change_without_clearAllDrafts", () = ); }); -// ── status field: markDraftSent, getActiveDraftEntries, getSentDraftEntries ─── -// markDraftSentEntry snapshots the draft into a distinct `sent::` key -// and removes the original active key so composer cleanup and new drafts are -// never affected by the sent record's lifecycle. +// ── markDraftSentEntry: clears active draft (no longer writes sent records) ── +// markDraftSentEntry now delegates to clearDraftEntry — it clears the active +// draft and writes nothing to the store. The sent-section UI was removed; +// these tests verify the new contract. -test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key", () => { +test("markDraftSent_clears_active_draft_and_writes_no_sent_record", () => { setup(); persistDraftEntry("chan-1", "sent message content", "chan-1", [IMG_A], []); markDraftSentEntry("chan-1", "sent message content", "chan-1", [IMG_A], []); @@ -448,185 +448,67 @@ test("markDraftSent_writes_sent_record_under_distinct_key_and_removes_active_key undefined, "active key must be cleared after markDraftSent", ); - // Sent record must exist under a sent: key. + // No sent records written. const sent = getSentDraftEntries(); - assert.equal(sent.length, 1, "one sent entry must exist"); - assert.equal(sent[0].draft.status, "sent", "status must be 'sent'"); - assert.equal( - sent[0].draft.content, - "sent message content", - "content preserved", - ); - assert.equal(sent[0].draft.pendingImeta.length, 1, "image preserved"); - assert.equal(sent[0].draft.pendingImeta[0].url, IMG_A.url); - assert.ok( - sent[0].key.startsWith("sent:chan-1:"), - "sent key must have sent: prefix", - ); + assert.equal(sent.length, 0, "no sent entries written"); + // All-entries also empty. + assert.equal(getAllDraftEntries().length, 0, "store is empty after send"); }); -test("markDraftSent_writes_sent_record_even_when_active_key_already_cleared", () => { - // The never-persisted boundary is enforced at the call site (sentDraftKey - // is only set when a draft was actually persisted). This function writes - // unconditionally so a navigation-during-send race cannot cause data loss: - // if the active key was already cleared before send success, the snapshot - // content still produces a sent record (createdAt falls back to now). +test("markDraftSent_is_a_no_op_when_active_key_absent", () => { setup(); // Call without any prior persistDraftEntry — simulates the race where the - // active key was deleted by a composer cleanup before markDraftSent ran. + // active key was already cleared before markDraftSent ran. markDraftSentEntry("no-such-key", "content", "chan-x", [], []); assert.equal( loadDraftEntry("no-such-key"), undefined, "active key still absent", ); - const sent = getSentDraftEntries(); - assert.equal( - sent.length, - 1, - "sent record is written even without a live active key", - ); - assert.equal(sent[0].draft.content, "content"); - assert.equal(sent[0].draft.status, "sent"); - assert.ok( - sent[0].key.startsWith("sent:no-such-key:"), - "sent key has correct prefix", - ); -}); - -test("markDraftSent_send_then_cleanup_preserves_sent_record", () => { - // Simulate the full composer lifecycle: - // 1. Draft exists on key A. - // 2. User sends -> markDraftSent(A) snapshots under sent:A:ts and clears A. - // 3. Composer cleanup calls persistDraft(A, "", ...) -> clearDraftEntry(A). - // The sent record under sent:A:ts must still exist after step 3. - setup(); - persistDraftEntry("chan-A", "my draft", "chan-A", [IMG_A], []); - markDraftSentEntry("chan-A", "my draft", "chan-A", [IMG_A], []); - // Simulate composer cleanup: empty persist on the now-absent active key. - persistDraftEntry("chan-A", "", "chan-A", [], []); - const sent = getSentDraftEntries(); - assert.equal(sent.length, 1, "sent record must survive composer cleanup"); - assert.equal(sent[0].draft.content, "my draft"); -}); - -test("markDraftSent_navigation_during_async_send_still_creates_sent_record", () => { - // Regression test for the async-send/navigation race (Thufir Pass-2 finding): - // 1. Persisted draft A exists at submit time. - // 2. Composer clears the editor (clearContent) then awaits onSend. - // 3. While onSend is in flight, user switches channel. MessageComposer - // cleanup runs persistDraftEntry(A, empty) -> clearDraftEntry(A) — active - // key is gone before send success. - // 4. Send succeeds; markDraftSentEntry(A, savedContent, ...) runs. - // The sent record MUST still be written from the passed-in snapshot. - setup(); - persistDraftEntry("chan-race", "race draft", "chan-race", [IMG_A], []); - // Simulate step 3: active key cleared by navigation-during-send cleanup. - persistDraftEntry("chan-race", "", "chan-race", [], []); - assert.equal( - loadDraftEntry("chan-race"), - undefined, - "active key should be cleared (simulating race)", - ); - // Simulate step 4: send succeeds, mark sent with full snapshot. - markDraftSentEntry("chan-race", "race draft", "chan-race", [IMG_A], []); - const sent = getSentDraftEntries(); - assert.equal( - sent.length, - 1, - "sent record must be written despite active key being gone", - ); - assert.equal( - sent[0].draft.content, - "race draft", - "snapshot content preserved", - ); - assert.equal( - sent[0].draft.pendingImeta.length, - 1, - "snapshot image preserved", - ); - assert.equal(sent[0].draft.status, "sent"); + // Still no sent record written. + assert.equal(getSentDraftEntries().length, 0, "no sent entries"); + assert.equal(getAllDraftEntries().length, 0, "store still empty"); }); test("markDraftSent_new_active_draft_after_send_is_independent", () => { // After sending, a new draft typed in the same channel must appear in - // getActiveDraftEntries() as active, and the sent record must remain in - // getSentDraftEntries() -- they coexist under distinct keys. + // getActiveDraftEntries() — markDraftSent must not affect it. setup("pubkey-coexist"); persistDraftEntry("chan-X", "original draft", "chan-X", [], []); markDraftSentEntry("chan-X", "original draft", "chan-X", [], []); // New draft in the same channel. persistDraftEntry("chan-X", "new draft after send", "chan-X", [IMG_B], []); const active = getActiveDraftEntries(); - const sent = getSentDraftEntries(); assert.equal(active.length, 1, "one active draft"); assert.equal(active[0].draft.content, "new draft after send"); assert.equal(active[0].draft.status, "active"); - assert.equal(sent.length, 1, "one sent record"); - assert.equal(sent[0].draft.content, "original draft"); - assert.equal(sent[0].draft.status, "sent"); -}); - -test("markDraftSent_double_send_in_same_channel_creates_two_distinct_sent_records", () => { - // Sending twice from the same channel must produce two independent sent - // records -- the timestamp suffix prevents key collision. - setup("pubkey-double-send"); - persistDraftEntry("chan-Y", "first draft", "chan-Y", [], []); - markDraftSentEntry("chan-Y", "first draft", "chan-Y", [], []); - // Second draft in the same channel. - persistDraftEntry("chan-Y", "second draft", "chan-Y", [], []); - markDraftSentEntry("chan-Y", "second draft", "chan-Y", [], []); - const sent = getSentDraftEntries(); - assert.equal(sent.length, 2, "two distinct sent records"); - const contents = sent.map((e) => e.draft.content).sort(); - assert.deepEqual(contents, ["first draft", "second draft"]); - const keys = sent.map((e) => e.key); - assert.notEqual(keys[0], keys[1], "sent keys must be distinct"); + // No sent records. + assert.equal(getSentDraftEntries().length, 0, "no sent records"); }); -test("getActiveDraftEntries_returns_only_active_drafts", () => { +test("getActiveDraftEntries_excludes_cleared_drafts", () => { setup("pubkey-active"); persistDraftEntry("chan-active", "active draft", "chan-active", [], []); persistDraftEntry("chan-sent", "sent draft", "chan-sent", [], []); markDraftSentEntry("chan-sent", "sent draft", "chan-sent", [], []); const active = getActiveDraftEntries(); - assert.equal(active.length, 1, "only one active draft"); + assert.equal(active.length, 1, "only one active draft remains"); assert.equal(active[0].key, "chan-active"); assert.equal(active[0].draft.status, "active"); }); -test("getSentDraftEntries_returns_only_sent_drafts", () => { +test("getSentDraftEntries_returns_empty_after_markDraftSent", () => { setup("pubkey-sent"); persistDraftEntry("chan-active2", "still drafting", "chan-active2", [], []); persistDraftEntry("chan-sent2", "already sent", "chan-sent2", [], []); markDraftSentEntry("chan-sent2", "already sent", "chan-sent2", [], []); + // markDraftSentEntry now just clears the active entry — no sent record. const sent = getSentDraftEntries(); - assert.equal(sent.length, 1, "only one sent draft"); - assert.ok(sent[0].key.startsWith("sent:chan-sent2:"), "sent key has prefix"); - assert.equal(sent[0].draft.status, "sent"); -}); - -test("getActiveDraftEntries_and_getSentDraftEntries_partition_all_entries", () => { - setup("pubkey-partition"); - persistDraftEntry("ch-a", "draft a", "ch-a", [], []); - persistDraftEntry("ch-b", "draft b", "ch-b", [], []); - persistDraftEntry("ch-c", "draft c", "ch-c", [], []); - markDraftSentEntry("ch-b", "draft b", "ch-b", [], []); - const all = getAllDraftEntries(); + assert.equal(sent.length, 0, "no sent entries"); + // The remaining active draft is still there. const active = getActiveDraftEntries(); - const sent = getSentDraftEntries(); - // ch-a, ch-c still active; sent:ch-b:ts is the sent record. - assert.equal(all.length, 3); - assert.equal(active.length + sent.length, all.length, "active + sent = all"); - assert.ok( - active.every((e) => e.draft.status === "active"), - "all active entries have status active", - ); - assert.ok( - sent.every((e) => e.draft.status === "sent"), - "all sent entries have status sent", - ); + assert.equal(active.length, 1, "one active draft"); + assert.equal(active[0].key, "chan-active2"); }); // ── status migration: pre-status entries read as "active" ──────────────────── @@ -682,3 +564,31 @@ test("pre_status_entry_appears_in_getActiveDraftEntries_after_migration", () => assert.equal(active[0].key, "chan-old"); assert.equal(active[0].draft.status, "active"); }); + +test("pre_status_sent_entry_is_dropped_on_read", () => { + // Entries previously written with status "sent" (by the old markDraftSentEntry) + // used a "sent:" key prefix. readStore now skips those keys entirely so legacy + // sent records cannot resurface as ghost drafts. + setup("pubkey-normalise"); + const oldSentEntry = { + content: "a message I sent a while ago", + selectionStart: 27, + selectionEnd: 27, + channelId: "chan-z", + createdAt: "2025-09-01T00:00:00.000Z", + updatedAt: "2025-09-01T00:00:00.000Z", + pendingImeta: [], + spoileredAttachmentUrls: [], + status: "sent", + }; + localStorage.setItem( + "buzz-drafts.v1:pubkey-normalise", + JSON.stringify({ "sent:chan-z:1725148800000-1": oldSentEntry }), + ); + clearAllDrafts(); + initDraftStore("pubkey-normalise"); + // The sent: key is skipped — the entry must NOT appear as an active draft. + const active = getActiveDraftEntries(); + assert.equal(active.length, 0, "old sent: entry is dropped, not promoted"); + assert.equal(getSentDraftEntries().length, 0, "no entries read as sent"); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 80cfb4d968..ace2324a85 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -59,11 +59,11 @@ export type DraftState = { /** URLs of imeta attachments marked as spoilered. */ spoileredAttachmentUrls: string[]; /** - * Lifecycle status of this draft. - * - "active": draft is in progress (not yet sent). - * - "sent": draft was sent; kept for the Drafts inbox "Sent" subsection. + * Lifecycle status of this draft. Always `"active"` at runtime. + * The `"sent"` value is not written by any production path; legacy `sent:` + * keyed records from older builds are dropped on read by `readStore`. * Entries persisted before this field was added have no status field — - * the read path treats absent status as "active" (see `isValidDraftState`). + * the read path treats absent status as `"active"` (see `isValidDraftState`). */ status: "active" | "sent"; }; @@ -77,10 +77,6 @@ const MAX_DRAFTS = 100; /** Module-level pubkey set by `initDraftStore`. Empty string = no identity. */ let currentPubkey = ""; -/** Monotonically-incrementing counter used to guarantee unique sent-record keys - * even when two sends happen within the same millisecond (e.g. in tests). */ -let _sentSeq = 0; - function storageKey(): string { return `${DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } @@ -140,6 +136,12 @@ function readStore(): Map { !Array.isArray(parsed) ) { for (const [key, value] of Object.entries(parsed as StoredDrafts)) { + // Drop legacy sent: records — they were written by the old + // markDraftSentEntry and have no role now that the Sent section is + // removed. Skipping here compacts them out on the next flush. + if (key.startsWith("sent:")) { + continue; + } if (isValidDraftState(value)) { map.set(key, value); } @@ -169,11 +171,13 @@ function isValidDraftState(v: unknown): v is DraftState { return false; } // Migration: entries written before the status field was introduced have no - // status. Treat absent/invalid status as "active" rather than rejecting the - // entry — this avoids data loss on first run after the upgrade. + // status field. Treat absent status as "active" to avoid data loss on the + // first run after the upgrade. + // Legacy sent: keys are skipped by readStore before reaching this function; + // reject any remaining entry whose status is not "active". if (d.status === undefined || d.status === null) { (d as DraftState).status = "active"; - } else if (d.status !== "active" && d.status !== "sent") { + } else if (d.status !== "active") { return false; } return true; @@ -291,7 +295,7 @@ export function getActiveDraftEntries(): Array<{ /** * Returns only sent drafts, sorted most-recently-updated first. - * Used by the "Sent" subsection of the Drafts inbox panel. + * Returns empty — sent records are dropped on read. Kept for test assertions. */ export function getSentDraftEntries(): Array<{ key: string; @@ -301,60 +305,20 @@ export function getSentDraftEntries(): Array<{ } /** - * Mark a draft as sent by writing its content to a durable sent-record key. - * - * The active draft key is simultaneously cleared so the composer can create - * a fresh draft in the same channel without inheriting the sent status, and so - * the composer's empty-content cleanup can never delete the sent record. + * Clear the active draft entry for a sent draft. * - * The sent record is stored under `sent::` — a key the - * composer never writes to — so active and sent records for the same channel - * can coexist in the store independently. - * - * The "never-persisted draft writes no sent record" boundary is enforced at - * the call site: callers only invoke this function when `sentDraftKey` is - * non-null, which only holds for drafts that were persisted before submit. - * This function writes unconditionally so the sent record is created even - * when the active key was already cleared by a composer cleanup that raced - * the async send (e.g. the user switched channels while send was in flight). + * Kept as a named export so callers (`useMentionSendFlow`) don't need + * updating. Previously wrote a visible sent-record to the store; the + * sent section has been removed, so we now just clear the active draft. */ export function markDraftSentEntry( draftKey: string, - content: string, - channelId: string, - pendingImeta: ImetaMedia[], - spoileredAttachmentUrls: string[], + _content: string, + _channelId: string, + _pendingImeta: ImetaMedia[], + _spoileredAttachmentUrls: string[], ): void { - const map = readStore(); - const existing = map.get(draftKey); - const now = new Date().toISOString(); - // Use the live entry's createdAt when available; fall back to now when the - // active key was already cleared by a navigation-during-send race. Either - // way the sent record is written — the race cannot cause data loss. - const createdAt = existing?.createdAt ?? now; - // Write the sent record under a stable, distinct key so it can never be - // overwritten by the composer's active-draft persist path. - // The `Date.now()-seq` suffix guarantees uniqueness even if two sends in the - // same channel happen within the same millisecond. - const sentKey = `sent:${draftKey}:${Date.now()}-${++_sentSeq}`; - map.set(sentKey, { - content, - selectionStart: content.length, - selectionEnd: content.length, - channelId, - createdAt, - updatedAt: now, - pendingImeta, - spoileredAttachmentUrls, - status: "sent", - }); - - // Clear the active draft key (if still present) so the composer starts fresh - // and any subsequent empty-content persist doesn't encounter the sent record. - map.delete(draftKey); - evictOldest(map); - flushStore(map); - notifySubscribers(); + clearDraftEntry(draftKey); } // ── Reactive hooks ──────────────────────────────────────────────────────────── diff --git a/desktop/src/features/messages/ui/DraftsPanel.tsx b/desktop/src/features/messages/ui/DraftsPanel.tsx index 44cf1899a4..54a424cbf4 100644 --- a/desktop/src/features/messages/ui/DraftsPanel.tsx +++ b/desktop/src/features/messages/ui/DraftsPanel.tsx @@ -6,7 +6,6 @@ import { useChannelsQuery } from "@/features/channels/hooks"; import { clearDraftEntry, getActiveDraftEntries, - getSentDraftEntries, useDraftsSnapshot, type DraftState, } from "@/features/messages/lib/useDrafts"; @@ -122,15 +121,11 @@ function getDraftPreview(draft: DraftState): string { function readDraftSections(): DraftSection[] { const active = getActiveDraftEntries().filter(isVisibleDraft); - const sent = getSentDraftEntries().filter(isVisibleDraft); const sections: DraftSection[] = []; if (active.length > 0) { sections.push({ label: "Drafts", status: "active", entries: active }); } - if (sent.length > 0) { - sections.push({ label: "Sent", status: "sent", entries: sent }); - } return sections; } diff --git a/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs b/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs index 249bfd8934..45d6d564e3 100644 --- a/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs +++ b/desktop/src/features/messages/ui/DraftsPanelPredicate.test.mjs @@ -61,8 +61,7 @@ test("canOpenDraft_active_resolved_channel_returns_true", () => { }); // ── (b) sent + resolved channel → NOT openable ─────────────────────────────── -// Composer restores only active/thread keys; sent: keys cannot be restored. -// Sent subsection is Delete-only (Will-confirmed behavior). +// Composer restores only active/thread keys; sent: keys are dropped on read. test("canOpenDraft_sent_resolved_channel_returns_false", () => { const draft = sentDraft("chan-1"); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9a52a447c9..11e6190127 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -623,9 +623,9 @@ function MessageComposerImpl({ pendingImeta: currentPendingImeta, // resolveSentDraftKey checks at submit time (synchronously, before any // await) whether a draft was actually persisted. If not — fast/ - // never-persisted send — it returns null so no sent record is written. - // The function is exported and tested directly in - // MessageComposerDraftPredicate.test.mjs. + // never-persisted send — it returns null so the active draft is not + // cleared (nothing to clear). The function is exported and tested directly + // in MessageComposerDraftPredicate.test.mjs. sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, diff --git a/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs index bc25802467..da4391911a 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftPredicate.test.mjs @@ -10,13 +10,13 @@ * these tests immediately. * * Three properties under test: - * (a) never-persisted key → null (fast send must not produce a sent record) - * (b) persisted key → key (normal send produces a sent record) + * (a) never-persisted key → null (fast send: no active draft to clear) + * (b) persisted key → key (key is captured at submit time) * (c) submit-time capture semantics: the value returned at submit time is * stable even if the store entry is cleared before send success (proving * the predicate is evaluated once at submit, not re-read at success). * - * Integration scenarios (d)+(e) drive the full storage flow to confirm + * Integration scenarios (d)+(e)+(f) drive the full storage flow to confirm * that the predicate output correctly gates markDraftSentEntry. */ @@ -125,7 +125,7 @@ test("resolveSentDraftKey_null_effectiveKey_returns_null", () => { assert.equal(result2, null, "undefined effectiveDraftKey → null"); }); -// ── (d) Integration: never-persisted send → no sent record ─────────────────── +// ── (d) Integration: never-persisted send → no active draft to clear ───────── // Simulates submitMessage calling resolveSentDraftKey before the send: // the resolver returns null → markDraftSentEntry is never called. @@ -138,13 +138,17 @@ test("submit_predicate_never_persisted_send_produces_no_sent_record", () => { assert.equal(sentDraftKey, null, "resolver returns null for fast send"); // markDraftSentEntry is never called (sentDraftKey is null → gate in - // useMentionSendFlow.ts:399 fires false). No sent record. - assert.equal(getSentDraftEntries().length, 0, "no sent record for fast send"); + // useMentionSendFlow.ts:399 fires false). Active draft was never written. + assert.equal( + getSentDraftEntries().length, + 0, + "no active draft for fast send", + ); }); -// ── (e) Integration: persisted draft → sent record written ─────────────────── +// ── (e) Integration: persisted draft → active draft cleared on send ─────────── -test("submit_predicate_persisted_draft_produces_sent_record", () => { +test("submit_predicate_persisted_draft_clears_active_entry_on_send", () => { setup("pubkey-normal-send"); const draftKey = "chan-normal-integration"; @@ -162,18 +166,21 @@ test("submit_predicate_persisted_draft_produces_sent_record", () => { // Send succeeds — markDraftSentEntry called with the captured key. markDraftSentEntry(draftKey, "my draft content", draftKey, [], []); - const sent = getSentDraftEntries(); - assert.equal(sent.length, 1, "sent record created"); - assert.equal(sent[0].draft.content, "my draft content"); - assert.equal(sent[0].draft.status, "sent"); + // Active key is cleared; active draft removed. + assert.equal(loadDraftEntry(draftKey), undefined, "active key cleared"); + assert.equal( + getSentDraftEntries().length, + 0, + "no active draft entries remain", + ); }); -// ── (f) Integration: persisted draft + race → sent record still written ─────── +// ── (f) Integration: persisted draft + race → active key already gone, no-op ── // Simulates: persist → resolveSentDraftKey captures key at submit time → // navigation race clears the active entry → send succeeds with captured key. -// markDraftSentEntry writes unconditionally → sent record exists with snapshot. +// markDraftSentEntry now just calls clearDraftEntry — a no-op when key is gone. -test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written", () => { +test("submit_predicate_persisted_then_race_clears_key_markDraftSent_is_noop", () => { setup("pubkey-race-send"); const draftKey = "chan-race-pred"; @@ -193,23 +200,9 @@ test("submit_predicate_persisted_then_race_clears_key_sent_record_still_written" ); // Step 4: send succeeds; markDraftSentEntry called with captured sentDraftKey. + // Key is already gone — no-op. markDraftSentEntry(sentDraftKey, "race content", draftKey, [IMG_A], []); - const sent = getSentDraftEntries(); - assert.equal( - sent.length, - 1, - "sent record written despite active key being cleared", - ); - assert.equal( - sent[0].draft.content, - "race content", - "snapshot content preserved", - ); - assert.equal( - sent[0].draft.pendingImeta.length, - 1, - "snapshot image preserved", - ); - assert.equal(sent[0].draft.status, "sent"); + assert.equal(getSentDraftEntries().length, 0, "no entries in store"); + assert.equal(loadDraftEntry(draftKey), undefined, "active key still absent"); }); diff --git a/desktop/src/features/messages/ui/draftSubmitKey.ts b/desktop/src/features/messages/ui/draftSubmitKey.ts index 21ac4f0672..c2d25d918e 100644 --- a/desktop/src/features/messages/ui/draftSubmitKey.ts +++ b/desktop/src/features/messages/ui/draftSubmitKey.ts @@ -3,8 +3,8 @@ * time. * * Returns the key when a draft was actually persisted in the store before the - * send fired (so a sent record should be written), or null when no entry - * exists (fast / never-persisted send — no sent record should be written). + * send fired (so the active draft should be cleared on send), or null when no + * entry exists (fast / never-persisted send — no active draft to clear). * * This is a pure function with no dependencies so it can be imported and * exercised directly in Node .mjs tests without a React renderer. diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index 2d1a4b852d..de82407bb8 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -5,10 +5,7 @@ import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; -import { - clearAllDrafts, - initDraftStore, -} from "@/features/messages/lib/useDrafts"; +import { initDraftStore } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetActiveAgentTurnsStore, @@ -41,7 +38,6 @@ function resetWorkspaceState(): void { resetVideoPlayerState(); resetRenderScopedReactionHydration(); clearSearchHitEventCache(); - clearAllDrafts(); clearMarkdownNodeCache(); } diff --git a/desktop/tests/e2e/drafts-screenshots.spec.ts b/desktop/tests/e2e/drafts-screenshots.spec.ts index 5fcd2c8183..cd6ab2313f 100644 --- a/desktop/tests/e2e/drafts-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-screenshots.spec.ts @@ -15,8 +15,6 @@ const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // Fixed timestamps for deterministic rendering const CREATED_AT_1 = "2026-07-01T10:00:00.000Z"; const CREATED_AT_2 = "2026-07-02T14:30:00.000Z"; -const CREATED_AT_3 = "2026-07-03T09:15:00.000Z"; -const CREATED_AT_SENT = "2026-07-04T16:45:00.000Z"; type StoredDraftState = { content: string; @@ -68,23 +66,6 @@ const ACTIVE_DRAFTS: StoredDrafts = { }, }; -/** Active drafts + one sent record for shots that need both subsections. */ -const ACTIVE_AND_SENT_DRAFTS: StoredDrafts = { - ...ACTIVE_DRAFTS, - [`sent:channel:${GENERAL_CHANNEL_ID}:1720115100000-1`]: { - content: - "Shipping the draft message improvements in PR #1539 — image persistence, sent records, and the new Drafts inbox section.", - selectionStart: 119, - selectionEnd: 119, - channelId: GENERAL_CHANNEL_ID, - createdAt: CREATED_AT_3, - updatedAt: CREATED_AT_SENT, - pendingImeta: [], - spoileredAttachmentUrls: [], - status: "sent", - }, -}; - /** * Patch the mock workspace to include the pubkey so initDraftStore gets the * correct pubkey on app startup. The workspace is seeded by installMockBridge @@ -191,37 +172,6 @@ test.describe("drafts screenshots", () => { }); }); - test("02 — sent subsection visible", async ({ page }) => { - await installMockBridge(page); - await patchWorkspacePubkey(page); - await seedDraftStore(page, ACTIVE_AND_SENT_DRAFTS); - - const panel = await openDraftsPanel(page); - - // Both subsection headings should render - await expect(panel.getByText("Drafts", { exact: true })).toBeVisible({ - timeout: 10_000, - }); - await expect(panel.getByText("Sent", { exact: true })).toBeVisible({ - timeout: 5_000, - }); - - // At least one row in each subsection - const draftRows = panel.locator("[data-testid^='home-draft-item-']"); - await expect(draftRows).toHaveCount(3, { timeout: 6_000 }); - - // The sent draft content should appear - await expect( - panel.getByText("Shipping the draft message improvements", { - exact: false, - }), - ).toBeVisible({ timeout: 5_000 }); - - await page.waitForTimeout(200); - - await panel.screenshot({ path: `${SHOTS}/02-sent-subsection.png` }); - }); - test("03 — hover actions visible", async ({ page }) => { await installMockBridge(page); await patchWorkspacePubkey(page);