From ea6b20d7ffd4b9e3af0e2f762f6aedc73e097f07 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 11:21:35 +0100
Subject: [PATCH] fix: show the selection in the reading pane once rows beyond
the open message are ticked
---
.../mail/__tests__/mail-app-list-ux.test.tsx | 35 +++++++++++++++++--
components/mail/mail-app.tsx | 21 +++++++++--
2 files changed, 50 insertions(+), 6 deletions(-)
diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx
index 7f11c1bf9..b209cea91 100644
--- a/components/mail/__tests__/mail-app-list-ux.test.tsx
+++ b/components/mail/__tests__/mail-app-list-ux.test.tsx
@@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({
useWelcomeBannerDue: () => false,
}));
vi.mock('@/components/email/email-viewer', () => ({
- EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
-
{emptyPaneContent ??
no conversation selected
}{emptyPaneFooter}
+ EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
+ {email ?
reading {email.id}
: <>{emptyPaneContent ??
no conversation selected
}{emptyPaneFooter}>}
),
}));
vi.mock('@/components/email/email-list', () => ({
@@ -94,7 +94,7 @@ beforeEach(() => {
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
- selectedEmailKeys: new Set(), unreadSection: null,
+ selectedEmailKeys: new Set(), unreadSection: null, listRows: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
@@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => {
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
+ it('replaces the open message once other rows are ticked too (mail-p2-3)', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never);
+ render();
+ expect(screen.queryByText('reading m1')).toBeNull();
+ expect(listProps.host).toBeInstanceOf(HTMLElement);
+ expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
+ });
+
+ it('replaces the open message when another single row is ticked', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never);
+ render();
+ expect(screen.queryByText('reading m1')).toBeNull();
+ });
+
+ it('keeps the open message when only its own row is ticked', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never);
+ render();
+ expect(screen.getByText('reading m1')).toBeTruthy();
+ });
+
+ it('keeps the open message when its conversation row is ticked', () => {
+ useEmailStore.setState({
+ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']),
+ listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }],
+ } as never);
+ render();
+ expect(screen.getByText('reading m1')).toBeTruthy();
+ });
+
it('says nothing is selected without a selection', () => {
render();
expect(screen.getByText('no conversation selected')).toBeTruthy();
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index bdc888cfc..4853163de 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -4927,8 +4927,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
+ // Rows ticked beyond the open message take the reading pane (mail-p2-3):
+ // the selection bar and the keys act on the ticked rows, so a reader
+ // toolbar acting on the one open message would be a second, different set.
+ // Unticking brings the message back. Mountain View keeps the message while
+ // rows are ticked (#1231); the focus layout can't tick rows while reading.
+ const openRowKeys = (() => {
+ if (!selectedEmail) return null;
+ const key = emailKeyFor(selectedEmail);
+ // A conversation row ticks all its messages: the open one's whole row.
+ const row = useEmailStore.getState().listRows?.find((r) => r.key === key || r.members.includes(key));
+ return new Set(row ? [key, ...row.members] : [key]);
+ })();
+ const selectionTakesReader = !isMobile && !isFocusedMailLayout && !mvShell && !isScheduledView
+ && selectedEmailKeys.size > 0
+ && !(openRowKeys && [...selectedEmailKeys].every((key) => openRowKeys.has(key)));
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
- || Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
+ || Boolean(selectedEmail && !(mvShell && cursorOnly) && !selectionTakesReader) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
@@ -6106,9 +6121,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<>