fix: keep the assistant panel's button in the same place with no message open

This commit is contained in:
Linus Rath
2026-11-02 20:55:26 +01:00
parent 55f1875f20
commit c3da79e7d5
2 changed files with 97 additions and 51 deletions
@@ -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 }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...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();
+38 -51
View File
@@ -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 ? (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8 shrink-0", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
) : 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 ? (
<div
data-viewer-top-toolbar="full-width"
data-toolbar-toggle-only=""
className={cn("bg-background flex-shrink-0", !mvViewer && "border-b border-border")}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center justify-end", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
{panelToggle}
</div>
</div>
) : 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 && (
<div className="contents" data-reader-side-panel="">
{/* With a message open the toggle sits in its toolbar; without one, here. */}
{sidebarCollapsed && !panelToggleInToolbar && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
@@ -3903,22 +3917,6 @@ ${printWatermark ? printWatermark.html : ''}
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
{!panelToggleInToolbar && (
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
@@ -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}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
@@ -4527,22 +4525,11 @@ ${printWatermark ? printWatermark.html : ''}
)}
</div>
{/* The plugin side panel: shown or hidden for every message alike. */}
{hasDetailSidebar && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
)}
{toolbarPosition === 'top' && panelToggle}
</div>
)}
{/* 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. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
<>