diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 0e66a395dc..e53093fc46 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -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 }) diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index b6ea249337..58436e95eb 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -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 = () => {}; diff --git a/packages/app/src/plugins/buttons/validation.ts b/packages/app/src/plugins/buttons/validation.ts index 650c21fac2..3971844b4b 100644 --- a/packages/app/src/plugins/buttons/validation.ts +++ b/packages/app/src/plugins/buttons/validation.ts @@ -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), diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 42067043c2..4b781014d3 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -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, diff --git a/packages/plugin/src/client/buttons.ts b/packages/plugin/src/client/buttons.ts index f5272e7ae4..a14722bc2b 100644 --- a/packages/plugin/src/client/buttons.ts +++ b/packages/plugin/src/client/buttons.ts @@ -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; diff --git a/public-docs/plugins/v0.8/migration.md b/public-docs/plugins/v0.8/migration.md index a1f24168e5..f0e0a5473e 100644 --- a/public-docs/plugins/v0.8/migration.md +++ b/public-docs/plugins/v0.8/migration.md @@ -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 diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 3677956a75..ec77f433e1 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -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`. | -| `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`. | +| `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 =