mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
feat(plugin): add shared header buttons and composer controls (#4577)
* feat(plugin): add shared header buttons and composer controls * fix(plugin): isolate button registration imports and settle captures * test(plugin): express button journeys through domain helpers
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Header buttons and composer pills
|
||||
|
||||
Install this directory as a plugin, open an agent, and choose **Button examples: action** in the
|
||||
Command Center. One header button and one composer pill appear. Choose another mode to update
|
||||
those same registrations. Switching agents moves the example to that agent's workspace and composer.
|
||||
|
||||
| Command Center command | Header | Composer | Try |
|
||||
| ------------------------------------- | ------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Button examples: action** | Refresh icon only | Refresh icon and label | Refresh reads the workspace from the daemon and updates the label and tooltip. Paseo owns pending and error feedback. |
|
||||
| **Button examples: menu** | Wrench, Tools, chevron | Wrench and Tools | Refresh, a separator, custom details, a Display submenu, and a disabled item. |
|
||||
| **Button examples: popover** | Reactive status dot, Details, chevron | Reactive status dot and Details | Workspace data updates through `useWorkspace`; Done closes the surface. |
|
||||
| **Button examples: hide / show** | Hide or restore the button | Hide or restore the pill | Visibility changes through `registration.update({ visible })`. |
|
||||
| **Button examples: disable / enable** | Disable or enable the button | Disable or enable the pill | Disabled state changes through `registration.update({ disabled })`. |
|
||||
|
||||
On compact layouts the header button is borderless and icon-only. Menus and custom content open
|
||||
bottom sheets. Composer pills keep their icon and label, without a chevron, on every layout.
|
||||
|
||||
The example contributes one header button at a time. Additional actions live under the named
|
||||
**Tools** button; the example does not add a three-dot control or trigger plugin overflow.
|
||||
|
||||
| Action | Menu | Custom content | Compact sheet |
|
||||
| ------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
|  |  |  |  |
|
||||
|
||||
The client entry owns the active example. [client/examples.tsx](client/examples.tsx) contains the
|
||||
descriptors, updates, and React content. Return `.remove()` cleanup when the example moves or stops.
|
||||
See the [button reference](../../public-docs/plugins/v0.8/reference.md#button-descriptor) and
|
||||
[composer migration](../../public-docs/plugins/v0.8/migration.md#composer-pills).
|
||||
|
||||
From the repository root, `npm run typecheck --workspace=@getpaseo/plugin` checks this example
|
||||
against the SDK. Existing plugin projects must update `@getpaseo/plugin` before running their own
|
||||
`npm run typecheck` to detect the old `Component`/`onPress` pill shape and callable cleanup handle.
|
||||
|
||||
The browser regression installs this exact directory in an isolated daemon and exercises both
|
||||
layouts, updates, and cleanup:
|
||||
|
||||
```sh
|
||||
npm run test:e2e --workspace=@getpaseo/app -- e2e/browser/plugin-button-example.spec.ts
|
||||
```
|
||||
|
||||
The captures use Chromium at desktop and phone widths; native iOS and Android were not exercised.
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
type PluginButton,
|
||||
type PluginButtonContentProps,
|
||||
type PluginButtonIconProps,
|
||||
type PluginClientContext,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
export type ButtonMode = "action" | "menu" | "popover";
|
||||
|
||||
const composerPresentation = {
|
||||
action: { title: "Refresh context", label: "Refresh" },
|
||||
menu: { title: "Composer tools", label: "Tools" },
|
||||
popover: { title: "Context details", label: "Details" },
|
||||
};
|
||||
|
||||
function WorkspaceStatusIcon({ workspaceId, size, color, theme }: PluginButtonIconProps) {
|
||||
const status = useWorkspace(workspaceId, (workspace) => workspace.status);
|
||||
let fill = color;
|
||||
if (status === "failed") fill = theme.colors.statusDanger;
|
||||
else if (status === "needs_input") fill = theme.colors.statusWarning;
|
||||
else if (status === "done") fill = theme.colors.statusSuccess;
|
||||
const style = useMemo(
|
||||
() => ({ width: size, height: size, borderRadius: size / 2, backgroundColor: fill }),
|
||||
[size, fill],
|
||||
);
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
function WorkspaceDetails({ workspaceId, theme, close }: PluginButtonContentProps) {
|
||||
const workspace = useWorkspace(workspaceId, ({ name, projectDisplayName, status, diffStat }) => ({
|
||||
name,
|
||||
projectDisplayName,
|
||||
status,
|
||||
diffStat,
|
||||
}));
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
body: { gap: 12 },
|
||||
title: { color: theme.colors.foreground, fontSize: 18 },
|
||||
text: { color: theme.colors.foreground },
|
||||
detail: { color: theme.colors.foregroundMuted },
|
||||
button: { padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
return (
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title}>Workspace details</Text>
|
||||
<Text style={styles.text}>{workspace?.name}</Text>
|
||||
<Text style={styles.detail}>{workspace?.projectDisplayName}</Text>
|
||||
<Text style={styles.detail}>Status: {workspace?.status}</Text>
|
||||
{workspace?.diffStat ? (
|
||||
<Text style={styles.detail}>
|
||||
+{workspace.diffStat.additions} / −{workspace.diffStat.deletions} lines
|
||||
</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close workspace details"
|
||||
onPress={close}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text style={styles.text}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function createButtonExamples(
|
||||
client: PluginClientContext,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
) {
|
||||
let mode: ButtonMode = "action";
|
||||
let refreshes = 0;
|
||||
|
||||
async function refreshWorkspace() {
|
||||
// Return the real operation's promise: Paseo supplies pending, double-press prevention, and errors.
|
||||
await client.paseo.workspaces.ref(workspaceId).refresh();
|
||||
refreshes += 1;
|
||||
if (mode === "action") {
|
||||
header.update({ title: `Refresh workspace (${refreshes})` });
|
||||
pill.update({ label: `Refreshed · ${refreshes}` });
|
||||
}
|
||||
}
|
||||
|
||||
function button(next: ButtonMode): PluginButton {
|
||||
if (next === "action")
|
||||
return {
|
||||
title: "Refresh workspace",
|
||||
icon: "RefreshCw",
|
||||
label: undefined, // The header is icon-only. The composer supplies its own label below.
|
||||
behavior: { kind: "action", onPress: refreshWorkspace },
|
||||
};
|
||||
if (next === "popover")
|
||||
return {
|
||||
title: "Workspace details",
|
||||
icon: WorkspaceStatusIcon,
|
||||
label: "Details",
|
||||
behavior: { kind: "popover", Content: WorkspaceDetails },
|
||||
};
|
||||
return {
|
||||
title: "Workspace tools",
|
||||
icon: "Wrench",
|
||||
label: "Tools",
|
||||
behavior: {
|
||||
kind: "menu",
|
||||
items: [
|
||||
{
|
||||
kind: "item",
|
||||
id: "refresh",
|
||||
title: "Refresh workspace",
|
||||
icon: "RefreshCw",
|
||||
behavior: { kind: "action", onPress: refreshWorkspace },
|
||||
},
|
||||
{ kind: "separator", id: "details-divider" },
|
||||
{
|
||||
kind: "item",
|
||||
id: "details",
|
||||
title: "Workspace details",
|
||||
icon: WorkspaceStatusIcon,
|
||||
behavior: { kind: "popover", Content: WorkspaceDetails },
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
id: "display",
|
||||
title: "Display",
|
||||
icon: "Eye",
|
||||
behavior: {
|
||||
kind: "menu",
|
||||
items: [
|
||||
{
|
||||
kind: "item",
|
||||
id: "hide",
|
||||
title: "Hide example buttons",
|
||||
icon: "EyeOff",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
onPress() {
|
||||
setVisible(false);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
id: "disable",
|
||||
title: "Disable example buttons",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
onPress() {
|
||||
setDisabled(true);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "item",
|
||||
id: "publish",
|
||||
title: "Publish (unavailable in example)",
|
||||
icon: "Upload",
|
||||
disabled: true,
|
||||
behavior: { kind: "action", onPress() {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function composerButton(next: ButtonMode): PluginButton {
|
||||
return {
|
||||
...button(next),
|
||||
...composerPresentation[next],
|
||||
};
|
||||
}
|
||||
|
||||
const header = client.addHeaderButton({ id: "example", workspaceId, button: button(mode) });
|
||||
const pill = client.addComposerPill({
|
||||
id: "example",
|
||||
workspaceId,
|
||||
agentId,
|
||||
button: composerButton(mode),
|
||||
});
|
||||
|
||||
function setVisible(visible: boolean) {
|
||||
header.update({ visible });
|
||||
pill.update({ visible });
|
||||
}
|
||||
function setDisabled(disabled: boolean) {
|
||||
header.update({ disabled });
|
||||
pill.update({ disabled });
|
||||
}
|
||||
return {
|
||||
setMode(next: ButtonMode) {
|
||||
mode = next;
|
||||
header.update({ ...button(next), visible: true, disabled: false });
|
||||
pill.update({ ...composerButton(next), visible: true, disabled: false });
|
||||
},
|
||||
setVisible,
|
||||
setDisabled,
|
||||
remove() {
|
||||
header.remove();
|
||||
pill.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { PluginAgentCommandContext, PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { createButtonExamples, type ButtonMode } from "./client/examples";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
// Keep one example active at a time, including when switching agents or workspaces.
|
||||
let current:
|
||||
| {
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
buttons: ReturnType<typeof createButtonExamples>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function buttonsFor({ workspace, agent }: PluginAgentCommandContext) {
|
||||
if (current?.workspaceId !== workspace.id || current.agentId !== agent.id) {
|
||||
current?.buttons.remove();
|
||||
current = {
|
||||
workspaceId: workspace.id,
|
||||
agentId: agent.id,
|
||||
buttons: createButtonExamples(client, workspace.id, agent.id),
|
||||
};
|
||||
}
|
||||
return current.buttons;
|
||||
}
|
||||
|
||||
for (const mode of ["action", "menu", "popover"] satisfies ButtonMode[]) {
|
||||
client.addCommandCenterItem({
|
||||
id: `show-${mode}`,
|
||||
title: `Button examples: ${mode}`,
|
||||
icon: "MousePointerClick",
|
||||
context: "agent",
|
||||
onSelect(context) {
|
||||
buttonsFor(context).setMode(mode);
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const visible of [false, true]) {
|
||||
client.addCommandCenterItem({
|
||||
id: visible ? "show" : "hide",
|
||||
title: `Button examples: ${visible ? "show" : "hide"}`,
|
||||
icon: visible ? "Eye" : "EyeOff",
|
||||
context: "agent",
|
||||
onSelect(context) {
|
||||
buttonsFor(context).setVisible(visible);
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const disabled of [true, false]) {
|
||||
client.addCommandCenterItem({
|
||||
id: disabled ? "disable" : "enable",
|
||||
title: `Button examples: ${disabled ? "disable" : "enable"}`,
|
||||
icon: "MousePointerClick",
|
||||
context: "agent",
|
||||
onSelect(context) {
|
||||
buttonsFor(context).setDisabled(disabled);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => current?.buttons.remove();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "button-examples",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,10 +1,8 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import {
|
||||
type PluginClientContext,
|
||||
type PluginComposerPillProps,
|
||||
type PluginButtonRegistration,
|
||||
type PluginWorkspacePanelProps,
|
||||
useAgent,
|
||||
useRpc,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin/client";
|
||||
@@ -12,41 +10,33 @@ import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { incrementRpc } from "../shared/increment";
|
||||
|
||||
export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) {
|
||||
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
||||
const agent = useAgent(agentId, ({ title }) => ({ title }));
|
||||
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
|
||||
return (
|
||||
<>
|
||||
<Icon name="Blocks" size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{agent?.title ?? workspace?.name ?? "Counter"}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const pills = new Map<string, PluginButtonRegistration>();
|
||||
let stopped = false;
|
||||
const register = (agent: { id: string; workspaceId?: string | null }) => {
|
||||
if (stopped || !agent.workspaceId) return;
|
||||
pills.get(agent.id)?.();
|
||||
pills.get(agent.id)?.remove();
|
||||
const workspaceId = agent.workspaceId;
|
||||
const remove = client.addComposerPill({
|
||||
id: "open-counter",
|
||||
title: "Open plugin counter",
|
||||
workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: OpenCounterPill,
|
||||
onPress() {
|
||||
client.openPanel("counter", { workspaceId });
|
||||
button: {
|
||||
title: "Open plugin counter",
|
||||
icon: "Blocks",
|
||||
label: "Counter",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
onPress() {
|
||||
client.openPanel("counter", { workspaceId });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, remove);
|
||||
};
|
||||
const remove = (agentId: string) => {
|
||||
pills.get(agentId)?.();
|
||||
pills.get(agentId)?.remove();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
@@ -63,7 +53,7 @@ export function contributeClient(client: PluginClientContext) {
|
||||
return () => {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
for (const dispose of pills.values()) dispose();
|
||||
for (const pill of pills.values()) pill.remove();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user