fix: deduplicate mobile plugin toolbars (#3908)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-24 16:18:07 +01:00
committed by GitHub
parent 5cb908e4ad
commit 9fae7ab500
20 changed files with 597 additions and 58 deletions
+5
View File
@@ -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
View File
@@ -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")
+17 -3
View File
@@ -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");
+3
View File
@@ -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