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;
+3 -2
View File
@@ -121,8 +121,9 @@ pill.remove();
```
Move dynamic text from the former component into updates from your model or SDK subscription.
Custom icon components may still use hooks. Composer pills always show icon and label and never
show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
Custom icon components may still use hooks. Composer pills use `title` when `label` is omitted;
pass `null` or an empty string for icon-only. They never show chevrons. See
[buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
## 4. Separate imports
+11 -10
View File
@@ -1335,8 +1335,9 @@ into a shared overflow menu. Placement and overflow are host decisions.
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
and returns the same registration. It targets one agent's composer track alongside Tasks and
Subagents. Composer pills always show the icon and `label` (or `title` when `label` is omitted).
They never show a chevron, including for menus and popovers.
Subagents. Composer pills show the icon and `label` (or `title` when `label` is omitted).
Pass `null` or an empty string for an icon-only pill. Icon-only pills use a square button without
text padding. They never show a chevron, including for menus and popovers.
```tsx
const pill = client.addComposerPill({
@@ -1361,14 +1362,14 @@ const pill = client.addComposerPill({
These contracts are exported from `@getpaseo/plugin/client`.
| Field | Required | Meaning |
| ---------- | -------- | ----------------------------------------------------------------------- |
| `title` | Yes | Non-empty accessible label, tooltip, and sheet title. |
| `icon` | Yes | Lucide name or `ComponentType<PluginButtonIconProps>`. |
| `label` | No | Non-empty display text. Omit to use the placement's default. |
| `visible` | No | Defaults to `true`. False removes the trigger and its layout space. |
| `disabled` | No | Defaults to `false`. Keeps the button visible and prevents interaction. |
| `behavior` | Yes | One of the three shapes below. |
| Field | Required | Meaning |
| ---------- | -------- | -------------------------------------------------------------------------------- |
| `title` | Yes | Non-empty accessible label, tooltip, and sheet title. |
| `icon` | Yes | Lucide name or `ComponentType<PluginButtonIconProps>`. |
| `label` | No | Display text. Omit for the placement default; pass `null` or `""` for icon-only. |
| `visible` | No | Defaults to `true`. False removes the trigger and its layout space. |
| `disabled` | No | Defaults to `false`. Keeps the button visible and prevents interaction. |
| `behavior` | Yes | One of the three shapes below. |
```tsx
type PluginButtonBehavior =