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
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
created: 2026-09-24
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-008
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Mobile plugin deduplication
|
||||
|
||||
## Overview
|
||||
|
||||
The phone menu independently renders workspace and task toolbar registrations.
|
||||
Provider Usage creates separate component instances for the same status in both
|
||||
slots, so component identity cannot identify the duplicate. Choose the task
|
||||
slot per plugin owner when its task control renders content. The user explicitly authorized
|
||||
uninterrupted implementation and PR delivery in this session.
|
||||
|
||||
## Scope and technical approach
|
||||
|
||||
Add an owner exclusion to the host PluginSlot and MainTopBarPluginActions.
|
||||
MobilePluginNavSection selects exclusions from chat-top-bar owners with mounted
|
||||
content in its supplied task actions. PluginSlotPresence observes owner wrappers
|
||||
to preserve the workspace fallback while task controls render no content. Flatten the two context groups into
|
||||
one wrapping row; retain sidebar workspace actions and plugin destinations.
|
||||
No SDK signature, plugin implementation, persistence, or desktop behavior changes
|
||||
are required.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Task hamburger menu. Existing inset drawer, fixed Menu heading, one
|
||||
safe-area-aware vertical scroller, and 44px touch controls remain the exemplar.
|
||||
|
||||
```text
|
||||
Before After
|
||||
Plugins Plugins
|
||||
Workspace [CPU] [Companion] [Usage 42%]
|
||||
[CPU] [Usage 42%] [Plugin destinations]
|
||||
Task
|
||||
[Companion] [Usage 42%]
|
||||
```
|
||||
|
||||
Task controls keep task/session context. Listings use workspace controls.
|
||||
No contributions means no section. Desktop retains its separate toolbar
|
||||
locations. Wrapping and hierarchy are requirements; spacing is illustrative.
|
||||
|
||||
## Tests and E2E
|
||||
|
||||
Existing plugin slot and mobile section unit suites cover identity, distinct
|
||||
owners, multiple task actions, workspace fallback, unload, and saved layouts.
|
||||
The packaged mobile plugin E2E verifies actions, context, 320/393/767px
|
||||
containment, and desktop breakpoint behavior. Capture a real installed Provider
|
||||
Usage plugin with deterministic provider data and API-seeded task records on
|
||||
base and fixed builds; no DOM replacement.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Select one mobile toolbar per plugin](task-01-select-mobile-toolbar.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- RED: four focused unit failures reproduced owner duplication and subgroup headings.
|
||||
- GREEN: 64 tests in five focused Vitest suites passed, including task slot
|
||||
context and error-boundary coverage. The task slot suite was included in the
|
||||
recorded command in addition to the four listed initially.
|
||||
- Managed production build: all three selected mobile scenarios passed (two
|
||||
permanent regressions plus the disposable installed-plugin capture).
|
||||
- Final mobile E2E: two scenarios passed after adding archived-task fallback
|
||||
and 768/1440px parity checks. Phone assertions cover 320/393/767px, control
|
||||
activation, current task/session props, one scroller, touch targets, and no
|
||||
horizontal overflow. This final run reused the same fresh production build.
|
||||
- Web typecheck, changed-file ESLint, Prettier, and i18n ratchet passed.
|
||||
- Public docs: 62 validator tests and 47 pages passed. Catalog validation:
|
||||
304 decisions and 1,136 specifications; specification and harness lint passed.
|
||||
- Five inspected screenshots: before/after dark and light plus expanded usage.
|
||||
Installed the unmodified Provider Usage 0.9.3 release in isolated backends;
|
||||
seeded three Atlas Studio tasks, a session, and messages through the API.
|
||||
The provider's quota HTTP response is deterministic fixture data (42% session,
|
||||
28% weekly); no DOM replacement or live account data. Before assets used base
|
||||
d60274528129dc17d413357c104c9c427c4e7af9; after assets used the fixed build.
|
||||
- Capture backend PIDs exited, ports closed, and temporary databases/repositories
|
||||
were removed by the fixture. The disposable capture spec was removed; only
|
||||
evidence remains in ignored PR assets and an external artifact directory.
|
||||
- The phone Plugins drawer is absent from desktop; desktop parity is verified
|
||||
by the 768/1440px route checks rather than an unrelated screenshot.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## Risks
|
||||
|
||||
A plugin with both toolbar slots must put its task-relevant actions in its task
|
||||
slot. Sidebar workspace actions are independent and remain reachable. Owner
|
||||
identity is stable even when component factories return distinct functions.
|
||||
|
||||
## CI documentation follow-up
|
||||
|
||||
The coverage evaluator identified the new requirement missing from the design's
|
||||
frontmatter. Added `REQ-UI-MOBILE-MENU-008` to the design dependency list. The
|
||||
exact repository coverage evaluator failed before the metadata correction and
|
||||
passes afterward (`status: covered`), along with catalog/spec lint and diff
|
||||
checks. Product code and screenshot content are unchanged.
|
||||
|
||||
The authoring-guide review identified stale Workspace/Task subgroup guidance.
|
||||
Updated both slot tables, the canonical guide, PLUGIN-API reference, and SDK/host
|
||||
type comments to describe the same owner-based selection. Public docs validation
|
||||
(62 tests, 47 pages), coverage evaluation, spec/catalog lint, and diff checks pass.
|
||||
|
||||
## Null-rendering task fallback
|
||||
|
||||
Review found registered task controls can return null before a session exists.
|
||||
The new regression fails under registration-only selection. Observe mounted
|
||||
content in layout-neutral owner wrappers, retaining workspace controls until
|
||||
task content appears and restoring them if it disappears. An observer scoped
|
||||
to the task slot disconnects on unmount and only reports changed owner sets.
|
||||
Targeted unit tests, fresh production mobile E2E, and refreshed after screenshots
|
||||
verify the correction: 64 focused unit tests and all three selected mobile
|
||||
scenarios passed against a fresh production build.
|
||||
|
||||
## Context presentation clarification
|
||||
|
||||
The final review identified an older acceptance criterion still requiring
|
||||
workspace/task distinction. AC-UI-MOBILE-MENU-007.2 now delegates plugin
|
||||
presentation to 008.1/008.2, and 008.1 explicitly supersedes the former subgroup
|
||||
distinction. This documentation correction preserves the verified behavior and
|
||||
screenshots. Catalog validation, specification lint, the exact PR documentation
|
||||
coverage evaluator, and diff checks passed.
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
id: "01-select-mobile-toolbar"
|
||||
title: "Select one mobile toolbar per plugin"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-008
|
||||
acceptance_criteria:
|
||||
- AC-UI-MOBILE-MENU-008.1
|
||||
- AC-UI-MOBILE-MENU-008.2
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
---
|
||||
|
||||
# Task 01: Select one mobile toolbar per plugin
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Choose task toolbar registrations in the phone menu for plugins contributing
|
||||
both toolbars, retain all other controls, and remove redundant context headings.
|
||||
Own the host render filtering, mobile composition, regressions, documentation,
|
||||
and seeded before/after PR evidence. Plugin implementation, SDK signatures, and backend remain out of scope.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One toolbar per plugin on tasks; all task actions retain context.
|
||||
- Listings, archived tasks, live unload, and distinct workspace plugins work.
|
||||
- Actions fit 320/393/767px, retain 44px targets, and desktop is unchanged.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Task hamburger menu. Existing inset drawer, fixed Menu heading, one
|
||||
safe-area-aware vertical scroller, and 44px touch controls remain the exemplar.
|
||||
|
||||
```text
|
||||
Before After
|
||||
Plugins Plugins
|
||||
Workspace [CPU] [Companion] [Usage 42%]
|
||||
[CPU] [Usage 42%] [Plugin destinations]
|
||||
Task
|
||||
[Companion] [Usage 42%]
|
||||
```
|
||||
|
||||
Task controls keep task/session context. Listings use workspace controls.
|
||||
No contributions means no section. Desktop retains its separate toolbar
|
||||
locations. Wrapping and hierarchy are requirements; spacing is illustrative.
|
||||
|
||||
Full preview: [plan](plan.md#ascii-ui-preview).
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/plugins/plugin-slot.tsx` and existing tests
|
||||
- `apps/web/components/plugins/plugin-slot-presence.tsx`
|
||||
- `apps/web/components/plugins/mobile-plugin-nav-section.tsx` and existing tests
|
||||
- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx`
|
||||
- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`
|
||||
- Relevant UI requirements/design, frontend guide, and public plugin guide
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm exec vitest run components/plugins/plugin-slot.test.tsx components/plugins/mobile-plugin-nav-section.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/navigation/app-nav-sheet.test.tsx components/task/task-top-bar-plugin-actions.test.tsx)
|
||||
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/plugins/mobile-plugin-topbar.spec.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run changed-file ESLint/Prettier and i18n ratchet; inspect and publish seeded
|
||||
screenshots. Dependencies: none. Parallelism: sequential.
|
||||
|
||||
## Risks
|
||||
|
||||
Owner-based toolbar selection intentionally replaces a plugin's workspace
|
||||
toolbar on task pages. All entries within its selected slot remain intact.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: four focused unit failures reproduced owner duplication and subgroup headings.
|
||||
- GREEN: 64 tests in five focused Vitest suites passed, including task slot
|
||||
context and error-boundary coverage. The task slot suite was included in the
|
||||
recorded command in addition to the four listed initially.
|
||||
- Managed production build: all three selected mobile scenarios passed (two
|
||||
permanent regressions plus the disposable installed-plugin capture).
|
||||
- Final mobile E2E: two scenarios passed after adding archived-task fallback
|
||||
and 768/1440px parity checks. Phone assertions cover 320/393/767px, control
|
||||
activation, current task/session props, one scroller, touch targets, and no
|
||||
horizontal overflow. This final run reused the same fresh production build.
|
||||
- Web typecheck, changed-file ESLint, Prettier, and i18n ratchet passed.
|
||||
- Public docs: 62 validator tests and 47 pages passed. Catalog validation:
|
||||
304 decisions and 1,136 specifications; specification and harness lint passed.
|
||||
- Five inspected screenshots: before/after dark and light plus expanded usage.
|
||||
Installed the unmodified Provider Usage 0.9.3 release in isolated backends;
|
||||
seeded three Atlas Studio tasks, a session, and messages through the API.
|
||||
The provider's quota HTTP response is deterministic fixture data (42% session,
|
||||
28% weekly); no DOM replacement or live account data. Before assets used base
|
||||
d60274528129dc17d413357c104c9c427c4e7af9; after assets used the fixed build.
|
||||
- Capture backend PIDs exited, ports closed, and temporary databases/repositories
|
||||
were removed by the fixture. The disposable capture spec was removed; only
|
||||
evidence remains in ignored PR assets and an external artifact directory.
|
||||
- The phone Plugins drawer is absent from desktop; desktop parity is verified
|
||||
by the 768/1440px route checks rather than an unrelated screenshot.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## CI documentation follow-up
|
||||
|
||||
The coverage evaluator identified the new requirement missing from the design's
|
||||
frontmatter. Added `REQ-UI-MOBILE-MENU-008` to the design dependency list. The
|
||||
exact repository coverage evaluator failed before the metadata correction and
|
||||
passes afterward (`status: covered`), along with catalog/spec lint and diff
|
||||
checks. Product code and screenshot content are unchanged.
|
||||
|
||||
The authoring-guide review identified stale Workspace/Task subgroup guidance.
|
||||
Updated both slot tables, the canonical guide, PLUGIN-API reference, and SDK/host
|
||||
type comments to describe the same owner-based selection. Public docs validation
|
||||
(62 tests, 47 pages), coverage evaluation, spec/catalog lint, and diff checks pass.
|
||||
|
||||
## Null-rendering task fallback
|
||||
|
||||
Review found registered task controls can return null before a session exists.
|
||||
The new regression fails under registration-only selection. Observe mounted
|
||||
content in layout-neutral owner wrappers, retaining workspace controls until
|
||||
task content appears and restoring them if it disappears. An observer scoped
|
||||
to the task slot disconnects on unmount and only reports changed owner sets.
|
||||
Targeted unit tests, fresh production mobile E2E, and refreshed after screenshots
|
||||
verify the correction: 64 focused unit tests and all three selected mobile
|
||||
scenarios passed against a fresh production build.
|
||||
|
||||
## Context presentation clarification
|
||||
|
||||
The final review identified an older acceptance criterion still requiring
|
||||
workspace/task distinction. AC-UI-MOBILE-MENU-007.2 now delegates plugin
|
||||
presentation to 008.1/008.2, and 008.1 explicitly supersedes the former subgroup
|
||||
distinction. This documentation correction preserves the verified behavior and
|
||||
screenshots. Catalog validation, specification lint, the exact PR documentation
|
||||
coverage evaluator, and diff checks passed.
|
||||
@@ -123,3 +123,12 @@ AC-UI-MOBILE-TASK-CHROME-001.6/.7.
|
||||
Plugins own their markup. Preserve opaque controls and separate workspace/task
|
||||
contexts rather than deduplicating contributions by appearance. Saved sidebar
|
||||
layouts already own destinations and must not gain duplicate links.
|
||||
|
||||
## Subsequent toolbar-selection correction
|
||||
|
||||
The [September 24 follow-up](../mobile-plugin-deduplication/plan.md) supersedes
|
||||
the Workspace/Task subheadings and simultaneous toolbar rendering described
|
||||
here. Phone navigation now selects each plugin's task toolbar when task actions
|
||||
exist, while preserving workspace-only controls and independent sidebar actions.
|
||||
The follow-up records current regression coverage and seeded screenshots; the
|
||||
results above remain the historical September 23 delivery record.
|
||||
|
||||
@@ -145,3 +145,12 @@ containment and test host buttons and raw sidebar controls separately.
|
||||
The history suite's retry override was temporarily removed for these runs
|
||||
and restored when the unsuccessful experiment was removed.
|
||||
No production behavior or screenshot changed during this follow-up.
|
||||
|
||||
## Subsequent toolbar-selection correction
|
||||
|
||||
The [September 24 follow-up](../mobile-plugin-deduplication/plan.md) supersedes
|
||||
the Workspace/Task subheadings and simultaneous toolbar rendering described
|
||||
here. Phone navigation now selects each plugin's task toolbar when task actions
|
||||
exist, while preserving workspace-only controls and independent sidebar actions.
|
||||
The follow-up records current regression coverage and seeded screenshots; the
|
||||
results above remain the historical September 23 delivery record.
|
||||
|
||||
@@ -1043,6 +1043,11 @@ interface PluginRegistry {
|
||||
// `ChatTopBarSlotProps`: `{ taskId, taskTitle, workspaceId,
|
||||
// activeSessionId, sessionIds, presentation }`. On a phone, presentation is
|
||||
// "mobile" and contributions live inside the shared Plugins menu section.
|
||||
// When task controls are present, a plugin's chat-top-bar registrations
|
||||
// replace its main-top-bar registrations in that menu. Every registration
|
||||
// in the selected slot renders; sidebar workspace actions stay independent.
|
||||
// Null-rendering task controls retain the workspace fallback until content
|
||||
// appears; the fallback returns if the task content disappears.
|
||||
// The host gives `host.ui.Button` controls a 44px touch target. Arbitrary
|
||||
// plugin interaction does not dismiss the menu. Both presentations carry
|
||||
// the active session plus every kandev session id on the task.
|
||||
@@ -1056,6 +1061,9 @@ interface PluginRegistry {
|
||||
// contribution sizing is unchanged. It is the app-wide,
|
||||
// task-agnostic counterpart to "chat-top-bar", so it carries no task/session
|
||||
// ids.
|
||||
// Phone listings and archived tasks retain main-top-bar controls. On other
|
||||
// task pages, workspace-only plugins remain alongside the selected task
|
||||
// toolbars in one group without Workspace/Task subheadings.
|
||||
// "sidebar-workspace-actions" renders icon buttons after the built-in Quick
|
||||
// Terminal and Quick Chat actions in the desktop sidebar's New Task row and
|
||||
// in the shared phone navigation sheet. It forwards
|
||||
|
||||
@@ -645,7 +645,7 @@ to strings, but an unmounted name renders nowhere.
|
||||
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
|
||||
| chat-submit-decoration | Layer over the composer's send button | ChatSubmitDecorationSlotProps |
|
||||
| chat-top-bar | Session top bar or phone Plugins menu | ChatTopBarSlotProps |
|
||||
| main-top-bar | Home/Kanban/Tasks top bar or phone Plugins menu | MainTopBarSlotProps |
|
||||
| main-top-bar | Home/Kanban/Tasks top bar or phone Plugins menu; a task toolbar from the same plugin takes precedence | MainTopBarSlotProps |
|
||||
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
||||
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
|
||||
@@ -1887,7 +1887,7 @@ plugins at once. Available slots:
|
||||
| `new-session-input-actions` | New-session composer toolbar | `PluginComposerSlotProps` |
|
||||
| `chat-submit-decoration` | Layer over the chat composer's send button, for adornments that belong on the send affordance itself | `ChatSubmitDecorationSlotProps` |
|
||||
| `chat-top-bar` | Session top bar on desktop; shared Plugins menu section on phones | `ChatTopBarSlotProps` |
|
||||
| `main-top-bar` | Default app top bar on desktop; shared Plugins menu section on phones | `MainTopBarSlotProps` |
|
||||
| `main-top-bar` | Default app top bar on desktop; phone Plugins menu unless the same plugin supplies task controls | `MainTopBarSlotProps` |
|
||||
| `app-status-bar-left` | Default-left item in the global status surface | `AppStatusBarSlotProps` |
|
||||
| `app-status-bar-right` | Default-right item in the global status surface | `AppStatusBarSlotProps` |
|
||||
| `plugin-settings` | A plugin's own settings page (**Settings > Plugins > `<plugin>`**), at the top above the settings form | `{ pluginId, status }` |
|
||||
@@ -2099,8 +2099,14 @@ Because the bar is not scoped to a task, no task/session ids are provided. On
|
||||
desktop, keep contributions to small badges or icon buttons in the compact
|
||||
horizontal strip. On a phone, `presentation` is `"mobile"`; the contribution
|
||||
renders in the shared app menu's **Plugins** section alongside sidebar workspace
|
||||
actions. When task contributions are also present, **Workspace** and **Task**
|
||||
labels distinguish their context. The host wraps
|
||||
actions. On a task with task controls, a plugin's `chat-top-bar` registrations
|
||||
replace that same plugin's `main-top-bar` registrations once task content renders.
|
||||
If task controls return `null`, the workspace toolbar remains available until
|
||||
task content appears; it returns if task content disappears. Keep task-relevant
|
||||
actions in `chat-top-bar`; every registration in that selected slot renders.
|
||||
Workspace-only plugins and sidebar workspace actions remain available. Listings
|
||||
and archived tasks use the workspace toolbar. The menu uses one wrapping group
|
||||
without Workspace/Task subheadings. The host wraps
|
||||
contributions within the menu width and gives `host.ui.Button` controls a
|
||||
minimum 44px active target. Use `host.ui.Button` for documented icon actions;
|
||||
the host normalizes their SVG icons to 16px. Desktop contributions keep their
|
||||
|
||||
@@ -97,6 +97,13 @@ Kandev does not inspect or reorder children inside a contribution, and disabled
|
||||
plugins return to their saved position when re-enabled.
|
||||
Full-bleed routes that opt out of host topbar chrome own their Status trigger.
|
||||
|
||||
## Use plugins on a phone
|
||||
|
||||
Open the hamburger menu to find plugin controls together under **Plugins**.
|
||||
On a task, a plugin's task toolbar takes the place of its workspace toolbar,
|
||||
so shared status controls appear once. Workspace-only controls, sidebar actions,
|
||||
and plugin pages remain available. Desktop keeps its separate toolbar locations.
|
||||
|
||||
## Installing a plugin
|
||||
|
||||
The easiest way to install is from the in-app catalog: **Settings > Plugins >
|
||||
|
||||
@@ -176,8 +176,9 @@ the full task list.
|
||||
indicators, launch behavior, and focus handoff shall be retained.
|
||||
- **AC-UI-MOBILE-MENU-007.2:** Regular workspace sections shall follow Tasks,
|
||||
Automations, Integrations, Utilities order. Existing eligible plugin actions
|
||||
shall share one Plugins section, with workspace and task context distinguished
|
||||
when both are present. Optional system metrics shall follow navigation instead
|
||||
shall share one Plugins section. Toolbar selection and context presentation
|
||||
shall follow AC-UI-MOBILE-MENU-008.1 and AC-UI-MOBILE-MENU-008.2.
|
||||
Optional system metrics shall follow navigation instead
|
||||
of separating plugin controls. Canvases and page-local destinations shall
|
||||
remain reachable. Utilities shall
|
||||
place Settings before Stats, followed by theme and support actions; existing
|
||||
@@ -195,6 +196,24 @@ the full task list.
|
||||
navigation composition, Tasks' initial expanded state, and Automations'
|
||||
initial collapsed state shall remain unchanged.
|
||||
|
||||
### REQ-UI-MOBILE-MENU-008: One contextual toolbar per plugin
|
||||
|
||||
- **AC-UI-MOBILE-MENU-008.1:** Phone navigation shall show plugin controls in
|
||||
one wrapping group under Plugins without Workspace and Task subheadings.
|
||||
This supersedes the former workspace/task subgroup distinction in
|
||||
AC-UI-MOBILE-MENU-007.2.
|
||||
When task controls are available, each plugin contributing to the task
|
||||
toolbar shall use that toolbar instead of its workspace toolbar. All task
|
||||
contributions from that plugin remain reachable. Workspace-only plugins
|
||||
and sidebar workspace actions remain available.
|
||||
- **AC-UI-MOBILE-MENU-008.2:** Listing pages and task pages without task
|
||||
contributions, including archived tasks, shall retain workspace toolbar
|
||||
controls. A registered task control that renders no content shall retain its
|
||||
workspace fallback until task content appears, and restore that fallback if
|
||||
task content disappears. Selection follows live contributions, preserves each selected
|
||||
slot's context and error isolation, and does not change desktop/tablet
|
||||
placement or plugin destinations in saved sidebar layouts.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Global bottom navigation, a second top-bar task-actions menu, or redesigned
|
||||
@@ -217,3 +236,5 @@ the full task list.
|
||||
|
||||
- [Unified mobile navigation](../../../plans/unified-mobile-navigation/plan.md)
|
||||
- [Coherent mobile plugin menu](../../../plans/mobile-plugin-menu-coherence/plan.md)
|
||||
|
||||
- [Mobile plugin deduplication](../../../plans/mobile-plugin-deduplication/plan.md)
|
||||
|
||||
@@ -9,6 +9,7 @@ requirements:
|
||||
- REQ-UI-MOBILE-MENU-005
|
||||
- REQ-UI-MOBILE-MENU-006
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-MOBILE-MENU-008
|
||||
---
|
||||
|
||||
# Unified mobile navigation design
|
||||
@@ -304,10 +305,21 @@ The September 23 composition refinement groups `main-top-bar`,
|
||||
`sidebar-workspace-actions`, and page-scoped `chat-top-bar` contributions inside
|
||||
`MobilePluginNavSection`. `AppNavSheet` supplies phone workspace context through
|
||||
`AppNavSections`; the section checks registrations before rendering workspace
|
||||
controls. When both workspace and task actions exist, localized context labels
|
||||
separate them. Keep each registration and its original props; identical-looking
|
||||
status controls can have different scopes and must not be deduplicated by the
|
||||
host. Empty sections disappear. `MobileWorkspaceActionsSection` retains canvases
|
||||
controls. Phone navigation uses one wrapping action group without workspace/task
|
||||
subheadings. When task actions are supplied, `PluginSlotPresence` observes
|
||||
owner-marked `chat-top-bar` registration wrappers and reports owners with mounted
|
||||
content. Exclude only those owners from `main-top-bar` in this menu. A layout
|
||||
effect measures initial content; a scoped MutationObserver follows asynchronous
|
||||
null/content changes and disconnects on unmount. Layout-neutral wrappers retain
|
||||
child width constraints. Null-rendering task controls retain workspace fallback;
|
||||
report only changed owner sets to avoid feedback from normal status updates.
|
||||
Keep every registration in the selected slot with its original props and error
|
||||
boundary. This chooses a contextual toolbar by plugin identity, never by rendered
|
||||
text or component identity (plugins may create separate component factories for
|
||||
the two slots). Sidebar workspace actions remain independent. Without task
|
||||
actions, including archived tasks, show all workspace toolbar controls. Recompute
|
||||
from live registry state; desktop/tablet slots and navigation destinations remain
|
||||
unchanged. Empty sections disappear. `MobileWorkspaceActionsSection` retains canvases
|
||||
and opts out of rendering plugin actions only for phone app navigation.
|
||||
|
||||
`NavigationMetrics` retains the status-bar preference gate and reuses
|
||||
@@ -352,3 +364,5 @@ Validate ordered geometry and both quick launch outcomes from Home and task
|
||||
workbench, disclosure keyboard/touch behavior, empty/configured integrations,
|
||||
workspace switches, long translated labels, and unchanged 768px/wider composition.
|
||||
Reuse the populated isolated preview and reapply its mock seed after any restart.
|
||||
|
||||
The [mobile plugin deduplication plan](../../../plans/mobile-plugin-deduplication/plan.md) records the September 24 toolbar selection correction.
|
||||
|
||||
Reference in New Issue
Block a user