feat: name the active account in the browser-tab title

The tab title was "<context> - <app>", so two mailboxes open side by side
read the same in the tab strip and in the browser's session-restore list.

It is now "<context> - <account> - <app>", the order Gmail and Outlook Web
use: browsers cut a long title from the end, so the part that changes comes
first and the app name last. There is no setting for it; the account is what
tells the tabs apart.

The formatting, and the mail view's choice of context (composer, then
message, then mailbox), live in lib/tab-title.ts with tests. The mail view
sets its title through useDocumentTitle(), which other surfaces can use too.
Next renders the root layout's metadata <title> again on every client-side
navigation, after the surface has mounted; the hook puts its title back when
that happens. Only the surface that set the title last does so, so two
titles never fight.

Refs #1159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oğuz Yılmaz
2026-10-07 08:26:23 +03:00
co-authored by Claude Opus 5.5
parent ccf6bf7706
commit 8ca7dbbe9b
5 changed files with 222 additions and 30 deletions
+26 -30
View File
@@ -99,7 +99,8 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useThemeStore } from "@/stores/theme-store";
@@ -165,7 +166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const t = useTranslations();
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
// Phones present search full-screen from the header field rather than
// giving it a permanent second bar under the header.
@@ -943,37 +943,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return () => clearTimeout(timer);
}, [clearPendingUndoSend, pendingUndoSend]);
// Update page title based on context
useEffect(() => {
let title = appName;
// Page title: "<context> - <account> - <app>" (see useDocumentTitle)
const titleContext = useMemo(() => {
// Composing email
const composer = showComposer
? {
compose: t('email_composer.new_message'),
reply: t('email_composer.reply'),
replyAll: t('email_composer.reply_all'),
forward: t('email_composer.forward'),
}[composerMode] || t('email_composer.new_message')
: null;
if (showComposer) {
// Composing email
const modeText = {
compose: t('email_composer.new_message'),
reply: t('email_composer.reply'),
replyAll: t('email_composer.reply_all'),
forward: t('email_composer.forward'),
}[composerMode] || t('email_composer.new_message');
title = `${modeText} - ${appName}`;
} else if (selectedEmail) {
// Reading email
const subject = selectedEmail.subject || t('email_viewer.no_subject');
title = `${subject} - ${appName}`;
} else if (selectedMailbox && mailboxes.length > 0) {
// Mailbox view
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
if (mailbox) {
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0;
title = unreadCount > 0
? `${mailboxName} (${unreadCount}) - ${appName}`
: `${mailboxName} - ${appName}`;
}
// Reading email
const subject = selectedEmail ? selectedEmail.subject || t('email_viewer.no_subject') : null;
// Mailbox view
let mailboxLine: string | null = null;
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
if (mailbox) {
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
const unreadCount = mailbox.unreadEmails || 0;
mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
}
document.title = title;
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]);
return mailTitleContext({ composer, subject, mailbox: mailboxLine });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
useDocumentTitle(titleContext);
// When this page is rendered inside the Pro shell as the Mail tab body,
// we hoist every "show composer" intent into its own Pro tab and reset
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Acme Mail' }) }));
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
const account = (id: string, email: string) => ({ id, email }) as AccountEntry;
/**
* What Next does on a client-side navigation: it renders the root layout's
* metadata <title> again, after the new surface has mounted.
*/
function rerenderMetadataTitle(title = 'Acme Mail'): void {
document.head.querySelectorAll('title').forEach((el) => el.remove());
const el = document.createElement('title');
el.textContent = title;
document.head.appendChild(el);
}
/** Drains MutationObserver callbacks. */
async function settle(): Promise<void> {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
describe('useDocumentTitle', () => {
beforeEach(() => {
document.title = '';
useAccountStore.setState({
accounts: [account('a', 'jane@example.com'), account('b', 'joe@example.com')],
activeAccountId: 'a',
});
});
it('names the active account between the context and the app', () => {
renderHook(() => useDocumentTitle('Calendar'));
expect(document.title).toBe('Calendar - jane@example.com - Acme Mail');
});
it('follows an account switch', () => {
renderHook(() => useDocumentTitle('Calendar'));
act(() => useAccountStore.setState({ activeAccountId: 'b' }));
expect(document.title).toBe('Calendar - joe@example.com - Acme Mail');
});
it('shows the account and the app when the surface adds no context', () => {
renderHook(() => useDocumentTitle());
expect(document.title).toBe('jane@example.com - Acme Mail');
});
it('shows the context and the app when no account is in view', () => {
useAccountStore.setState({ accounts: [], activeAccountId: null });
renderHook(() => useDocumentTitle('Settings'));
expect(document.title).toBe('Settings - Acme Mail');
});
it('puts its title back when the metadata title is rendered again', async () => {
renderHook(() => useDocumentTitle('Settings'));
rerenderMetadataTitle();
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
});
it('leaves the title to the surface that set it last', async () => {
renderHook(() => useDocumentTitle('Inbox'));
renderHook(() => useDocumentTitle('Settings'));
rerenderMetadataTitle();
await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail'));
await settle();
expect(document.title).toBe('Settings - jane@example.com - Acme Mail');
});
it('stops re-applying once unmounted', async () => {
const { unmount } = renderHook(() => useDocumentTitle('Settings'));
unmount();
rerenderMetadataTitle('Sign in');
await settle();
expect(document.title).toBe('Sign in');
});
});
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from 'react';
import { useConfig } from '@/hooks/use-config';
import { useAccountStore } from '@/stores/account-store';
import { formatTabTitle } from '@/lib/tab-title';
// The surface that set the title last owns it. One that is still mounted
// (during a route transition) stops re-applying, so two titles never fight.
let owner: symbol | null = null;
/**
* Sets the browser-tab title for a surface: "<context> - <account> - <app>"
* (see formatTabTitle). Pass a context that is already localized, or nothing
* for a surface that has nothing to add.
*
* Next renders the root layout's metadata <title> again on every client-side
* navigation, after the new surface has mounted, which puts the bare app name
* back. So the title is re-applied whenever <head> changes and it is not ours.
*/
export function useDocumentTitle(context?: string | null): void {
const { appName } = useConfig();
// The account actually in view, so two windows on two accounts stay apart.
const account = useAccountStore(
(s) => s.accounts.find((a) => a.id === s.activeAccountId)?.email || null,
);
const title = formatTabTitle(context, account, appName);
useEffect(() => {
const self = Symbol('document-title');
owner = self;
// Termination: setting the title is itself a <head> mutation; on that run
// the title is already ours and nothing is written.
const apply = () => {
if (owner === self && document.title !== title) document.title = title;
};
apply();
const observer = new MutationObserver(apply);
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
if (owner === self) owner = null;
};
}, [title]);
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { formatTabTitle, mailTitleContext } from '../tab-title';
describe('formatTabTitle', () => {
it('puts the context first, the account next and the app name last', () => {
expect(formatTabTitle('Inbox (3)', 'jane@example.com', 'Acme Mail')).toBe(
'Inbox (3) - jane@example.com - Acme Mail',
);
});
it('leaves out a missing account', () => {
expect(formatTabTitle('Inbox', null, 'Acme Mail')).toBe('Inbox - Acme Mail');
expect(formatTabTitle('Inbox', '', 'Acme Mail')).toBe('Inbox - Acme Mail');
});
it('leaves out a missing context', () => {
expect(formatTabTitle(undefined, 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
expect(formatTabTitle('', 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail');
});
it('falls back to the app name alone', () => {
expect(formatTabTitle(null, null, 'Acme Mail')).toBe('Acme Mail');
});
});
describe('mailTitleContext', () => {
const view = { composer: 'Reply', subject: 'Quarterly report', mailbox: 'Inbox (3)' };
it('names the composer while it is open', () => {
expect(mailTitleContext(view)).toBe('Reply');
});
it('names the open message otherwise', () => {
expect(mailTitleContext({ ...view, composer: null })).toBe('Quarterly report');
});
it('names the mailbox when no message is open', () => {
expect(mailTitleContext({ mailbox: 'Inbox (3)' })).toBe('Inbox (3)');
});
it('has nothing to add before a mailbox is selected', () => {
expect(mailTitleContext({})).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
// Browser-tab titles read "<context> - <account> - <app>", the order Gmail and
// Outlook Web use. Browsers cut a long title from the end, so the part that
// changes (folder, unread count, subject) goes first and the app name last.
// The account sits in between, so two mailboxes open side by side stay apart
// in the tab strip and in session restore. Empty parts are left out. (#1159)
export function formatTabTitle(
context: string | null | undefined,
account: string | null | undefined,
appName: string,
): string {
return [context, account, appName].filter(Boolean).join(' - ');
}
/** What the mail view is showing, already localized. */
export interface MailTitleView {
/** Composer label while the composer is open ("New message", "Reply", ...). */
composer?: string | null;
/** Subject of the open message. */
subject?: string | null;
/** Selected mailbox, with its unread count when there is one. */
mailbox?: string | null;
}
/** The context part of the mail view's tab title: composer, then message, then mailbox. */
export function mailTitleContext(view: MailTitleView): string | null {
return view.composer || view.subject || view.mailbox || null;
}