From 09becdfd9ddd6d42390c7c76e2036fec48e33cd7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 07:55:33 +0100
Subject: [PATCH] fix: follow the typed subject in a Pro compose tab's title
---
components/email/email-composer.tsx | 7 +++++
.../__tests__/pro-compose-tab-title.test.tsx | 31 ++++++++++++-------
components/pro/pro-compose-tab-body.tsx | 23 ++++++--------
3 files changed, 36 insertions(+), 25 deletions(-)
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index d7a9bd994..b2e4b5aff 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -348,6 +348,8 @@ interface EmailComposerProps {
*/
onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void;
onSaveState?: (data: ComposerDraftData) => void;
+ /** The subject as typed, for a host that shows it (a Pro compose tab's title). */
+ onSubjectChange?: (subject: string) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
@@ -494,6 +496,7 @@ export function EmailComposer({
requestCloseRef,
onDiscardDraft,
onSaveState,
+ onSubjectChange,
className,
initialDraftText: initialDraftTextProp,
initialData: initialDataProp,
@@ -1828,6 +1831,10 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSessionKey]);
+ useEffect(() => {
+ onSubjectChange?.(subject);
+ }, [onSubjectChange, subject]);
+
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
diff --git a/components/pro/__tests__/pro-compose-tab-title.test.tsx b/components/pro/__tests__/pro-compose-tab-title.test.tsx
index d2da40b07..8d1246393 100644
--- a/components/pro/__tests__/pro-compose-tab-title.test.tsx
+++ b/components/pro/__tests__/pro-compose-tab-title.test.tsx
@@ -5,7 +5,13 @@ import React from 'react';
// The mail tab hands every compose tab the selected message as replyTo, a new
// message too. A new message was titled after whatever message was open.
-vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => React.createElement('div') }));
+const composer = vi.hoisted(() => ({ onSubjectChange: null as ((s: string) => void) | null }));
+vi.mock('@/components/email/email-composer', () => ({
+ EmailComposer: (props: { onSubjectChange: (s: string) => void }) => {
+ composer.onSubjectChange = props.onSubjectChange;
+ return React.createElement('div');
+ },
+}));
vi.mock('@/components/error', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
ComposerErrorFallback: () => null,
@@ -35,20 +41,23 @@ import { ProComposeTabBody } from '../pro-compose-tab-body';
const selected = { subject: 'Invoice August' };
describe('Pro compose tab title', () => {
- beforeEach(() => updateTabTitle.mockClear());
-
- it('stays the new-message title with a message selected in the mail tab', () => {
- render();
- expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'New message');
+ beforeEach(() => {
+ updateTabTitle.mockClear();
+ composer.onSubjectChange = null;
});
- it('keeps the prefix the opener gave a reply', () => {
- render();
- expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Re: Invoice August');
+ it('does not take the title of the message selected in the mail tab', () => {
+ render();
+ composer.onSubjectChange!('');
+ expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'email_composer.new_message');
+ expect(updateTabTitle).not.toHaveBeenCalledWith('t', 'Invoice August');
});
- it('follows the subject of a reopened draft', () => {
- render();
+ it('follows the subject as it is typed, and falls back when it is emptied', () => {
+ render();
+ composer.onSubjectChange!('Lunch?');
expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'Lunch?');
+ composer.onSubjectChange!(' ');
+ expect(updateTabTitle).toHaveBeenLastCalledWith('t', 'email_composer.new_message');
});
});
diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx
index 33d2ba483..49e30379b 100644
--- a/components/pro/pro-compose-tab-body.tsx
+++ b/components/pro/pro-compose-tab-body.tsx
@@ -195,21 +195,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const handleSaveState = useCallback((state: ComposerDraftData) => {
updateComposeDraft(tabIdRef.current, state);
- // Keep the tab title in sync with the working subject.
- const subject = state.subject?.trim() || t('email_composer.new_message');
- updateTabTitle(tabIdRef.current, subject);
- }, [updateComposeDraft, updateTabTitle, t]);
+ }, [updateComposeDraft]);
- // On first mount, ensure the tab title matches whatever subject we were
- // initialised with. The opener's title already says "Re: …" or "Fwd: …" for
- // a reply or forward. replyTo is no source for it: the mail tab fills that
- // in from the selected message for a new message too.
- useEffect(() => {
- const title = data.initialData?.subject?.trim() || data.title || t('email_composer.new_message');
- updateTabTitle(tabIdRef.current, title);
- // Run once on mount only.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ // The tab's title is the subject as it is typed, so several open compose
+ // tabs can be told apart. replyTo is no source for it: the mail tab fills
+ // that in from the selected message for a new message too.
+ const newMessageTitle = t('email_composer.new_message');
+ const handleSubjectChange = useCallback((subject: string) => {
+ updateTabTitle(tabIdRef.current, subject.trim() || newMessageTitle);
+ }, [updateTabTitle, newMessageTitle]);
// Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`).
// Without it `mode === 'compose'` resolves `findComposeIdentityId(identities,
@@ -252,6 +246,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
requestCloseRef={requestCloseRef}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
+ onSubjectChange={handleSubjectChange}
className="flex-1"
/>