From 7065252d4d355eedc07b009ac5aa4a97b3e17b14 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 13:00:47 +0100 Subject: [PATCH] feat(app): mark composer pills that open a menu and keep the pill track's space Plugin composer pills with a menu or popover now show an upward chevron at every width, so a pill that opens a surface reads differently from a one-tap action pill. Built-in Tasks and Subagents pills are unchanged. The transcript's bottom inset used to be reserved only while some pill existed. Plugins that add pills when a turn ends and remove them when the next starts moved the transcript every turn. The inset is now reserved whenever the agent has an active composer. --- .../app/e2e/browser/plugin-buttons.spec.ts | 1 + .../app/e2e/support/helpers/plugin-buttons.ts | 75 +++++++++++++++++-- packages/app/src/composer/tracks.tsx | 5 +- packages/app/src/panels/agent-panel.tsx | 28 ++----- packages/app/src/panels/agent-tracks.tsx | 41 +--------- packages/app/src/plugins/buttons/view.tsx | 18 ++--- packages/app/src/plugins/index.ts | 7 +- plugin-examples/buttons/README.md | 17 +++-- public-docs/plugins/migration.md | 4 +- public-docs/plugins/reference.md | 5 +- 10 files changed, 100 insertions(+), 101 deletions(-) diff --git a/packages/app/e2e/browser/plugin-buttons.spec.ts b/packages/app/e2e/browser/plugin-buttons.spec.ts index fe4227a8c3..0c60fd3990 100644 --- a/packages/app/e2e/browser/plugin-buttons.spec.ts +++ b/packages/app/e2e/browser/plugin-buttons.spec.ts @@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda await buttons.verifyObservationLifetime(); await buttons.openWideMenusAndPopovers(); await buttons.runAndUpdateActions(); + await buttons.keepTrackSpaceWithoutPills(); await buttons.openCompactSheets(); await buttons.useCompactWorkspaceMenu(); await buttons.hideAndUnloadOpenButtons(); diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 2418f284d2..b28050fd00 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -4,6 +4,9 @@ import path from "node:path"; import type { Locator, TestInfo } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; +import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor"; +import { expectTurnCopyButton } from "./agent-stream"; +import { submitMessage } from "./composer"; import { buildAgentRoute } from "./mock-agent"; import { openCommandCenter } from "./command-center"; import { @@ -88,7 +91,7 @@ export default function contribute(client) { header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } }); 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 } }); + const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } }); 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()); @@ -102,10 +105,16 @@ export default function contribute(client) { command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false })); command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true })); const lifetime = new AbortController(); + let pillsHidden = false; const applyWorkspace = (workspace) => { if (workspace.id !== workspaceId) return; + const hidePills = workspace.name === "No composer pills"; deploy.update({ visible: workspace.name !== "Compact checks" }); - pillMenu.update({ visible: workspace.name !== "Hide composer menu" }); + pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills }); + if (hidePills === pillsHidden) return; + pillsHidden = hidePills; + review.update({ visible: !hidePills }); + usage.update({ visible: !hidePills }); }; void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => { subscription.subscribe({ @@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject: await page.emulateMedia({ colorScheme: "light" }); } +const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const; + +function chevronOf(page: Page, title: string): Locator { + return page + .getByRole("button", { name: title, exact: true }) + .getByTestId("plugin-button-chevron"); +} + +/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */ +async function expectComposerPillChevrons(page: Page) { + await expect(chevronOf(page, "Composer checks")).toHaveCount(1); + await expect(chevronOf(page, "Composer status")).toHaveCount(1); + await expect(chevronOf(page, "Run review")).toHaveCount(0); +} + +async function setComposerPillsShown( + page: Page, + showcase: { setTitle(title: string): Promise }, + shown: boolean, +) { + await showcase.setTitle(shown ? "Button showcase" : "No composer pills"); + for (const title of COMPOSER_PILLS) { + await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0); + } +} + +async function lastTurnTop(page: Page): Promise { + const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox(); + expect(box).not.toBeNull(); + return box!.y; +} + async function expectHeaderTextTruncated(page: Page) { const title = page.getByTestId("workspace-header-title"); const subtitle = page.getByTestId("workspace-header-subtitle"); @@ -276,6 +317,7 @@ export async function withButtonShowcase( run: (buttons: { openWideMenusAndPopovers(): Promise; runAndUpdateActions(): Promise; + keepTrackSpaceWithoutPills(): Promise; openCompactSheets(): Promise; useCompactWorkspaceMenu(): Promise; hideAndUnloadOpenButtons(): Promise; @@ -348,11 +390,7 @@ export async function withButtonShowcase( .getByRole("button", { name: "Header checks", exact: true }) .getByTestId("plugin-button-chevron"), ).toHaveCount(1); - await expect( - page - .getByRole("button", { name: "Composer checks", exact: true }) - .getByTestId("plugin-button-chevron"), - ).toHaveCount(0); + await expectComposerPillChevrons(page); await expectSurfaceVisible(page); await captureSchemes( page, @@ -476,10 +514,31 @@ export async function withButtonShowcase( "Review ready", ); }), + keepTrackSpaceWithoutPills: () => + test.step("the composer track keeps its space when every pill leaves and returns", async () => { + await page.setViewportSize(WIDE); + await submitMessage(page, "Stream a turn under the composer pills"); + await expectTurnCopyButton(page); + await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 }); + for (const viewport of [WIDE, COMPACT]) { + await page.setViewportSize(viewport); + await scrollAgentChatToBottom(page); + const top = await lastTurnTop(page); + await setComposerPillsShown(page, showcase, false); + expect(await lastTurnTop(page)).toBe(top); + await setComposerPillsShown(page, showcase, true); + expect(await lastTurnTop(page)).toBe(top); + } + await page.setViewportSize(WIDE); + }), openCompactSheets: () => test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => { await page.setViewportSize(COMPACT); - await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); + await expect( + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), + ).toHaveCount(0); + await expectComposerPillChevrons(page); + await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2); await expect( page.getByRole("button", { name: "Composer checks", exact: true }), ).toContainText("Checks"); diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 341e7dc8e0..a6c0db1098 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -29,8 +29,9 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles"; * on every platform. * * The bar floats over the transcript with no background, so content remains visible underneath. - * Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps - * the final footer clear without turning the overlay into a layout band. + * Its host gives the scroll viewport a small bottom inset for as long as the bar is mounted, with + * or without pills: that keeps the final footer clear, and pills that come and go every turn do not + * move the transcript. */ export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement { return ( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a59ffc1277..32ad1aedbb 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -34,7 +34,6 @@ import { useComposerFullscreen, type ComposerEditingSession, } from "@/composer/editing-session"; -import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { resolveComposerTrackControlClearance, resolveComposerTrackTailClearance, @@ -66,7 +65,6 @@ import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; -import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, @@ -81,7 +79,7 @@ import { deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; -import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks"; +import { AgentTracks } from "@/panels/agent-tracks"; import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; @@ -1110,18 +1108,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ parentAgentId: agentId, rows: subagentRows, }); - const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; const parentAgentId = useParentAgentId(serverId, agentId); // With a composer the parent is a pill above it; without one, the box takes the composer's place. const parentPillAgentId = showsComposer ? parentAgentId : null; - const hasVisibleAgentTracks = hasAgentTracks({ - parentAgentId: parentPillAgentId, - subagentRows, - tasks, - archiveFinishedStatus: archiveFinishedSubagents.status, - hasPluginComposerPills, - }); const rawAgentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1199,13 +1189,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ @@ -1220,7 +1208,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ tasks={tasks} archiveFinishedStatus={archiveFinishedSubagents.status} onArchiveFinished={archiveFinishedSubagents.archiveFinished} - hasPluginComposerPills={hasPluginComposerPills} /> ) : null} @@ -1366,36 +1353,31 @@ function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: str const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, - workspaceId, agentId, agent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, hasActiveComposer, - hasVisibleAgentTracks, toast, onOpenWorkspaceFile, }: { streamViewRef: React.RefObject; serverId: string; - workspaceId: string; agentId?: string; agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; hasActiveComposer: boolean; - hasVisibleAgentTracks: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const isCompactFormFactor = useIsCompactFormFactor(); - const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); - const hasVisibleComposerTracks = - hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat); - const bottomOverlayTailClearance = hasVisibleComposerTracks + // The track bar's space is reserved whenever the bar is mounted, pills or not: pills come and go + // every turn, and the transcript must not jump with them. + const bottomOverlayTailClearance = hasActiveComposer ? resolveComposerTrackTailClearance(isCompactFormFactor) : 0; - const bottomOverlayControlClearance = hasVisibleComposerTracks + const bottomOverlayControlClearance = hasActiveComposer ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; const streamItemsRaw = useAgentStreamTail(serverId, agentId); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 6984633bcc..433264eac9 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,7 +1,6 @@ import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { memo, useCallback, type ReactElement } from "react"; import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; -import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -40,7 +39,6 @@ export const AgentTracks = memo(function AgentTracks({ tasks, archiveFinishedStatus, onArchiveFinished, - hasPluginComposerPills, }: { serverId: string; workspaceId: string; @@ -50,10 +48,8 @@ export const AgentTracks = memo(function AgentTracks({ tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; onArchiveFinished: () => void; - hasPluginComposerPills: boolean; -}): ReactElement | null { +}): ReactElement { const { tabId } = usePaneContext(); - const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); @@ -112,19 +108,6 @@ export const AgentTracks = memo(function AgentTracks({ }); }, [isCompact, openInSidePane, workspaceKey]); - if ( - !hasWorkspaceDiffStat && - !hasAgentTracks({ - parentAgentId, - subagentRows, - tasks, - archiveFinishedStatus, - hasPluginComposerPills, - }) - ) { - return null; - } - return ( {parentAgentId ? : null} @@ -154,25 +137,3 @@ export const AgentTracks = memo(function AgentTracks({ ); }); - -export function hasAgentTracks({ - parentAgentId, - subagentRows, - tasks, - archiveFinishedStatus, - hasPluginComposerPills = false, -}: { - parentAgentId: string | null; - subagentRows: readonly SubagentRow[]; - tasks: readonly TodoEntry[] | undefined; - archiveFinishedStatus: ArchiveFinishedStatus; - hasPluginComposerPills?: boolean; -}): boolean { - return ( - parentAgentId !== null || - subagentRows.length > 0 || - Boolean(tasks?.length) || - archiveFinishedStatus.kind !== "idle" || - hasPluginComposerPills - ); -} diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 3db4381597..889d71001f 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -13,7 +13,7 @@ import { } from "react"; import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native"; +import { AlertCircle, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { MenuRoot, @@ -247,7 +247,11 @@ function ButtonControl({ view }: { view: ButtonView }) { const composer = entry.placement === "composer"; const disabled = button.disabled || entry.pending; const expanded = button.behavior.kind !== "action"; - const chevron = !composer && !props.layout.compact && expanded; + // A chevron marks a button that opens something. Composer pills keep their label on compact and + // open their surface above, so theirs points up and shows at every width; compact headers drop + // the label and the chevron with it. + const chevron = expanded && (composer || !props.layout.compact); + const Chevron = composer ? ChevronUp : ChevronDown; let label = button.label; if (composer) label = button.label ?? button.title; else if (props.layout.compact) label = undefined; @@ -284,7 +288,7 @@ function ButtonControl({ view }: { view: ButtonView }) { ) : null} {chevron ? ( - + ) : null} @@ -559,14 +563,6 @@ function useButtons(serverId: string, workspaceId: string, agentId: string | nul ); } -export function useHasPluginComposerPills( - serverId: string, - workspaceId: string, - agentId: string, -): boolean { - return useButtons(serverId, workspaceId, agentId).length > 0; -} - export function PluginComposerPills({ serverId, workspaceId, diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index ac3500e7e9..425c925804 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -3,9 +3,4 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes"; export { PluginSurfaceScreen } from "./surface-screen"; export { usePluginAttachmentPicker } from "./attachments/picker"; export { PluginResourceAttachmentPill } from "./attachments/pill"; -export { - PluginComposerPills, - PluginHeaderButtons, - useHasPluginComposerPills, - usePluginWorkspaceMenu, -} from "./buttons/view"; +export { PluginComposerPills, PluginHeaderButtons, usePluginWorkspaceMenu } from "./buttons/view"; diff --git a/plugin-examples/buttons/README.md b/plugin-examples/buttons/README.md index 111be3b08d..acb64c087e 100644 --- a/plugin-examples/buttons/README.md +++ b/plugin-examples/buttons/README.md @@ -4,16 +4,17 @@ Install this directory as a plugin, open an agent, and choose **Button examples: 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 })`. | +| 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, Tools, chevron | Refresh, a separator, custom details, a Display submenu, and a disabled item. | +| **Button examples: popover** | Reactive status dot, Details, chevron | Status dot, Details, chevron | 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. +bottom sheets. Composer pills keep their icon and label on every layout, and menus and popovers keep +their chevron. 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. diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index fdefda5971..d4b247a16c 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -144,8 +144,8 @@ 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 always show icon and label; menus and +popovers add a chevron. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility. ## 4. Separate imports diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 463cbd4957..ef25d93c35 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1897,7 +1897,10 @@ 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. +Menus and popovers add an upward chevron on every layout, because their surface opens above the +pill; actions show none, so a pill that runs on tap reads differently from one that opens something. +The track keeps its space above the composer whether or not it has pills, so adding and removing +pills does not move the transcript. ```tsx const pill = client.addComposerPill({