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
@@ -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.
+8
View File
@@ -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
+10 -4
View File
@@ -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
+7
View File
@@ -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.