diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 02c24baf8..05772d4f5 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -366,8 +366,9 @@ describe('composer-options-menu and composer-toolbar-actions', () => { const button = screen.getByRole('button', { name: 'Assistant' }); expect(button).toHaveAttribute('title', 'Assistant (Alt+A)'); expect(button.className).toContain('h-9'); - // Icon only unless the bar is wide enough for the label next to the app's buttons. - expect(button.querySelector('span')!.className).toContain('@[44rem]/composer-bar:inline'); + // In the bar only when it is wide enough next to the app's own buttons... + expect(button.className).toMatch(/(^|\s)hidden(\s|$)/); + expect(button.className).toContain('@[44rem]/composer-bar:inline-flex'); let bar: HTMLElement | null = button; while (bar && !bar.classList.contains('@container/composer-bar')) bar = bar.parentElement; expect(bar).not.toBeNull(); @@ -375,6 +376,14 @@ describe('composer-options-menu and composer-toolbar-actions', () => { expect(button.closest('[data-composer-plugin-row]')).toBeNull(); await act(async () => { fireEvent.click(button); }); expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) }); + + // ...and otherwise in the Options menu, which is shown only while narrow. + dispatch.mockClear(); + fireEvent.click(screen.getByTestId('composer-options-menu')); + const entry = screen.getByRole('menuitem', { name: 'Assistant' }); + expect(entry.closest('[data-narrow-only]')!.className).toContain('@[44rem]/composer-bar:hidden'); + await act(async () => { fireEvent.click(entry); }); + expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) }); }); it('refuses odd values for checked and description', () => { diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index b0a9925f1..eff707bc4 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -150,12 +150,19 @@ interface PluginItemsProps { onSelect?: () => void; /** Draw a separator before the entries (when the menu has its own above). */ separator?: boolean; + /** Only while the composer bar is narrower than 44rem (see ComposerPluginToolbarButtons). */ + onlyWhenNarrow?: boolean; } /** * Buttons plugins declared for the composer's bottom bar * (composer-toolbar-actions), drawn like Attach and Templates. An action's * shortcut is shown in its tooltip; the composer binds it. + * + * The bar is a container (`@container/composer-bar`). Below 44rem there is + * no room next to the app's own buttons, Discard and Send, so the buttons + * are hidden and the same actions are listed in the Options menu instead + * (ComposerPluginMenuItems with `onlyWhenNarrow`). */ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "composer-toolbar-actions"; context: () => Record }) { const actions = usePluginActions(surface); @@ -170,7 +177,7 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c key={`${action.pluginId}:${action.id}`} type="button" variant="ghost" - className={cn("h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")} + className={cn("hidden @[44rem]/composer-bar:inline-flex h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")} title={tip} aria-label={action.label} aria-keyshortcuts={action.shortcut} @@ -178,9 +185,7 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c onClick={() => { void runAction(action, surface, context()); }} > - {/* The label only when the bar has room for it next to the app's own - buttons; otherwise the icon, with the label as tooltip. */} - {action.label} + {action.label} ); })} @@ -188,12 +193,18 @@ export function ComposerPluginToolbarButtons({ surface, context }: { surface: "c ); } +/** Shows its children only while the composer bar is narrower than 44rem. */ +function NarrowOnly({ active, children }: { active: boolean; children: React.ReactNode }) { + if (!active) return <>{children}; + return
{children}
; +} + /** Entries plugins declared for a composer menu, drawn like the menu's own. */ -export function ComposerPluginMenuItems({ surface, context, onSelect, separator }: PluginItemsProps) { +export function ComposerPluginMenuItems({ surface, context, onSelect, separator, onlyWhenNarrow }: PluginItemsProps) { const actions = usePluginActions(surface); if (actions.length === 0) return null; return ( - <> + {separator &&
} {actions.map((action) => { const Icon = iconForName(action.icon) ?? Puzzle; @@ -234,6 +245,6 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator ); })} - + ); } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 14e89eca7..5cff52511 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -4118,6 +4118,7 @@ export function EmailComposer({ )} + } diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index a7fbee598..e3bb43df2 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -19,7 +19,7 @@ import { usePluginStore } from '@/stores/plugin-store'; import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons'; +import { iconForName, Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons'; import { cn } from '@/lib/utils'; import { PluginIframeSlot } from './plugin-iframe-slot'; import type { SlotName } from '@/lib/plugin-types'; @@ -79,7 +79,13 @@ export function readDialogButtons(value: unknown): DialogButtonSpec[] { const label = b.label.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40); if (!label) continue; const variant = b.variant === 'secondary' || b.variant === 'ghost' || b.variant === 'destructive' ? b.variant : 'primary'; - out.push({ id: b.id, label, variant, ...(b.disabled === true ? { disabled: true } : {}), ...(b.busy === true ? { busy: true } : {}) }); + const icon = typeof b.icon === 'string' && /^[A-Za-z0-9:-]{1,48}$/.test(b.icon) ? b.icon : undefined; + out.push({ + id: b.id, label, variant, + ...(b.disabled === true ? { disabled: true } : {}), + ...(b.busy === true ? { busy: true } : {}), + ...(icon ? { icon } : {}), + }); } return out; } @@ -235,7 +241,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: ' className="gap-1.5" onClick={() => pressButton(b.id)} > - {b.busy &&