fix(drafts): stop clearing drafts on workspace switch and hide sent section (#1708)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-10 12:23:45 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 177fc30bea
commit e2d104f669
9 changed files with 110 additions and 303 deletions
@@ -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>:<ts>` 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");
// No sent records.
assert.equal(getSentDraftEntries().length, 0, "no sent records");
});
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");
});
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");
});
+25 -61
View File
@@ -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<string, DraftState> {
!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.
* Clear the active draft entry for a sent draft.
*
* 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.
*
* The sent record is stored under `sent:<draftKey>:<timestamp>` 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 ────────────────────────────────────────────────────────────
@@ -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;
}
@@ -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");
@@ -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,
@@ -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");
});
@@ -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.
@@ -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();
}
@@ -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);