From d6ae84f5e88fab6a96c5183c7c328ff882921a6b Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 18 Aug 2026 15:39:02 +0200 Subject: [PATCH] fix: keep the fresh-compose tab title clear of the selected email subject The Pro-mode bridge that turns an inline compose intent into a compose tab derived its working title from the draft subject or, failing that, the selected email's subject - regardless of mode. For reply/forward the selected email is the message being acted on; for a fresh compose it is whatever happens to be highlighted in the list, so "New Email" opened with an unrelated subject in the tab strip (#856). Extract the rule into buildComposeTabTitle: a fresh compose only ever uses its own draft subject (mailto, resumed draft) and otherwise starts on the "New message" fallback. Typing a subject already renames the tab live via handleSaveState, so nothing else changes. --- components/mail/mail-app.tsx | 21 ++++++++----------- lib/__tests__/subject-prefix.test.ts | 31 ++++++++++++++++++++++++++++ lib/subject-prefix.ts | 23 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 85b7036bf..f6f69620b 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 { @@ -904,17 +904,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; +}