fix: list plugin composer buttons under Options when the composer is too narrow for them

This commit is contained in:
Linus Rath
2026-10-14 14:59:29 +02:00
parent c6ec58e5cc
commit db7a4408a1
5 changed files with 48 additions and 13 deletions
@@ -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', () => {
+18 -7
View File
@@ -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<string, unknown> }) {
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()); }}
>
<Icon className="w-4 h-4" />
{/* 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. */}
<span className="hidden @[44rem]/composer-bar:inline text-sm">{action.label}</span>
<span className="text-sm">{action.label}</span>
</Button>
);
})}
@@ -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 <div className="@[44rem]/composer-bar:hidden" data-narrow-only="">{children}</div>;
}
/** 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 (
<>
<NarrowOnly active={!!onlyWhenNarrow}>
{separator && <div role="separator" className="my-1 h-px bg-border" />}
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
@@ -234,6 +245,6 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
</button>
);
})}
</>
</NarrowOnly>
);
}
+1
View File
@@ -4118,6 +4118,7 @@ export function EmailComposer({
</ComposerToolbarMenuCheckItem>
)}
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
</>}
</ComposerToolbarMenu>
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
+15 -3
View File
@@ -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 && <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />}
{b.busy ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <FooterIcon name={b.icon} />}
{b.label}
</Button>
))}
@@ -303,6 +309,12 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
);
}
/** A footer button's icon by name; unknown names show none. */
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
return Glyph ? <Glyph className="w-4 h-4" aria-hidden="true" /> : null;
}
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => void }) {
const t = useTranslations('plugins.dialog');
+3 -1
View File
@@ -191,7 +191,7 @@ describe('plugin dialog footer', () => {
act(() => (slotProps.setDialogButtons as (b: unknown) => void)([
{ id: 'cancel', label: 'Cancel', variant: 'secondary' },
{ id: 'send', label: 'Send', disabled: true },
{ id: 'send', label: 'Send', disabled: true, icon: 'send' },
{ id: 'bad id!', label: 'Dropped' },
{ id: 'x', label: '' },
]));
@@ -200,6 +200,8 @@ describe('plugin dialog footer', () => {
expect(buttons.map((b) => b.textContent)).toEqual(['Cancel', 'Send']);
expect(buttons[0].className).toContain('border-input');
expect(buttons[1]).toBeDisabled();
expect(buttons[1].querySelector('svg')).not.toBeNull();
expect(buttons[0].querySelector('svg')).toBeNull();
fireEvent.click(buttons[0]);
expect(slotProps.dialogButtonClick).toEqual({ id: 'cancel', n: 1 });