mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user