diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index f4d5b8324a..9346d77146 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -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. */ diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index accf8a15b0..c92bffa4db 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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) diff --git a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx index 3230f1f59e..55997e1e57 100644 --- a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx +++ b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx @@ -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( () => ({ @@ -55,7 +62,9 @@ export function MainTopBarPluginActions(props: { [workspaceId, workspaceLabel, currentPage, presentation], ); - const content = ; + const content = ( + + ); if (presentation === "desktop") return content; return ( diff --git a/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx b/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx index c1e5384ef6..84205ac43b 100644 --- a/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx +++ b/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx @@ -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 ; @@ -62,7 +66,7 @@ describe("MobilePluginNavSection workspace grouping", () => { renderSection(undefined, , 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, () => ); + plugin.registerComponent(TASK_SLOT, () => ); + plugin.registerComponent(TASK_SLOT, () => ); + plugin.registerComponent(SIDEBAR_SLOT, () => ); + pluginRegistry.forPlugin("plugin-b").registerComponent(MAIN_SLOT, () => ); + + renderSection(undefined, , 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, () => ); + const { rerender } = renderSection( + undefined, + , + true, + workspaceContext, + ); + expect(screen.getByRole("button", { name: WORKSPACE_USAGE })).toBeTruthy(); + + await act(async () => plugin.registerComponent(TASK_SLOT, () => )); + expect(screen.queryByRole("button", { name: WORKSPACE_USAGE })).toBeNull(); + expect(screen.getByRole("button", { name: "Task usage" })).toBeTruthy(); + + rerender( {}} 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, () => ); + plugin.registerComponent(TASK_SLOT, ({ slotProps }) => + (slotProps as { available: boolean }).available ? : null, + ); + const section = (available: boolean) => ( + {}} + workspaceContext={workspaceContext} + actions={} + /> + ); + 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", () => ); + .registerComponent(MAIN_SLOT, () => ); 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", () => ); + .registerComponent(SIDEBAR_SLOT, () => ); const { container } = renderSection(undefined, undefined, true, { currentPage: "kanban" }); expect(container.innerHTML).toBe(""); }); diff --git a/apps/web/components/plugins/mobile-plugin-nav-section.tsx b/apps/web/components/plugins/mobile-plugin-nav-section.tsx index d1ef06a8e5..f6d3a0b1fb 100644 --- a/apps/web/components/plugins/mobile-plugin-nav-section.tsx +++ b/apps/web/components/plugins/mobile-plugin-nav-section.tsx @@ -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([]); + 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")} >

{t("common:plugins")}

- {hasWorkspaceActions && workspaceContext && ( -
- {actions &&

{t("common:workspace")}

} - -
- )} - {actions && ( -
- {hasWorkspaceActions && ( -

{t("common:task")}

+ {(hasWorkspaceActions || actions) && ( +
+ {hasWorkspaceActions && workspaceContext && ( + + )} + {actions && ( + + {actions} + )} - {actions}
)} {destinations.length > 0 && ( @@ -103,14 +106,22 @@ function WorkspacePluginControls({ context, hasMainActions, hasSidebarActions, + excludePluginIds, }: { context: MobilePluginWorkspaceContext; hasMainActions: boolean; hasSidebarActions: boolean; + excludePluginIds: readonly string[]; }) { return (
- {hasMainActions && } + {hasMainActions && ( + + )} {hasSidebarActions && context.workspaceId && ( , 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, diff --git a/apps/web/components/plugins/plugin-slot-presence.tsx b/apps/web/components/plugins/plugin-slot-presence.tsx new file mode 100644 index 0000000000..bafde08e0f --- /dev/null +++ b/apps/web/components/plugins/plugin-slot-presence.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { createContext, useLayoutEffect, useRef, type ReactNode } from "react"; + +export const ObservedPluginSlotContext = createContext(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(null); + + useLayoutEffect(() => { + const element = container.current; + if (!element) return; + let previous: string[] | undefined; + const update = () => { + const owners = new Set(); + for (const registration of element.querySelectorAll( + "[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 ( + +
+ {children} +
+
+ ); +} diff --git a/apps/web/components/plugins/plugin-slot.test.tsx b/apps/web/components/plugins/plugin-slot.test.tsx index e504c500e9..ab68453eaf 100644 --- a/apps/web/components/plugins/plugin-slot.test.tsx +++ b/apps/web/components/plugins/plugin-slot.test.tsx @@ -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, () => ); + pluginRegistry.forPlugin("plugin-b").registerComponent(SLOT, () => ); + const { rerender } = render(); + expect(screen.queryByRole("button", { name: "Usage" })).toBeNull(); + expect(screen.getByRole("button", { name: "CPU" })).toBeTruthy(); + + rerender(); + 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") diff --git a/apps/web/components/plugins/plugin-slot.tsx b/apps/web/components/plugins/plugin-slot.tsx index d7fa3f5f2d..15f3f80942 100644 --- a/apps/web/components/plugins/plugin-slot.tsx +++ b/apps/web/components/plugins/plugin-slot.tsx @@ -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 = ( ); + if (observedSlot !== name) return content; + return ( +
+ {content} +
+ ); } diff --git a/apps/web/components/task/task-top-bar-plugin-actions.tsx b/apps/web/components/task/task-top-bar-plugin-actions.tsx index c568d7cc80..48655cbf40 100644 --- a/apps/web/components/task/task-top-bar-plugin-actions.tsx +++ b/apps/web/components/task/task-top-bar-plugin-actions.tsx @@ -76,7 +76,7 @@ export function TaskTopBarPluginActions(props: { return (
{content} diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts index ea50874808..1ae899738f 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts @@ -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"); diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index a425204e16..b966d68366 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -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 diff --git a/docs/plans/mobile-plugin-deduplication/plan.md b/docs/plans/mobile-plugin-deduplication/plan.md new file mode 100644 index 0000000000..c9189fe732 --- /dev/null +++ b/docs/plans/mobile-plugin-deduplication/plan.md @@ -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. diff --git a/docs/plans/mobile-plugin-deduplication/task-01-select-mobile-toolbar.md b/docs/plans/mobile-plugin-deduplication/task-01-select-mobile-toolbar.md new file mode 100644 index 0000000000..654ea1ece0 --- /dev/null +++ b/docs/plans/mobile-plugin-deduplication/task-01-select-mobile-toolbar.md @@ -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. diff --git a/docs/plans/mobile-plugin-menu-coherence/plan.md b/docs/plans/mobile-plugin-menu-coherence/plan.md index dd2e8a175e..ec01bf8ed4 100644 --- a/docs/plans/mobile-plugin-menu-coherence/plan.md +++ b/docs/plans/mobile-plugin-menu-coherence/plan.md @@ -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. diff --git a/docs/plans/mobile-plugin-menu-coherence/task-01-group-phone-plugins.md b/docs/plans/mobile-plugin-menu-coherence/task-01-group-phone-plugins.md index 8b225a66c8..2ceba275d4 100644 --- a/docs/plans/mobile-plugin-menu-coherence/task-01-group-phone-plugins.md +++ b/docs/plans/mobile-plugin-menu-coherence/task-01-group-phone-plugins.md @@ -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. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index b4b4376bda..2c4d8db055 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -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 diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index cc0fc79a06..eed11b0f46 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -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 > ``**), 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 diff --git a/docs/public/plugins.md b/docs/public/plugins.md index 28f8c90c9d..82b0e84a06 100644 --- a/docs/public/plugins.md +++ b/docs/public/plugins.md @@ -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 > diff --git a/docs/specs/ui/requirements/unified-mobile-navigation.md b/docs/specs/ui/requirements/unified-mobile-navigation.md index bb9a0344fb..2bca204707 100644 --- a/docs/specs/ui/requirements/unified-mobile-navigation.md +++ b/docs/specs/ui/requirements/unified-mobile-navigation.md @@ -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) diff --git a/docs/specs/ui/system-design/unified-mobile-navigation.md b/docs/specs/ui/system-design/unified-mobile-navigation.md index 36a48b6751..f2025e46e7 100644 --- a/docs/specs/ui/system-design/unified-mobile-navigation.md +++ b/docs/specs/ui/system-design/unified-mobile-navigation.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.