From c3da79e7d5d86c1cdd384229329aa875b95398b4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:55:26 +0100 Subject: [PATCH 01/58] fix: keep the assistant panel's button in the same place with no message open --- .../viewer-toolbar-over-side-panel.test.tsx | 59 ++++++++++++ components/email/email-viewer.tsx | 89 ++++++++----------- 2 files changed, 97 insertions(+), 51 deletions(-) diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx index 37b707f9e..67d01b6ce 100644 --- a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx +++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx @@ -41,6 +41,7 @@ class NoopResizeObserver { vi.stubGlobal('ResizeObserver', NoopResizeObserver); const { EmailViewer } = await import('../email-viewer'); +const { useSettingsStore } = await import('@/stores/settings-store'); const mailboxes = [ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, @@ -68,8 +69,24 @@ const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTML afterEach(() => { cleanup(); device.isMobile = false; + useSettingsStore.setState({ toolbarPosition: 'top' }); }); +const view = (props: { email: Email | null; isLoading: boolean }) => ( + {}} {...props} /> +); +/** The panel button: there is exactly one, in a toolbar row at the top of the reader, never in the panel. */ +const theToggle = () => { + const all = document.querySelectorAll('[data-plugin-panel-toggle]'); + expect(all).toHaveLength(1); + const toggle = all[0] as HTMLElement; + expect(toggle.closest('[data-viewer-top-toolbar="full-width"]')).toBeTruthy(); + expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull(); + const root = document.querySelector('[data-tour="email-viewer"]') ?? toggle.closest('.relative.flex-col'); + expect(root!.firstElementChild!.contains(toggle)).toBe(true); + return toggle; +}; + describe('the reader toolbar and the plugin side panel', () => { it('runs the toolbar over the side panel on a desktop', () => { show(); @@ -131,6 +148,48 @@ describe('the reader toolbar and the plugin side panel', () => { expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy(); }); + it('puts the panel button in the same toolbar row with no message open, folded or not, and while one loads', () => { + const { rerender } = render(view({ email: null, isLoading: false })); + const row = () => theToggle().closest('[data-viewer-top-toolbar]') as HTMLElement; + expect(row().hasAttribute('data-toolbar-toggle-only')).toBe(true); + // As high as the full toolbar. + expect(row().firstElementChild!.className).toContain('h-14'); + expect(theToggle().getAttribute('title')).toBe('plugin_panel_hide'); + act(() => { fireEvent.click(theToggle()); }); + // Folded: no strip and no row of the panel's own, the same button in the same row. + expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull(); + expect(document.querySelector('[data-reader-side-panel]')!.children).toHaveLength(0); + expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel_show'); + expect(theToggle().getAttribute('aria-pressed')).toBe('false'); + act(() => { fireEvent.click(theToggle()); }); + // Open: the panel holds no button row above the plugin's own header. + const panel = document.querySelector('[data-plugin-detail-panel]') as HTMLElement; + expect(panel.querySelector('button')).toBeNull(); + rerender(view({ email: null, isLoading: true })); + theToggle(); + }); + + it('keeps the panel button for a scheduled message', () => { + const scheduled = { ...email, id: 'e3', isScheduled: true, scheduledSendAt: '2030-01-01T10:00:00Z', scheduledUndoStatus: 'pending' } as unknown as Email; + render(view({ email: scheduled, isLoading: false })); + theToggle(); + }); + + it('keeps the panel button at the top when the toolbar is below the subject', () => { + useSettingsStore.setState({ toolbarPosition: 'below-subject' }); + render(view({ email, isLoading: false })); + expect(theToggle().closest('[data-toolbar-toggle-only]')).toBeTruthy(); + }); + + it('keeps the side panel frame from a message to none and back', () => { + mounts['email-detail-sidebar'] = 0; + const { rerender } = render(view({ email, isLoading: false })); + rerender(view({ email: null, isLoading: true })); + rerender(view({ email: null, isLoading: false })); + rerender(view({ email, isLoading: false })); + expect(mounts['email-detail-sidebar']).toBe(1); + }); + it('keeps the toolbar in the message column on a phone', () => { device.isMobile = true; show(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index c5b85d991..65c95b116 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1883,6 +1883,22 @@ export function EmailViewer({ setDetailSidebarForcedOpen(false); } }; + // The one button that shows and hides the plugin side panel, at the end of + // the toolbar row in every state of the reader (message, loading, none, scheduled). + const panelToggle = hasDetailSidebar && !isMobile ? ( + + ) : null; useEffect(() => onPanelRequest((request) => { if (request.slot !== 'email-detail-sidebar') return null; const state = detailPanelRef.current; @@ -3851,31 +3867,29 @@ ${printWatermark ? printWatermark.html : ''} // eslint-disable-next-line react-hooks/exhaustive-deps }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // The toolbar row with only the panel button: over the empty or loading + // reader, and on top when the user put the toolbar below the subject. Same + // height and place as the full toolbar, so the button never moves. + const toggleOnlyToolbar = panelToggle && liftToolbar ? ( +
+
+ {panelToggle} +
+
+ ) : null; + // The plugin side panel. One element in the same place whatever the viewer // shows (a message, the next one loading, none), so its frame lives on while - // the user reads on instead of starting over with every message. - const panelToggleInToolbar = !!email; + // the user reads on instead of starting over with every message. Its button + // is always at the end of the toolbar row above it. const sidePanel = ( <> {hasDetailSidebar && !isMobile && (
- {/* With a message open the toggle sits in its toolbar; without one, here. */} - {sidebarCollapsed && !panelToggleInToolbar && ( -
- -
- )} {!sidebarCollapsed && ( <> {!sidebarOverlay && ( @@ -3903,22 +3917,6 @@ ${printWatermark ? printWatermark.html : ''} ? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) } : { width: detailSidebarWidth, minWidth: detailSidebarWidth }} > - {!panelToggleInToolbar && ( -
- -
- )}
@@ -3934,7 +3932,7 @@ ${printWatermark ? printWatermark.html : ''} ref={setViewerRootEl} className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)} > - {null} + {toggleOnlyToolbar}
{content} {sidePanel} @@ -4527,22 +4525,11 @@ ${printWatermark ? printWatermark.html : ''} )}
{/* The plugin side panel: shown or hidden for every message alike. */} - {hasDetailSidebar && !isMobile && ( - - )} + {toolbarPosition === 'top' && panelToggle}
)} + {/* A scheduled message has no organize actions; the panel button still ends the row. */} + {isScheduled && toolbarPosition === 'top' && panelToggle} ); @@ -4579,7 +4566,7 @@ ${printWatermark ? printWatermark.html : ''} data-tour="email-viewer" className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)} > - {toolbarPosition === 'top' && liftToolbar && topToolbar} + {toolbarPosition === 'top' && liftToolbar ? topToolbar : toggleOnlyToolbar} {/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<> From 6db1f3d373a97056fa79140c28493af929af2b59 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:59:53 +0100 Subject: [PATCH 02/58] fix: give the composer's side panels the reader's button and header --- .../composer-plugin-surfaces.test.tsx | 30 ++++++ components/email/composer-plugin-sidebars.tsx | 98 ++++++++++--------- components/email/email-composer.tsx | 23 ++++- 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index c9812f7c9..3293748af 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -680,6 +680,36 @@ describe('composer-sidebar and composer-sidebar-right', () => { } }); + it('show and hide the panels from the header, the left one at its start and the right one at its end', () => { + stubWidth(1400); + offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right'], { startFolded: ['composer-sidebar-right'] }); + render(); + const header = document.querySelector('[data-composer-header]') as HTMLElement; + // As high as the reader's toolbar and the panels' own headers. + expect(header.className).toContain('md:h-14'); + const left = header.querySelector('[data-composer-panel-toggle="left"]') as HTMLElement; + const right = header.querySelector('[data-composer-panel-toggle="right"]') as HTMLElement; + expect(left).toBeTruthy(); + expect(right).toBeTruthy(); + // Left first; right just before Close, at the end. + expect(left.compareDocumentPosition(right) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(right.nextElementSibling).toBe(screen.getByTestId('composer-close')); + // The panels hold no buttons of their own: no strip, no row above the plugin. + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + expect(panel('right').hidden).toBe(true); + expect(panel('left').querySelector('button')).toBeNull(); + expect(right.getAttribute('aria-pressed')).toBe('false'); + + // Opening by hand asks the panel to take the focus, as a plugin's request does. + fireEvent.click(right); + expect(panel('right').dataset.composerSidebarMode).toBe('docked'); + expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).focusRequest).toBe(1); + expect(right.getAttribute('aria-pressed')).toBe('true'); + expect(right.getAttribute('aria-label')).toBe('plugin_panel_hide'); + fireEvent.click(right); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + }); + it('open a folded panel over the composer when there is no room beside it', () => { stubWidth(600); offerSlots('p1', ['composer-sidebar-right']); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index b3ba0beda..a0a2bf1b4 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -1,9 +1,11 @@ "use client"; // The composer-sidebar and composer-sidebar-right plugin panels. Each has a -// width the user can drag (kept across composers) and a toggle to fold it -// to a thin strip. When the composer is too narrow for an open panel next -// to it, the panel folds by itself (right first); opening it anyway lays it +// width the user can drag (kept across composers) and a button in the +// composer's header that shows and hides it (the left panel's at the start, +// the right one's at the end, like the reader's). Folded, a panel takes no +// room at all. When the composer is too narrow for an open panel next to +// it, the panel folds by itself (right first); opening it anyway lays it // over the composer instead of squeezing the message into a column nobody // can write in. @@ -20,8 +22,6 @@ import { cn } from "@/lib/utils"; export const COMPOSER_SIDEBAR_WIDTH = 260; export const MIN_COMPOSER_SIDEBAR_WIDTH = 220; export const MAX_COMPOSER_SIDEBAR_WIDTH = 480; -/** Width of a folded panel (the strip with its toggle). */ -export const COMPOSER_SIDEBAR_STRIP = 37; /** Below this window width the panels are not shown at all (the `md` breakpoint). */ export const COMPOSER_SIDEBAR_MIN_WINDOW = 768; /** @@ -31,7 +31,7 @@ export const COMPOSER_SIDEBAR_MIN_WINDOW = 768; export const MIN_COMPOSER_WIDTH = 560; export type ComposerSidebarSide = "left" | "right"; -/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */ +/** docked: open beside the composer; folded: hidden; overlay: open over the composer. */ export type ComposerSidebarMode = "docked" | "folded" | "overlay"; interface SideState { @@ -54,12 +54,11 @@ export function composerSidebarModes( left: SideState, right: SideState, ): Record { - const present = (left.present ? 1 : 0) + (right.present ? 1 : 0); - let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity; + let room = width > 0 ? width - MIN_COMPOSER_WIDTH : Infinity; const place = (side: SideState): ComposerSidebarMode | null => { if (!side.present) return null; if (side.collapsed) return "folded"; - const extra = (side.width ?? COMPOSER_SIDEBAR_WIDTH) - COMPOSER_SIDEBAR_STRIP; + const extra = side.width ?? COMPOSER_SIDEBAR_WIDTH; if (extra <= room) { room -= extra; return "docked"; @@ -186,55 +185,73 @@ export function useComposerSidebars(root: HTMLElement | null, composerId: string return wantOpen; }), []); - return { modes, open, close, focusRequests, widths, setSideWidth }; + /** The header button: open (and focus the panel, as a plugin's request does) or close. */ + const toggle = (side: ComposerSidebarSide) => { + if (modes[side] === "docked" || modes[side] === "overlay") { + close(side); + } else if (modes[side]) { + open(side); + setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 })); + } + }; + + return { modes, open, close, toggle, focusRequests, widths, setSideWidth }; +} + +/** The button in the composer's header that shows and hides one panel (none on phones). */ +export function ComposerPanelToggle({ + side, + mode, + onToggle, + className, +}: { + side: ComposerSidebarSide; + mode: ComposerSidebarMode | null; + onToggle: () => void; + className?: string; +}) { + const t = useTranslations("email_viewer"); + if (!mode) return null; + const isOpen = mode !== "folded"; + const Icon = side === "left" ? (isOpen ? PanelLeftClose : PanelLeftOpen) : (isOpen ? PanelRightClose : PanelRightOpen); + return ( + + ); } /** One plugin panel of the composer, in the mode useComposerSidebars picked. */ export function ComposerPluginSidebar({ side, mode, - onOpen, - onClose, extraProps, width = COMPOSER_SIDEBAR_WIDTH, onWidthChange, }: { side: ComposerSidebarSide; mode: ComposerSidebarMode | null; - onOpen: () => void; - onClose: () => void; /** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */ extraProps?: Record; width?: number; /** The user drags the panel's edge; without it the panel has no handle. */ onWidthChange?: (width: number) => void; }) { - const t = useTranslations("email_viewer"); const dragStart = useRef(width); if (!mode) return null; const border = side === "left" ? "border-e" : "border-s"; - const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen; - const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose; + // Folded: nothing on screen (the header button brings it back). if (mode === "folded") { - return ( -
- -
- ); + return