diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c28c99888..11ba738eb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -94,7 +94,7 @@ import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-ut import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { buildQuoteHeader } from "@/lib/quote-header"; -import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; import { getEffectiveLocale } from '@/i18n/detect-locale'; import { @@ -913,17 +913,14 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { } : undefined); const effectiveMode = pendingDraft?.mode ?? composerMode; - const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? ''; - let title = t('email_composer.new_message'); - if (baseSubject) { - if (effectiveMode === 'reply' || effectiveMode === 'replyAll') { - title = buildReplySubject(baseSubject, t('email_composer.prefix.reply')); - } else if (effectiveMode === 'forward') { - title = buildForwardSubject(baseSubject, t('email_composer.prefix.forward')); - } else { - title = baseSubject; - } - } + const title = buildComposeTabTitle({ + mode: effectiveMode, + draftSubject: pendingDraft?.subject, + sourceSubject: selectedEmail?.subject, + replyPrefix: t('email_composer.prefix.reply'), + forwardPrefix: t('email_composer.prefix.forward'), + fallback: t('email_composer.new_message'), + }); useProTabStore.getState().openComposeTab({ sessionId: composerSessionId + 1, diff --git a/lib/__tests__/subject-prefix.test.ts b/lib/__tests__/subject-prefix.test.ts index bb514754b..85cc61d26 100644 --- a/lib/__tests__/subject-prefix.test.ts +++ b/lib/__tests__/subject-prefix.test.ts @@ -3,6 +3,7 @@ import { stripSubjectPrefixes, buildReplySubject, buildForwardSubject, + buildComposeTabTitle, } from '@/lib/subject-prefix'; describe('stripSubjectPrefixes', () => { @@ -54,3 +55,33 @@ describe('buildReplySubject / buildForwardSubject', () => { expect(buildReplySubject('', 'AW:')).toBe('AW:'); }); }); + +describe('buildComposeTabTitle', () => { + const prefixes = { replyPrefix: 'Re:', forwardPrefix: 'Fwd:', fallback: 'New message' }; + + it('never inherits the selected email subject for a fresh compose (#856)', () => { + expect(buildComposeTabTitle({ mode: 'compose', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('New message'); + }); + + it('uses the draft subject for a fresh compose when one exists (mailto, draft edit)', () => { + expect(buildComposeTabTitle({ mode: 'compose', draftSubject: 'Lunch?', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('Lunch?'); + }); + + it('derives reply and forward titles from the source subject', () => { + expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: 'Invoice August 2026', ...prefixes })) + .toBe('Re: Invoice August 2026'); + expect(buildComposeTabTitle({ mode: 'replyAll', sourceSubject: 'AW: Invoice', ...prefixes })) + .toBe('Re: Invoice'); + expect(buildComposeTabTitle({ mode: 'forward', sourceSubject: 'Invoice', ...prefixes })) + .toBe('Fwd: Invoice'); + }); + + it('prefers a resumed draft subject over the source and falls back when both are blank', () => { + expect(buildComposeTabTitle({ mode: 'reply', draftSubject: 'Re: edited', sourceSubject: 'Original', ...prefixes })) + .toBe('Re: edited'); + expect(buildComposeTabTitle({ mode: 'reply', sourceSubject: ' ', ...prefixes })) + .toBe('New message'); + }); +}); diff --git a/lib/subject-prefix.ts b/lib/subject-prefix.ts index 766155a7d..e61e9f256 100644 --- a/lib/subject-prefix.ts +++ b/lib/subject-prefix.ts @@ -119,3 +119,26 @@ export function buildForwardSubject(subject: string | undefined | null, prefix: const stripped = stripSubjectPrefixes(subject); return stripped ? `${prefix} ${stripped}` : prefix; } + +/** + * Working title for a compose tab. Reply and forward derive it from the + * message they act on; a fresh compose must NOT inherit the subject of + * whatever email happens to be selected in the list (#856) - it starts on + * the fallback ("New message") until a draft subject exists. + */ +export function buildComposeTabTitle(opts: { + mode: 'compose' | 'reply' | 'replyAll' | 'forward'; + draftSubject?: string | null; + sourceSubject?: string | null; + replyPrefix: string; + forwardPrefix: string; + fallback: string; +}): string { + const draft = opts.draftSubject?.trim() ?? ''; + const source = opts.mode === 'compose' ? '' : (opts.sourceSubject?.trim() ?? ''); + const base = draft || source; + if (!base) return opts.fallback; + if (opts.mode === 'reply' || opts.mode === 'replyAll') return buildReplySubject(base, opts.replyPrefix); + if (opts.mode === 'forward') return buildForwardSubject(base, opts.forwardPrefix); + return base; +}