From f3c12f848290c9b81c31252fc0880e3abf30cf7c Mon Sep 17 00:00:00 2001 From: Peter Chinman Date: Thu, 22 Oct 2026 19:57:43 -0400 Subject: [PATCH 1/2] fix(email-compose): keep a scheduled reply visible in its thread Scheduling a reply reset the composer and marked its draft discarded in the thread state, which hides a draft for the rest of the session. The scheduled reply vanished until a reload, which then showed it with its "Scheduled for" bar. The reply now keeps its scheduled draft, as the new-email composer already does. --- .../email-compose/primitives/reply-composer.ts | 8 ++------ .../email-compose/primitives/send-schedule.test.ts | 12 ++++-------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index aab9540ecc0..98a5f4ed625 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -1130,12 +1130,8 @@ export function createReplyComposer( if (result === 'scheduled') { const scheduledDraftId = savedDraftId(); rememberCurrentReply(); - try { - resetState(); - clearDraftState(); - } catch (error) { - props.notices.reportError(error); - } + // The scheduled draft stays in the thread with its "Scheduled for" + // bar, as a reload shows it; clearing it hid it until a refresh. void Promise.resolve( props.sideEffectOnSend?.(scheduledDraftId ?? null) ).catch(props.notices.reportError); diff --git a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts index 5279615d031..871c8acbf48 100644 --- a/apps/web/src/features/email-compose/primitives/send-schedule.test.ts +++ b/apps/web/src/features/email-compose/primitives/send-schedule.test.ts @@ -438,14 +438,10 @@ describe('send and schedule ordering', () => { expect(state.selectedTime()).toEqual(new Date(scheduled.sendTime)); pending.resolve(); await scheduling; - if (kind === 'standalone') { - expect(state.confirmedTime()).toEqual(new Date(scheduled.sendTime)); - expect(state.disabled()).toBe(true); - } else { - // Inline reply follows its normal successful-submit convention and - // collapses; a stale editing observation must not surface a cancel. - expect(state.scheduleState().type).toBe('editing'); - } + // Both keep showing the scheduled draft; a stale editing observation + // must not surface a cancel. + expect(state.confirmedTime()).toEqual(new Date(scheduled.sendTime)); + expect(state.disabled()).toBe(true); expect(context.notices.feedback.success).not.toHaveBeenCalledWith( 'Schedule cancelled. This email is editable again.' ); From 2af5843f17c5095f972cd030638661b8cb18c796 Mon Sep 17 00:00:00 2001 From: Peter Chinman Date: Thu, 22 Oct 2026 20:03:15 -0400 Subject: [PATCH 2/2] fix(email-compose): stop appending the quoted thread twice to reloaded replies Scheduling or sending a reply appends the quoted thread to the body, skipping it only when the editor already holds a quote node. A reloaded draft keeps that quote as plain blocks, because the server keeps the quote's class but strips the editor's markers. Every later schedule or send of that draft appended another copy. The composer already detects the saved quote on load, so delivery now skips the append when it has. --- .../primitives/compose-persistence.test.ts | 20 +++++++++++++++++++ .../primitives/reply-composer.ts | 8 ++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts index 7319932daa8..8ea87fa8324 100644 --- a/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts +++ b/apps/web/src/features/email-compose/primitives/compose-persistence.test.ts @@ -131,6 +131,26 @@ it('discards a reply draft at its server generation', async () => { ); }); +it('sends a reloaded reply without appending its quoted thread again', async () => { + const composeContext = createComposeContext(); + // The server keeps the quote's class but strips the editor's markers. + const root = mountReplyComposer(composeContext, () => message('parent'), { + draft: message('draft', { + is_draft: true, + replying_to_id: 'parent', + thread_db_id: 'thread', + body_html_sanitized: + '

Reply

Earlier
', + }), + }); + await root.sendEmail(); + root.dispose(); + const sent = vi.mocked(composeContext.delivery.sendMessage).mock.calls[0][0]; + expect(decodeBase64Utf8(sent.message.body_html ?? '')).not.toContain( + 'macro_quote' + ); +}); + it('keeps the draft editable after failed deletion and saves later edits', async () => { const composeContext = createComposeContext(); const goBack = vi.fn(); diff --git a/apps/web/src/features/email-compose/primitives/reply-composer.ts b/apps/web/src/features/email-compose/primitives/reply-composer.ts index 98a5f4ed625..fdf38de6869 100644 --- a/apps/web/src/features/email-compose/primitives/reply-composer.ts +++ b/apps/web/src/features/email-compose/primitives/reply-composer.ts @@ -565,11 +565,15 @@ export function createReplyComposer( let sendOperationVersion = 0; const [pendingDeletion, setPendingDeletion] = createSignal(false); + // A reloaded draft keeps its quoted thread as plain blocks (the server + // strips the editor's markers), so only the saved body knows it is there. + const appendsQuotedThread = () => !!replyTarget && !form.replyAppended(); + function collectDraft(forDelivery = false) { if (!forDelivery) $removeAllWatermarkNodes(editor()); const prepared = prepareEmailBody( editor(), - forDelivery && replyTarget + forDelivery && replyTarget && appendsQuotedThread() ? { replyType: form.replyType(), replyingTo: replyTarget, @@ -1218,7 +1222,7 @@ export function createReplyComposer( const prepared = prepareEmailBody( currentEditor, - replyingTo + replyingTo && appendsQuotedThread() ? { replyType: form.replyType(), replyingTo,