fix: deduplicate mobile plugin toolbars (#3908)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -30,6 +30,9 @@ export type PluginNavSection = "main" | "settings" | "integrations" | "sidebar-f
|
||||
/**
|
||||
* Context for the `main-top-bar` slot. Phone listing contributions live in the
|
||||
* menu with 44px touch targets; interactions retain the slot's local state.
|
||||
* On phones with task controls, the same plugin's chat-top-bar replaces this
|
||||
* slot once it renders content. Null-rendering task controls retain this
|
||||
* fallback. Listings and archived tasks retain the workspace toolbar.
|
||||
*/
|
||||
export interface MainTopBarSlotProps {
|
||||
workspaceId: string | null;
|
||||
@@ -41,6 +44,8 @@ export interface MainTopBarSlotProps {
|
||||
/**
|
||||
* Context for the `chat-top-bar` slot. Phone task contributions live in the
|
||||
* shared menu; tablet and desktop contributions remain inline in the top bar.
|
||||
* When task controls are present, every registration in this slot renders
|
||||
* instead of the same plugin's main-top-bar registrations in the phone menu.
|
||||
*/
|
||||
export interface ChatTopBarSlotProps {
|
||||
/** Task the top bar belongs to, or null before one exists. */
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ import { Dialog } from "@kandev/ui/dialog";
|
||||
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions.
|
||||
- Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus.
|
||||
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements; read [confirmation guidance](components/confirmation/AGENTS.md) when adopting phone confirmation surfaces.
|
||||
- Phone hamburger buttons use `AppNavSheet` for app navigation across listings, task workbenches, and page shells. The task title opens the task picker; `ResponsiveTaskPicker` owns its dialogs above responsive layout branches, and `TaskSheetSelectionProvider` shares cancellation with the embedded task sidebar. `MobileTaskNavigationProvider` retains the inline sidebar controller and action dialogs above responsive page headers, rendering the body into the shared menu outlet. Tasks collapses in place and shares the menu scroller. `KanbanHeaderMobile` opens listing-only `MobileMenuSheet` options from the Kanban/Threads/List title dropdown; Threads saved views render inline inside that surface. `MobileListingMenuActions` supplies search in listing options. Phone app navigation groups main-toolbar, sidebar workspace, and task plugin controls in `MobilePluginNavSection`, with context labels when workspace and task controls coexist. Fallback system metrics follow navigation before Utilities. Keep activity/connection cues on `AppNavTrigger` and restore the actual opener unless focus moves into a launched surface. Tablet/desktop composition stays separate. Phone navigation uses Home for all listing modes; Tasks/Threads remain routes and palette destinations. The inline Tasks heading retains its independent plus. Phone Automations reuses workspace automation reads only while expanded, and Integrations retains a workspace settings entry even without configured links. Phone app navigation groups the built-in Quick Chat/Quick terminal actions below Home. `MobileQuickActions` shares launch and focus behavior; plugin actions and metrics retain separate slots. `MobileIntegrationsSection` opts into a local, initially collapsed disclosure only for phone app navigation; wider consumers retain expanded rendering.
|
||||
- Phone hamburger buttons use `AppNavSheet` for app navigation across listings, task workbenches, and page shells. The task title opens the task picker; `ResponsiveTaskPicker` owns its dialogs above responsive layout branches, and `TaskSheetSelectionProvider` shares cancellation with the embedded task sidebar. `MobileTaskNavigationProvider` retains the inline sidebar controller and action dialogs above responsive page headers, rendering the body into the shared menu outlet. Tasks collapses in place and shares the menu scroller. `KanbanHeaderMobile` opens listing-only `MobileMenuSheet` options from the Kanban/Threads/List title dropdown; Threads saved views render inline inside that surface. `MobileListingMenuActions` supplies search in listing options. Phone app navigation groups main-toolbar, sidebar workspace, and task plugin controls in `MobilePluginNavSection`, in one wrapping group. When task controls exist, each plugin uses its task toolbar instead of a second workspace toolbar; workspace-only plugins and sidebar actions remain available. Fallback system metrics follow navigation before Utilities. Keep activity/connection cues on `AppNavTrigger` and restore the actual opener unless focus moves into a launched surface. Tablet/desktop composition stays separate. Phone navigation uses Home for all listing modes; Tasks/Threads remain routes and palette destinations. The inline Tasks heading retains its independent plus. Phone Automations reuses workspace automation reads only while expanded, and Integrations retains a workspace settings entry even without configured links. Phone app navigation groups the built-in Quick Chat/Quick terminal actions below Home. `MobileQuickActions` shares launch and focus behavior; plugin actions and metrics retain separate slots. `MobileIntegrationsSection` opts into a local, initially collapsed disclosure only for phone app navigation; wider consumers retain expanded rendering.
|
||||
- Saved phone sidebar layouts retain Home/quick actions before the task outlet and put optional tools/groups afterward in saved relative order. Built-in Automations, Canvases, and Integrations use labelled phone disclosures; icon strips belong to custom shortcut groups. The built-in integration disclosure excludes plugin links rendered as independently customizable nodes. Never persist this phone composition over the desktop layout.
|
||||
|
||||
## Data Flow Pattern (Critical)
|
||||
|
||||
@@ -42,8 +42,15 @@ export function MainTopBarPluginActions(props: {
|
||||
workspaceLabel?: string;
|
||||
currentPage: TaskListingPage;
|
||||
presentation?: MainTopBarSlotProps["presentation"];
|
||||
excludePluginIds?: readonly string[];
|
||||
}) {
|
||||
const { workspaceId, workspaceLabel, currentPage, presentation = "desktop" } = props;
|
||||
const {
|
||||
workspaceId,
|
||||
workspaceLabel,
|
||||
currentPage,
|
||||
presentation = "desktop",
|
||||
excludePluginIds,
|
||||
} = props;
|
||||
|
||||
const slotProps = useMemo<MainTopBarSlotProps>(
|
||||
() => ({
|
||||
@@ -55,7 +62,9 @@ export function MainTopBarPluginActions(props: {
|
||||
[workspaceId, workspaceLabel, currentPage, presentation],
|
||||
);
|
||||
|
||||
const content = <PluginSlot name="main-top-bar" slotProps={slotProps} />;
|
||||
const content = (
|
||||
<PluginSlot name="main-top-bar" slotProps={slotProps} excludePluginIds={excludePluginIds} />
|
||||
);
|
||||
if (presentation === "desktop") return content;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import { PluginSlot } from "./plugin-slot";
|
||||
import {
|
||||
MobilePluginNavSection,
|
||||
type MobilePluginWorkspaceContext,
|
||||
@@ -9,6 +10,15 @@ import {
|
||||
|
||||
const HELLO_PATH = "/plugins/hello";
|
||||
const HELLO_ITEM_TEST_ID = "mobile-plugin-nav-item-hello";
|
||||
const MAIN_SLOT = "main-top-bar";
|
||||
const TASK_SLOT = "chat-top-bar";
|
||||
const SIDEBAR_SLOT = "sidebar-workspace-actions";
|
||||
const WORKSPACE_USAGE = "Workspace usage";
|
||||
const workspaceContext = {
|
||||
workspaceId: "ws-1",
|
||||
workspaceLabel: "Demo",
|
||||
currentPage: "tasks",
|
||||
} as const;
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
usePathname: () => "/",
|
||||
@@ -37,12 +47,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("MobilePluginNavSection workspace grouping", () => {
|
||||
const workspaceContext = {
|
||||
workspaceId: "ws-1",
|
||||
workspaceLabel: "Demo",
|
||||
currentPage: "tasks",
|
||||
} as const;
|
||||
|
||||
it("omits the section when the phone has no contributions", () => {
|
||||
const { container } = renderSection(undefined, undefined, true, workspaceContext);
|
||||
expect(container.innerHTML).toBe("");
|
||||
@@ -50,7 +54,7 @@ describe("MobilePluginNavSection workspace grouping", () => {
|
||||
|
||||
it("keeps workspace slots with task controls even when a saved layout owns destinations", () => {
|
||||
const received: unknown[] = [];
|
||||
for (const name of ["main-top-bar", "sidebar-workspace-actions"]) {
|
||||
for (const name of [MAIN_SLOT, SIDEBAR_SLOT]) {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(name, ({ slotProps }) => {
|
||||
received.push(slotProps);
|
||||
return <button data-testid={name}>{name}</button>;
|
||||
@@ -62,7 +66,7 @@ describe("MobilePluginNavSection workspace grouping", () => {
|
||||
renderSection(undefined, <button>Task control</button>, false, workspaceContext);
|
||||
|
||||
const section = screen.getByRole("region", { name: "Plugins" });
|
||||
for (const label of ["main-top-bar", "sidebar-workspace-actions", "Task control"]) {
|
||||
for (const label of [MAIN_SLOT, SIDEBAR_SLOT, "Task control"]) {
|
||||
expect(section.contains(screen.getByRole("button", { name: label }))).toBe(true);
|
||||
}
|
||||
expect(received).toContainEqual({ ...workspaceContext, presentation: "mobile" });
|
||||
@@ -71,16 +75,84 @@ describe("MobilePluginNavSection workspace grouping", () => {
|
||||
workspaceLabel: "Demo",
|
||||
presentation: "mobile",
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "Workspace" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Task" })).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "Workspace" })).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Task" })).toBeNull();
|
||||
expect(screen.queryByTestId(HELLO_ITEM_TEST_ID)).toBeNull();
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// @covers AC-UI-MOBILE-MENU-008.1
|
||||
it("prefers a plugin's task toolbar while retaining every task and independent workspace action", () => {
|
||||
const plugin = pluginRegistry.forPlugin("plugin-a");
|
||||
plugin.registerComponent(MAIN_SLOT, () => <button>Workspace usage</button>);
|
||||
plugin.registerComponent(TASK_SLOT, () => <button>Task usage</button>);
|
||||
plugin.registerComponent(TASK_SLOT, () => <button>Task details</button>);
|
||||
plugin.registerComponent(SIDEBAR_SLOT, () => <button>New workspace note</button>);
|
||||
pluginRegistry.forPlugin("plugin-b").registerComponent(MAIN_SLOT, () => <button>CPU</button>);
|
||||
|
||||
renderSection(undefined, <PluginSlot name={TASK_SLOT} />, false, workspaceContext);
|
||||
|
||||
expect(screen.queryByRole("button", { name: WORKSPACE_USAGE })).toBeNull();
|
||||
for (const name of ["Task usage", "Task details", "New workspace note", "CPU"]) {
|
||||
expect(screen.getAllByRole("button", { name })).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
// @covers AC-UI-MOBILE-MENU-008.2
|
||||
it("restores the workspace toolbar when task actions are absent and follows live registrations", async () => {
|
||||
const plugin = pluginRegistry.forPlugin("plugin-a");
|
||||
plugin.registerComponent(MAIN_SLOT, () => <button>Workspace usage</button>);
|
||||
const { rerender } = renderSection(
|
||||
undefined,
|
||||
<PluginSlot name={TASK_SLOT} />,
|
||||
true,
|
||||
workspaceContext,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: WORKSPACE_USAGE })).toBeTruthy();
|
||||
|
||||
await act(async () => plugin.registerComponent(TASK_SLOT, () => <button>Task usage</button>));
|
||||
expect(screen.queryByRole("button", { name: WORKSPACE_USAGE })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Task usage" })).toBeTruthy();
|
||||
|
||||
rerender(<MobilePluginNavSection onNavigate={() => {}} workspaceContext={workspaceContext} />);
|
||||
expect(screen.getByRole("button", { name: WORKSPACE_USAGE })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Task usage" })).toBeNull();
|
||||
|
||||
act(() => pluginRegistry.unregisterPlugin("plugin-a"));
|
||||
expect(screen.queryByTestId("mobile-plugin-nav-section")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobilePluginNavSection workspace fallback", () => {
|
||||
it("keeps a workspace fallback until a task control renders and restores it when that control disappears", async () => {
|
||||
const plugin = pluginRegistry.forPlugin("plugin-a");
|
||||
plugin.registerComponent(MAIN_SLOT, () => <button>Workspace usage</button>);
|
||||
plugin.registerComponent(TASK_SLOT, ({ slotProps }) =>
|
||||
(slotProps as { available: boolean }).available ? <button>Session quota</button> : null,
|
||||
);
|
||||
const section = (available: boolean) => (
|
||||
<MobilePluginNavSection
|
||||
onNavigate={() => {}}
|
||||
workspaceContext={workspaceContext}
|
||||
actions={<PluginSlot name={TASK_SLOT} slotProps={{ available }} />}
|
||||
/>
|
||||
);
|
||||
const { rerender } = render(section(false));
|
||||
expect(screen.getByRole("button", { name: WORKSPACE_USAGE })).toBeTruthy();
|
||||
|
||||
rerender(section(true));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: WORKSPACE_USAGE })).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Session quota" })).toBeTruthy();
|
||||
|
||||
rerender(section(false));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: WORKSPACE_USAGE })).toBeTruthy());
|
||||
expect(screen.queryByRole("button", { name: "Session quota" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not invent task context for workspace-only controls and removes unloaded contributions", () => {
|
||||
pluginRegistry
|
||||
.forPlugin("plugin-a")
|
||||
.registerComponent("main-top-bar", () => <button>Workspace control</button>);
|
||||
.registerComponent(MAIN_SLOT, () => <button>Workspace control</button>);
|
||||
const { rerender, container } = renderSection(undefined, undefined, true, workspaceContext);
|
||||
expect(screen.getByRole("button", { name: "Workspace control" })).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "Task" })).toBeNull();
|
||||
@@ -92,7 +164,7 @@ describe("MobilePluginNavSection workspace grouping", () => {
|
||||
it("omits sidebar actions when there is no active workspace", () => {
|
||||
pluginRegistry
|
||||
.forPlugin("plugin-a")
|
||||
.registerComponent("sidebar-workspace-actions", () => <button>Workspace control</button>);
|
||||
.registerComponent(SIDEBAR_SLOT, () => <button>Workspace control</button>);
|
||||
const { container } = renderSection(undefined, undefined, true, { currentPage: "kanban" });
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppSidebarWorkspaceActions } from "@/components/app-sidebar/app-sidebar-workspace-actions";
|
||||
import { MainTopBarPluginActions } from "@/components/kanban/main-top-bar-plugin-actions";
|
||||
@@ -10,6 +10,7 @@ import { NO_WORKSPACE_CONTEXT } from "@/lib/navigation/surface-policy";
|
||||
import { usePluginRegistry } from "@/lib/plugins/registry";
|
||||
import type { TaskListingPage } from "@/lib/task-listing/view-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PluginSlotPresence } from "./plugin-slot-presence";
|
||||
|
||||
export type MobilePluginWorkspaceContext = {
|
||||
workspaceId?: string;
|
||||
@@ -44,9 +45,12 @@ export function MobilePluginNavSection({
|
||||
}: MobilePluginNavSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const registry = usePluginRegistry();
|
||||
const [renderedTaskPluginIds, setRenderedTaskPluginIds] = useState<string[]>([]);
|
||||
const taskPluginIds = actions ? renderedTaskPluginIds : [];
|
||||
const { hasMainActions, hasSidebarActions } = workspaceSlotAvailability(
|
||||
registry,
|
||||
workspaceContext,
|
||||
taskPluginIds,
|
||||
);
|
||||
const hasWorkspaceActions = hasMainActions || hasSidebarActions;
|
||||
// Resolved directly rather than through `useStaticDestinations`: this group's
|
||||
@@ -70,22 +74,21 @@ export function MobilePluginNavSection({
|
||||
aria-label={t("common:plugins")}
|
||||
>
|
||||
<h3 className="text-sm font-medium">{t("common:plugins")}</h3>
|
||||
{hasWorkspaceActions && workspaceContext && (
|
||||
<div className="space-y-2" data-testid="mobile-plugin-workspace-actions">
|
||||
{actions && <h4 className="text-xs text-muted-foreground">{t("common:workspace")}</h4>}
|
||||
<WorkspacePluginControls
|
||||
context={workspaceContext}
|
||||
hasMainActions={hasMainActions}
|
||||
hasSidebarActions={hasSidebarActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{actions && (
|
||||
<div className="space-y-2" data-testid="mobile-plugin-page-actions">
|
||||
{hasWorkspaceActions && (
|
||||
<h4 className="text-xs text-muted-foreground">{t("common:task")}</h4>
|
||||
{(hasWorkspaceActions || actions) && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{hasWorkspaceActions && workspaceContext && (
|
||||
<WorkspacePluginControls
|
||||
context={workspaceContext}
|
||||
hasMainActions={hasMainActions}
|
||||
hasSidebarActions={hasSidebarActions}
|
||||
excludePluginIds={taskPluginIds}
|
||||
/>
|
||||
)}
|
||||
{actions && (
|
||||
<PluginSlotPresence name="chat-top-bar" onChange={setRenderedTaskPluginIds}>
|
||||
{actions}
|
||||
</PluginSlotPresence>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{destinations.length > 0 && (
|
||||
@@ -103,14 +106,22 @@ function WorkspacePluginControls({
|
||||
context,
|
||||
hasMainActions,
|
||||
hasSidebarActions,
|
||||
excludePluginIds,
|
||||
}: {
|
||||
context: MobilePluginWorkspaceContext;
|
||||
hasMainActions: boolean;
|
||||
hasSidebarActions: boolean;
|
||||
excludePluginIds: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{hasMainActions && <MainTopBarPluginActions {...context} presentation="mobile" />}
|
||||
{hasMainActions && (
|
||||
<MainTopBarPluginActions
|
||||
{...context}
|
||||
presentation="mobile"
|
||||
excludePluginIds={excludePluginIds}
|
||||
/>
|
||||
)}
|
||||
{hasSidebarActions && context.workspaceId && (
|
||||
<AppSidebarWorkspaceActions
|
||||
workspaceId={context.workspaceId}
|
||||
@@ -125,9 +136,14 @@ function WorkspacePluginControls({
|
||||
function workspaceSlotAvailability(
|
||||
registry: ReturnType<typeof usePluginRegistry>,
|
||||
context?: MobilePluginWorkspaceContext,
|
||||
excludePluginIds: readonly string[] = [],
|
||||
) {
|
||||
return {
|
||||
hasMainActions: !!context && registry.getSlotRegistrations("main-top-bar").length > 0,
|
||||
hasMainActions:
|
||||
!!context &&
|
||||
registry
|
||||
.getSlotRegistrations("main-top-bar")
|
||||
.some(({ pluginId }) => !excludePluginIds.includes(pluginId)),
|
||||
hasSidebarActions:
|
||||
!!context?.workspaceId &&
|
||||
registry.getSlotRegistrations("sidebar-workspace-actions").length > 0,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
export const ObservedPluginSlotContext = createContext<string | null>(null);
|
||||
|
||||
/** Reports owners with mounted content, including asynchronous null/content changes. */
|
||||
export function PluginSlotPresence({
|
||||
name,
|
||||
children,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
onChange: (pluginIds: string[]) => void;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = container.current;
|
||||
if (!element) return;
|
||||
let previous: string[] | undefined;
|
||||
const update = () => {
|
||||
const owners = new Set<string>();
|
||||
for (const registration of element.querySelectorAll<HTMLElement>(
|
||||
"[data-plugin-slot-owner]",
|
||||
)) {
|
||||
if (registration.childNodes.length > 0 && registration.dataset.pluginSlotOwner) {
|
||||
owners.add(registration.dataset.pluginSlotOwner);
|
||||
}
|
||||
}
|
||||
const next = [...owners];
|
||||
if (previous?.length === next.length && previous.every((id, index) => id === next[index])) {
|
||||
return;
|
||||
}
|
||||
previous = next;
|
||||
onChange(next);
|
||||
};
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(element, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [name, onChange]);
|
||||
|
||||
return (
|
||||
<ObservedPluginSlotContext.Provider value={name}>
|
||||
<div ref={container} className="contents">
|
||||
{children}
|
||||
</div>
|
||||
</ObservedPluginSlotContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,18 @@ describe("PluginSlot", () => {
|
||||
expect(screen.queryByTestId("slot-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("excludes selected owners without affecting other instances of the slot", () => {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => <button>Usage</button>);
|
||||
pluginRegistry.forPlugin("plugin-b").registerComponent(SLOT, () => <button>CPU</button>);
|
||||
const { rerender } = render(<PluginSlot name={SLOT} excludePluginIds={["plugin-a"]} />);
|
||||
expect(screen.queryByRole("button", { name: "Usage" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "CPU" })).toBeTruthy();
|
||||
|
||||
rerender(<PluginSlot name={SLOT} />);
|
||||
expect(screen.getByRole("button", { name: "Usage" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "CPU" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes slotProps through to each registered component", () => {
|
||||
pluginRegistry
|
||||
.forPlugin("plugin-a")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { usePluginRegistry } from "@/lib/plugins/registry";
|
||||
import type { PluginSlotRegistration } from "@/lib/plugins/registry";
|
||||
import { PluginErrorBoundary } from "./plugin-error-boundary";
|
||||
import { ObservedPluginSlotContext } from "./plugin-slot-presence";
|
||||
|
||||
export type PluginSlotProps = {
|
||||
/** Named slot to render — see PLUGIN-API.md for the initial set of slot names. */
|
||||
@@ -16,6 +18,8 @@ export type PluginSlotProps = {
|
||||
* on the current plugin id themselves.
|
||||
*/
|
||||
ownerPluginId?: string;
|
||||
/** Owners whose contextual toolbar is already rendered in this surface. */
|
||||
excludePluginIds?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,11 +28,14 @@ export type PluginSlotProps = {
|
||||
* own error boundary so one broken plugin can't break the host surface. Pass
|
||||
* `ownerPluginId` to restrict rendering to that plugin's own components.
|
||||
*/
|
||||
export function PluginSlot({ name, slotProps, ownerPluginId }: PluginSlotProps) {
|
||||
export function PluginSlot({ name, slotProps, ownerPluginId, excludePluginIds }: PluginSlotProps) {
|
||||
const registry = usePluginRegistry();
|
||||
const registrations = registry
|
||||
.getSlotRegistrations(name)
|
||||
.filter((registration) => !ownerPluginId || registration.pluginId === ownerPluginId);
|
||||
.filter(
|
||||
({ pluginId }) =>
|
||||
(!ownerPluginId || pluginId === ownerPluginId) && !excludePluginIds?.includes(pluginId),
|
||||
);
|
||||
|
||||
if (registrations.length === 0) return null;
|
||||
|
||||
@@ -56,9 +63,16 @@ export function PluginSlotRegistrationView({
|
||||
slotProps?: unknown;
|
||||
}) {
|
||||
const { pluginId, Component } = registration;
|
||||
return (
|
||||
const observedSlot = useContext(ObservedPluginSlotContext);
|
||||
const content = (
|
||||
<PluginErrorBoundary context={`plugin "${pluginId}" slot "${name}" component`}>
|
||||
<Component slotProps={slotProps} />
|
||||
</PluginErrorBoundary>
|
||||
);
|
||||
if (observedSlot !== name) return content;
|
||||
return (
|
||||
<div className="contents [&>*]:min-w-0 [&>*]:max-w-full" data-plugin-slot-owner={pluginId}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function TaskTopBarPluginActions(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full min-w-0 max-w-full flex-wrap items-center gap-2 overflow-x-clip [&>*]:min-w-0 [&>*]:max-w-full [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11 [&_[data-slot=button]]:!max-w-full [&_[data-slot=button]]:!whitespace-normal"
|
||||
className="flex min-w-0 max-w-full flex-wrap items-center gap-2 overflow-x-clip [&>*]:min-w-0 [&>*]:max-w-full [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11 [&_[data-slot=button]]:!max-w-full [&_[data-slot=button]]:!whitespace-normal"
|
||||
data-testid="mobile-chat-top-bar-plugin-actions"
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -129,9 +129,16 @@ test.describe("Mobile plugin menu actions", () => {
|
||||
}
|
||||
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const width of [768, 1440]) {
|
||||
await testPage.setViewportSize({ width, height: 900 });
|
||||
await testPage.goto("/tasks");
|
||||
await expect(testPage.locator("#hello-main-top-bar")).toBeVisible();
|
||||
await expect(testPage.getByTestId("mobile-plugin-nav-section")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// @covers AC-UI-MOBILE-TASK-CHROME-001.7
|
||||
// @covers AC-UI-MOBILE-TASK-CHROME-001.7, AC-UI-MOBILE-MENU-008.1
|
||||
test("moves session contributions from a long task header into the Plugins section", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -178,9 +185,9 @@ test.describe("Mobile plugin menu actions", () => {
|
||||
await pluginSection.scrollIntoViewIfNeeded();
|
||||
await waitForFiniteAnimations(menu);
|
||||
await expect(menu.getByText("Plugins", { exact: true })).toHaveCount(1);
|
||||
await expect(pluginSection.getByRole("heading", { name: "Workspace" })).toBeVisible();
|
||||
await expect(pluginSection.getByRole("heading", { name: "Task", exact: true })).toBeVisible();
|
||||
await expect(pluginSection.locator("#hello-main-top-bar")).toHaveCount(1);
|
||||
await expect(pluginSection.getByRole("heading", { name: "Workspace" })).toHaveCount(0);
|
||||
await expect(pluginSection.getByRole("heading", { name: "Task", exact: true })).toHaveCount(0);
|
||||
await expect(pluginSection.locator("#hello-main-top-bar")).toHaveCount(0);
|
||||
const workspaceAction = pluginSection.getByTestId("e2e-sidebar-workspace-actions");
|
||||
await expect(workspaceAction).toHaveCount(1);
|
||||
await workspaceAction.tap();
|
||||
@@ -214,11 +221,7 @@ test.describe("Mobile plugin menu actions", () => {
|
||||
await testPage.setViewportSize({ width, height: 851 });
|
||||
await pluginSection.evaluate((element) => element.scrollIntoView({ block: "start" }));
|
||||
const sectionBox = await requireBox(pluginSection, "Plugins section");
|
||||
for (const control of [
|
||||
action,
|
||||
workspaceAction,
|
||||
pluginSection.locator("#hello-main-top-bar"),
|
||||
]) {
|
||||
for (const control of [action, workspaceAction]) {
|
||||
const box = await requireBox(control, "plugin control");
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||||
@@ -231,7 +234,7 @@ test.describe("Mobile plugin menu actions", () => {
|
||||
await testPage.emulateMedia({ colorScheme });
|
||||
await waitForFiniteAnimations(menu);
|
||||
await prCapture.screenshot(`mobile-task-plugin-menu-${colorScheme}`, {
|
||||
caption: `Workspace and task plugins grouped together, followed by resources (${colorScheme})`,
|
||||
caption: `One contextual toolbar per plugin, followed by resources (${colorScheme})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -248,6 +251,17 @@ test.describe("Mobile plugin menu actions", () => {
|
||||
await testPage.getByTestId("app-nav-trigger").tap();
|
||||
await expect(menu.getByTestId("mobile-plugin-nav-section")).toBeVisible();
|
||||
await expect(menu.getByTestId("app-status-metrics")).toHaveCount(0);
|
||||
|
||||
// @covers AC-UI-MOBILE-MENU-008.2
|
||||
await testPage.keyboard.press("Escape");
|
||||
await apiClient.archiveTask(task.id);
|
||||
await testPage.reload();
|
||||
await menuTrigger.tap();
|
||||
await expect(pluginSection.locator("#hello-main-top-bar")).toBeVisible();
|
||||
await expect(status).toHaveCount(0);
|
||||
await expect(action).toHaveCount(0);
|
||||
await expect(workspaceAction).toBeVisible();
|
||||
|
||||
if (prCapture.capturing) {
|
||||
await testPage.setViewportSize({ width: 393, height: 851 });
|
||||
await testPage.goto("/?home=overview");
|
||||
|
||||
@@ -127,6 +127,9 @@ export interface IntegrationSettingsRegistration {
|
||||
* "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage,
|
||||
* presentation }`). On phones, `presentation` is "mobile": contributions
|
||||
* live in the listing topbar menu with 44px touch targets and 16px SVG icons.
|
||||
* When task controls are present, each plugin's chat-top-bar registrations
|
||||
* replace its main-top-bar registrations in that menu. Listings and archived
|
||||
* tasks retain workspace controls; sidebar workspace actions stay independent.
|
||||
* Slots retain ownership of their controls and disclosure state; arbitrary
|
||||
* interactions do not dismiss the host menu. Desktop sizing stays unchanged.
|
||||
* "app-status-bar-left" / "app-status-bar-right" (receives
|
||||
|
||||
Reference in New Issue
Block a user