fix(plugin): support icon-only composer pills

Composer pills use an omitted label to fall back to the accessible title, but rejected every explicit no-label value. Preserve that omission default while representing null and empty labels as icon-only controls.
This commit is contained in:
paseo-bot[bot]
2026-09-10 17:55:37 +02:00
parent d7c7044dfc
commit eb9a4df9b9
7 changed files with 74 additions and 21 deletions
@@ -71,6 +71,9 @@ export default function contribute(client) {
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
pill("icon-empty", { title: "Empty label pill", icon: "Scan", label: "", behavior: { kind: "action", onPress() {} } });
pill("icon-null", { title: "Null label pill", icon: "Scan", label: null, behavior: { kind: "action", onPress() {} } });
pill("label-default", { title: "Default label pill", icon: "Scan", behavior: { kind: "action", onPress() {} } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
@@ -243,6 +246,20 @@ export async function withButtonShowcase(
await run({
openWideMenusAndPopovers: () =>
test.step("wide buttons have placement-owned labels and chevrons", async () => {
for (const title of ["Empty label pill", "Null label pill"]) {
const iconOnlyPill = page.getByRole("button", { name: title, exact: true });
await expect(iconOnlyPill).toBeVisible();
await expect(iconOnlyPill).toHaveText("");
await expect
.poll(async () => {
const box = await iconOnlyPill.boundingBox();
return box ? { width: box.width, height: box.height } : null;
})
.toEqual({ width: 32, height: 32 });
}
await expect(
page.getByRole("button", { name: "Default label pill", exact: true }),
).toHaveText("Default label pill");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
@@ -89,6 +89,27 @@ describe("plugin buttons", () => {
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
});
it("keeps omitted labels distinct from explicit icon-only labels", () => {
const buttons = store();
const plugin = installation();
const omitted = buttons.addComposerPill(plugin, {
id: "omitted",
workspaceId: "workspace",
agentId: "agent",
button: button(),
});
buttons.addComposerPill(plugin, {
id: "empty",
workspaceId: "workspace",
agentId: "agent",
button: { ...button(), label: " " },
});
expect(buttons.getSnapshot().map((entry) => entry.button.label)).toEqual([undefined, null]);
omitted.update({ label: null });
expect(buttons.getSnapshot()[0].button.label).toBeNull();
});
it("prevents repeated presses and cannot resurrect a removed pending button", async () => {
const buttons = store();
let finish = () => {};
@@ -76,9 +76,15 @@ function validateBehavior(
export type ResolvedPluginButton = PluginButton & {
visible: boolean;
disabled: boolean;
label: string | undefined;
label: string | null | undefined;
};
function optionalLabel(value: string | null | undefined): string | null | undefined {
if (value === undefined || value === null) return value;
if (typeof value !== "string") throw new Error("Plugin button needs label");
return value.trim() || null;
}
export function validateButton(
button: PluginButton,
validation: ButtonValidation,
@@ -87,7 +93,7 @@ export function validateButton(
return {
...button,
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
label: optionalLabel(button.label),
visible: optionalBoolean(button.visible, true),
disabled: optionalBoolean(button.disabled, false),
behavior: validateBehavior(button.behavior, validation),
+12 -5
View File
@@ -28,7 +28,7 @@ import {
iconButtonChromeStyle,
type IconButtonChromeState,
} from "@/components/ui/icon-button-chrome";
import { composerPillStyles } from "@/composer/pill-styles";
import { COMPOSER_PILL_MIN_HEIGHT, composerPillStyles } from "@/composer/pill-styles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
@@ -260,9 +260,10 @@ function ButtonControl({ view }: { view: ButtonView }) {
const disabled = button.disabled || entry.pending;
const expanded = button.behavior.kind !== "action";
const chevron = !composer && !props.layout.compact && expanded;
let label = button.label;
if (composer) label = button.label ?? button.title;
else if (props.layout.compact) label = undefined;
let label = button.label ?? undefined;
if (composer && button.label === undefined) label = button.title;
else if (!composer && props.layout.compact) label = undefined;
const composerIconOnly = composer && label === undefined;
const press = useCallback(() => pressButton(view, []), [view]);
const setOpen = useCallback(
(open: boolean) => pluginButtonStore.setOpen(entry.key, open),
@@ -274,11 +275,12 @@ function ButtonControl({ view }: { view: ButtonView }) {
? [
composerPillStyles.body,
styles.button,
composerIconOnly && styles.composerIconOnly,
(hovered || pressed || entry.open) && styles.active,
disabled && styles.disabled,
]
: headerButtonStyle(props.layout.compact, { hovered, pressed, open: entry.open }, disabled),
[composer, disabled, entry.open, props.layout.compact],
[composer, composerIconOnly, disabled, entry.open, props.layout.compact],
);
const contents = (
<>
@@ -654,6 +656,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
composerIconOnly: {
width: COMPOSER_PILL_MIN_HEIGHT,
minWidth: COMPOSER_PILL_MIN_HEIGHT,
paddingHorizontal: 0,
},
label: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1 },
icon: {
width: 16,
+2 -2
View File
@@ -32,8 +32,8 @@ export type PluginButtonMenuEntry =
export interface PluginButton {
title: string;
icon: PluginButtonIcon;
/** Omit for an icon-only header button. Composer pills use title when omitted. */
label?: string;
/** Omit to use the placement default. Pass null or an empty string for icon-only. */
label?: string | null;
visible?: boolean;
disabled?: boolean;
behavior: PluginButtonBehavior;