fix(ui): polish mobile navigation and plugins (#3890)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-23 21:30:36 +01:00
committed by GitHub
parent 4bebf7c511
commit 9885fd40ba
34 changed files with 1637 additions and 218 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ 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 options and workspace tools in app navigation. 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`, 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.
- 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)
@@ -13,6 +13,7 @@ import { usePluginRegistry } from "@/lib/plugins/registry";
import { cn } from "@/lib/utils";
import { canvasHref, workspaceCanvasSettingsHref } from "@/lib/api/domains/canvas-api";
import { isActiveWorkspaceCanvas, useWorkspaceCanvases } from "./sections/canvases-section";
import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation";
/**
* Props forwarded to every plugin component registered for the
@@ -44,7 +45,7 @@ export function AppSidebarWorkspaceActions(props: {
className={cn(
"flex shrink-0 items-center",
presentation === "mobile"
? "gap-2 [&_a]:min-h-11 [&_a]:min-w-11 [&_button]:min-h-11 [&_button]:min-w-11"
? "min-w-0 max-w-full flex-wrap gap-2 [&_a]:min-h-11 [&_a]:min-w-11 [&_button]:min-h-11 [&_button]:min-w-11"
: "gap-1",
)}
data-plugin-slot="sidebar-workspace-actions"
@@ -56,26 +57,30 @@ export function AppSidebarWorkspaceActions(props: {
}
/**
* Shared phone navigation entry point for workspace-scoped plugin actions.
* The same slot is mounted in the kanban drawer and at the top of the shared
* app navigation sheet on other phone surfaces.
* Workspace canvases and optional plugin actions for navigation menus.
* Phone app navigation renders plugin actions in its dedicated Plugins section.
*/
export function MobileWorkspaceActionsSection({
workspaceId: providedWorkspaceId,
includePluginActions = true,
}: {
workspaceId?: string;
includePluginActions?: boolean;
}) {
const { t } = useTranslation();
const activeWorkspaceId = useAppStore((state) => state.workspaces?.activeId ?? null);
const canvasesEnabled = useFeature("canvases");
const hasSavedSidebarLayout = useHasSavedSidebarLayout();
const showCanvases = canvasesEnabled && !hasSavedSidebarLayout;
const registry = usePluginRegistry();
const pathname = usePathname();
const workspaceId = providedWorkspaceId ?? activeWorkspaceId;
const canvases = useWorkspaceCanvases(canvasesEnabled ? workspaceId : null);
const canvases = useWorkspaceCanvases(showCanvases ? workspaceId : null);
const activeCanvases = canvases.filter(isActiveWorkspaceCanvas);
const hasPluginActions = registry.getSlotRegistrations("sidebar-workspace-actions").length > 0;
const hasPluginActions =
includePluginActions && registry.getSlotRegistrations("sidebar-workspace-actions").length > 0;
if (!workspaceId || (!hasPluginActions && !canvasesEnabled)) {
if (!workspaceId || (!hasPluginActions && !showCanvases)) {
return null;
}
@@ -86,7 +91,7 @@ export function MobileWorkspaceActionsSection({
role="group"
aria-label={t("common:workspace")}
>
{canvasesEnabled && (
{showCanvases && (
<div className="flex flex-col gap-1" data-testid="mobile-workspace-canvases">
<div className="flex items-center gap-2 px-1 text-xs font-semibold text-muted-foreground">
<IconLayoutGrid className="h-4 w-4" aria-hidden="true" />
@@ -2,6 +2,7 @@
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection";
import type {
@@ -64,6 +65,11 @@ function MobileShortcutSection({
<span className="flex items-center gap-2">
<node.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{node.label}</span>
{open ? (
<IconChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
{running && (
<ShortcutActivityIndicator
activity={{ state: "running", loading: false, error: false }}
@@ -281,6 +281,23 @@ describe("MobileIntegrationsSection", () => {
expect(screen.getByTestId("plugin-nav-item-hello")).toBeTruthy();
});
it("excludes separately customized plugins and omitted destinations from the built-in section", () => {
mockAvailability({ githubReady: true, jiraConfigured: false, linearConfigured: true });
registerHelloIntegrationItem();
render(
createElement(MobileIntegrationsSection, {
onNavigate: vi.fn(),
includePlugins: false,
omitDestinations: ["linear"],
showSetup: true,
}),
);
expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe("/github");
expect(screen.queryByRole("link", { name: HELLO_LABEL })).toBeNull();
expect(screen.queryByRole("link", { name: "Linear" })).toBeNull();
expect(screen.getByTestId("mobile-integration-settings")).toBeTruthy();
});
it("renders nothing when there are no links and no plugin items", () => {
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
@@ -17,11 +17,14 @@ import { useAppDestinations } from "@/hooks/use-app-destinations";
import { useAppStore } from "@/components/state-provider";
import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
type MobileIntegrationsSectionProps = {
onNavigate: () => void;
showSetup?: boolean;
collapsible?: boolean;
includePlugins?: boolean;
omitDestinations?: string[];
};
const HOVER_CLOSE_DELAY_MS = 180;
@@ -145,17 +148,22 @@ export function MobileIntegrationsSection({
onNavigate,
showSetup = false,
collapsible = false,
includePlugins,
omitDestinations,
}: MobileIntegrationsSectionProps) {
const { t } = useTranslation();
const destinations = useAppDestinations("mobileMenu", "integrations");
const destinations = useAppDestinations("mobileMenu", "integrations").filter(
(destination) =>
(includePlugins !== false || destination.source !== "plugin") &&
!omitDestinations?.includes(destination.id),
);
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
if (destinations.length === 0 && !showSetup) return null;
return (
<div className={showSetup ? "space-y-3 border-t border-border pt-4" : "space-y-3"}>
<div className={showSetup ? "space-y-2 border-t border-border pt-2" : "space-y-3"}>
{collapsible ? (
<Button
variant="ghost"
@@ -175,34 +183,68 @@ export function MobileIntegrationsSection({
<div className="text-sm font-medium">{t(INTEGRATIONS_LABEL)}</div>
)}
{(!collapsible || expanded) && (
<div id={bodyId} className="space-y-3">
<div
id={bodyId}
className={
collapsible
? "divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-muted/20"
: "space-y-3"
}
>
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="plugin-nav-item-"
showChevron={collapsible}
className={
collapsible
? "rounded-none border-0 bg-transparent px-3 shadow-none aria-[current=page]:bg-primary/10"
: undefined
}
/>
{showSetup && (
<Button
asChild
variant="outline"
className="cursor-pointer h-11 w-full justify-start gap-3 px-3 text-sm"
>
<Link
href={
workspaceId
? workspaceSettingsHref(workspaceId, "integrations")
: "/settings/workspaces"
}
onClick={onNavigate}
data-testid="mobile-integration-settings"
>
<IconPlugConnected className="size-4" />
{t("common:integrationSettings")}
</Link>
</Button>
<MobileIntegrationSettingsLink onNavigate={onNavigate} grouped={collapsible} />
)}
</div>
)}
</div>
);
}
function MobileIntegrationSettingsLink({
onNavigate,
grouped,
}: {
onNavigate: () => void;
grouped: boolean;
}) {
const { t } = useTranslation();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
return (
<Button
asChild
variant="outline"
className={cn(
"cursor-pointer h-11 w-full justify-start gap-3 px-3 text-sm",
grouped && "rounded-none border-0 bg-transparent shadow-none",
)}
>
<Link
href={
workspaceId ? workspaceSettingsHref(workspaceId, "integrations") : "/settings/workspaces"
}
onClick={onNavigate}
data-testid="mobile-integration-settings"
>
<IconPlugConnected className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{t("common:integrationSettings")}</span>
{grouped && (
<IconChevronRight
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
)}
</Link>
</Button>
);
}
@@ -44,7 +44,7 @@ describe("MainTopBarPluginActions", () => {
expect(screen.getByTestId("plugin-app-bar").textContent).toBe("null");
});
it("passes mobile presentation and applies the native icon-button contract", () => {
it("passes mobile presentation and preserves touch targets for labeled controls", () => {
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
const ctx = slotProps as MainTopBarSlotProps;
return (
@@ -59,7 +59,7 @@ describe("MainTopBarPluginActions", () => {
expect(screen.getByTestId("plugin-button").textContent).toBe("mobile");
expect(screen.getByTestId("mobile-main-top-bar-plugin-actions").className).toContain(
"[&_[data-slot=button]]:!size-8",
"[&_[data-slot=button]]:!min-h-11",
);
});
});
@@ -60,7 +60,7 @@ export function MainTopBarPluginActions(props: {
return (
<div
className="flex min-w-0 flex-wrap items-center gap-2 [&_[data-slot=button]]:!size-8 [&_[data-slot=button]]:!p-0 [&_[data-slot=button]_svg]:!size-4"
className="flex min-w-0 max-w-full flex-wrap items-center gap-2 [&>*]: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-slot=button]_svg]:!size-4"
data-testid="mobile-main-top-bar-plugin-actions"
>
{content}
@@ -111,7 +111,7 @@ export function MobileQuickActions({
requestAnimationFrame(action);
}
return (
<div className={inline ? "grid grid-cols-2 gap-3" : "flex flex-col gap-3"}>
<div className={inline ? "grid grid-cols-2 gap-2" : "flex flex-col gap-3"}>
<Button
variant="outline"
className="min-h-11 h-auto min-w-0 w-full cursor-pointer justify-start gap-2 whitespace-normal px-3 py-2 text-left text-sm"
@@ -17,7 +17,10 @@ import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-s
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { MobilePluginNavSection } from "@/components/plugins/mobile-plugin-nav-section";
import {
MobilePluginNavSection,
type MobilePluginWorkspaceContext,
} from "@/components/plugins/mobile-plugin-nav-section";
import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
import { useTheme } from "@/components/theme/app-theme";
import { getThemeToggleLabelKey, getThemeToggleTarget } from "@/components/theme/theme-toggle";
@@ -126,6 +129,8 @@ type AppNavSectionsProps = {
workspaceActions?: ReactNode;
/** Optional page-scoped controls rendered with plugin destinations. */
pluginActions?: ReactNode;
pluginWorkspaceContext?: MobilePluginWorkspaceContext;
resources?: ReactNode;
afterPrimary?: ReactNode;
quickActions?: ReactNode;
phoneNavigation?: boolean;
@@ -144,6 +149,8 @@ export function AppNavSections({
omitDestinations = [],
workspaceActions,
pluginActions,
pluginWorkspaceContext,
resources,
afterPrimary,
quickActions,
phoneNavigation = false,
@@ -161,6 +168,7 @@ export function AppNavSections({
omitDestinations={omitDestinations}
quickActions={quickActions}
homeCoversListings={phoneNavigation}
afterPrimary={afterPrimary}
/>
) : (
!omit.has("primary") && (
@@ -182,11 +190,12 @@ export function AppNavSections({
{t("sidebar:taskViews")}
</Button>
)}
{afterPrimary}
{!hasSavedSidebarLayout && afterPrimary}
{workspaceActions}
{!omit.has("plugins") && (
<MobilePluginNavSection
actions={pluginActions}
workspaceContext={pluginWorkspaceContext}
includeDestinations={!hasSavedSidebarLayout}
onNavigate={onNavigate}
/>
@@ -198,6 +207,7 @@ export function AppNavSections({
collapsible={phoneNavigation}
/>
)}
{resources}
<UtilityNavSection
onNavigate={onNavigate}
controls={controls}
@@ -222,12 +232,15 @@ function PrimaryNavSection({
const destinations = all.filter((destination) => !omitDestinations.includes(destination.id));
if (destinations.length === 0) return null;
return (
<div className="flex flex-col gap-3" data-testid="app-nav-primary">
<div
className={cn("flex flex-col", phoneNavigation ? "gap-2" : "gap-3")}
data-testid="app-nav-primary"
>
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
homeCoversListings={phoneNavigation}
className="gap-3 px-3 text-sm"
className="gap-3 px-3 text-sm aria-[current=page]:bg-primary/10"
/>
{quickActions}
</div>
@@ -108,6 +108,12 @@ vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({
}),
}));
vi.mock("@/components/system-metrics/status-surface-metrics", () => ({
StatusSurfaceMetrics: ({ drawerOpen }: { drawerOpen: boolean }) => (
<div role="region" aria-label="System metrics" data-open={drawerOpen} />
),
}));
vi.mock("@/hooks/use-system-health-indicator", () => ({
useSystemHealthIndicator: () => ({
hasIssues: healthHasIssues,
@@ -290,7 +296,36 @@ describe("AppNavSheet", () => {
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByTestId("mobile-workspace-action")).not.toBeNull();
expect(captured).toEqual({ workspaceId: "ws-1", presentation: "mobile" });
expect(
screen
.getByRole("region", { name: "Plugins" })
.contains(screen.getByTestId("mobile-workspace-action")),
).toBe(true);
expect(captured).toEqual({
workspaceId: "ws-1",
workspaceLabel: "Workspace",
presentation: "mobile",
});
});
});
describe("AppNavSheet metrics", () => {
beforeEach(resetAppNavMocks);
afterEach(cleanup);
it.each([false, true])("respects app status bar enabled = %s", (enabled) => {
state.userSettings.appStatusBarEnabled = enabled;
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.queryAllByRole("region", { name: "System metrics" })).toHaveLength(
enabled ? 0 : 1,
);
if (!enabled) {
expect(screen.getByRole("region", { name: "System metrics" }).getAttribute("data-open")).toBe(
"true",
);
}
});
});
@@ -306,7 +341,7 @@ describe("AppNavSheet plugin actions", () => {
);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
const section = screen.getByTestId("mobile-plugin-nav-section");
const section = screen.getByRole("region", { name: "Plugins" });
expect(section.contains(screen.getByTestId("session-plugin-action"))).toBe(true);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
});
@@ -7,10 +7,8 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNo
import { useTranslation } from "react-i18next";
import { MobileWorkspaceActionsSection } from "@/components/app-sidebar/app-sidebar-workspace-actions";
import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker";
import {
MobileListingMenuActions,
MobileQuickActions,
} from "@/components/kanban/mobile-listing-menu-actions";
import { MobileQuickActions } from "@/components/kanban/mobile-listing-menu-actions";
import { StatusSurfaceMetrics } from "@/components/system-metrics/status-surface-metrics";
import { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useInOffice } from "@/hooks/use-in-office";
@@ -68,7 +66,7 @@ export function AppNavSheet(props: AppNavSheetProps) {
trigger={<AppNavTrigger ref={opener} aria-expanded={open} />}
>
<nav
className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain p-4 pb-[max(1rem,env(safe-area-inset-bottom,0px))]"
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4 pb-[max(1rem,env(safe-area-inset-bottom,0px))] md:gap-6"
onClick={(event) => closeMenuOnLinkClick(event, close)}
>
{isMobile && <NavigationWorkspacePicker close={close} />}
@@ -100,22 +98,7 @@ export function AppNavSheet(props: AppNavSheetProps) {
}
workspaceActions={
<>
{isMobile && (
<MobileListingMenuActions
showQuickActions={false}
workspaceId={workspace?.id}
workspaceLabel={workspace?.name ?? ""}
currentPage={currentPage}
open={open}
closeMenu={(focus = false) => {
restoreFocus.current = focus;
close();
}}
isSearchOpen={false}
returnFocusRef={opener}
/>
)}
<MobileWorkspaceActionsSection />
<MobileWorkspaceActionsSection includePluginActions={!isMobile} />
<NavigationAutomations
isMobile={isMobile}
open={open}
@@ -126,6 +109,12 @@ export function AppNavSheet(props: AppNavSheetProps) {
</>
}
pluginActions={pluginActions}
pluginWorkspaceContext={
isMobile
? { workspaceId: workspace?.id, workspaceLabel: workspace?.name, currentPage }
: undefined
}
resources={isMobile && <NavigationMetrics open={open} />}
controls={isMobile ? { ...controls, openTaskViews: undefined } : controls}
/>
</nav>
@@ -135,6 +124,19 @@ export function AppNavSheet(props: AppNavSheetProps) {
);
}
function NavigationMetrics({ open }: { open: boolean }) {
const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled);
if (statusBarEnabled) return null;
return (
<StatusSurfaceMetrics
presentation="mobile-drawer"
density="compact"
drawerOpen={open}
iconSize="size-4"
/>
);
}
function useActiveNavigationWorkspace() {
return useAppStore((state) =>
state.workspaces.items.find((workspace) => workspace.id === state.workspaces.activeId),
@@ -149,7 +151,7 @@ function NavigationWorkspacePicker({ close }: { close: () => void }) {
const { t } = useTranslation();
return (
<section className="flex flex-col gap-2">
<h3 className="text-sm font-medium">{t("common:workspace")}</h3>
<h3 className="sr-only">{t("common:workspace")}</h3>
<AppSidebarWorkspacePicker
modal={false}
onActionComplete={close}
@@ -1,6 +1,7 @@
"use client";
import { Button } from "@kandev/ui/button";
import { IconChevronRight } from "@tabler/icons-react";
import Link from "@/components/routing/app-link";
import type { ResolvedDestination } from "@/lib/navigation/types";
import { usePathname } from "@/lib/routing/client-router";
@@ -20,6 +21,7 @@ type DestinationRowsProps = {
/** Extra classes so a surface can keep its own row spacing. */
className?: string;
homeCoversListings?: boolean;
showChevron?: boolean;
};
/**
@@ -34,6 +36,7 @@ export function DestinationRows({
pluginTestIdPrefix,
className,
homeCoversListings,
showChevron,
}: DestinationRowsProps) {
return (
<>
@@ -42,6 +45,7 @@ export function DestinationRows({
key={destination.id}
destination={destination}
homeCoversListings={homeCoversListings}
showChevron={showChevron}
onNavigate={onNavigate}
{...(pluginTestIdPrefix ? { pluginTestIdPrefix } : {})}
{...(className ? { className } : {})}
@@ -57,28 +61,40 @@ type DestinationRowProps = {
pluginTestIdPrefix?: string;
className?: string;
homeCoversListings?: boolean;
showChevron?: boolean;
};
function isDestinationCurrent(
destination: ResolvedDestination,
pathname: string,
homeCoversListings?: boolean,
) {
const hrefPath = destination.href.split("?")[0];
if (destination.id === "home") {
return (
pathname === "/" ||
(hrefPath === "/office" && pathname === hrefPath) ||
(homeCoversListings === true && ["/tasks", "/threads"].includes(pathname))
);
}
return (
(destination.id === "tasks" && /^\/(?:t|tasks)\/[^/]+/.test(pathname)) ||
pathname === hrefPath ||
(hrefPath !== "/" && pathname.startsWith(`${hrefPath}/`))
);
}
export function DestinationRow({
destination,
onNavigate,
pluginTestIdPrefix,
className,
homeCoversListings,
showChevron,
}: DestinationRowProps) {
const Icon = destination.icon;
const pathname = usePathname();
const hrefPath = destination.href.split("?")[0];
let current =
destination.id === "tasks" && /^\/(?:t|tasks)\/[^/]+/.test(pathname)
? true
: pathname === hrefPath || (hrefPath !== "/" && pathname.startsWith(`${hrefPath}/`));
if (destination.id === "home") {
current =
pathname === "/" ||
(hrefPath === "/office" && pathname === hrefPath) ||
(homeCoversListings === true && ["/tasks", "/threads"].includes(pathname));
}
const current = isDestinationCurrent(destination, pathname, homeCoversListings);
// Built from the raw `NavItem.id`, not the namespaced destination id — the
// `plugin-nav-item-<id>` / `mobile-plugin-nav-item-<id>` ids are public contract.
const testId =
@@ -104,6 +120,12 @@ export function DestinationRow({
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-left">{destination.label}</span>
{showChevron && (
<IconChevronRight
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
)}
</Link>
</Button>
);
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronRight, IconListDetails } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -13,15 +13,17 @@ import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs";
export function MobileAutomationsSection({
workspaceId,
onNavigate,
children,
}: {
workspaceId: string;
onNavigate: () => void;
children?: ReactNode;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
<section
className="flex flex-col gap-3 border-t border-border pt-4"
className="flex flex-col gap-2 border-t border-border pt-2"
data-testid="mobile-automations-section"
>
<div className="flex items-center gap-2">
@@ -53,9 +55,14 @@ export function MobileAutomationsSection({
</Link>
</Button>
</div>
{expanded && (
<MobileAutomationRows key={workspaceId} workspaceId={workspaceId} onNavigate={onNavigate} />
)}
{expanded &&
(children ?? (
<MobileAutomationRows
key={workspaceId}
workspaceId={workspaceId}
onNavigate={onNavigate}
/>
))}
</section>
);
}
@@ -0,0 +1,33 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MobileCanvasesSection } from "./mobile-canvases-section";
vi.mock("@/hooks/domains/features/use-feature", () => ({ useFeature: () => true }));
vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: vi.fn() }) }));
afterEach(cleanup);
it("keeps the disclosure target present and hidden until expanded", () => {
render(
<MobileCanvasesSection
workspaceId="workspace"
entries={[]}
loading={false}
error={null}
onRetry={() => {}}
onNavigate={() => {}}
/>,
);
const toggle = screen.getByRole("button", { name: "Canvases" });
const panelId = toggle.getAttribute("aria-controls");
expect(panelId).toBeTruthy();
const panel = document.getElementById(panelId!);
expect(panel).not.toBeNull();
expect(panel?.hidden).toBe(true);
expect(screen.queryByRole("link", { name: "Open canvas settings" })).toBeNull();
fireEvent.click(toggle);
expect(panel?.hidden).toBe(false);
expect(screen.getByRole("link", { name: "Open canvas settings" })).toBeTruthy();
fireEvent.click(toggle);
expect(document.getElementById(panelId!)).toBe(panel);
expect(panel?.hidden).toBe(true);
});
@@ -0,0 +1,88 @@
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronRight, IconLayoutGrid } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { workspaceCanvasSettingsHref } from "@/lib/api/domains/canvas-api";
import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
export function MobileCanvasesSection({
workspaceId,
entries,
loading,
error,
onRetry,
onNavigate,
}: {
workspaceId: string;
entries: ShortcutCatalogEntry[];
loading: boolean;
error: string | null;
onRetry: () => void;
onNavigate: () => void;
}) {
const { t } = useTranslation();
const enabled = useFeature("canvases");
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
if (!enabled) return null;
return (
<section className="space-y-2 border-t border-border pt-2">
<Button
variant="ghost"
className="h-11 w-full cursor-pointer justify-start gap-2 px-0 text-sm font-medium hover:bg-transparent aria-expanded:bg-transparent"
aria-expanded={expanded}
aria-controls={bodyId}
onClick={() => setExpanded(!expanded)}
>
{t("canvases:canvases")}
{expanded ? (
<IconChevronDown className="size-3.5 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 text-muted-foreground" />
)}
</Button>
<div id={bodyId} hidden={!expanded} className="space-y-2">
{loading && (
<p role="status" className="text-sm text-muted-foreground">
{t("common:loading")}
</p>
)}
{error && (
<div role="alert" className="space-y-2 text-sm">
<p>{t("common:requestFailed")}</p>
<Button variant="outline" className="h-11 cursor-pointer" onClick={onRetry}>
{t("canvases:retry")}
</Button>
</div>
)}
{!loading &&
!error &&
entries.map((entry) => (
<Button
key={entry.target.id}
asChild
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
>
<Link href={entry.href!} onClick={onNavigate}>
<IconLayoutGrid className="size-4 shrink-0" />
<span className="min-w-0 truncate">{entry.label}</span>
</Link>
</Button>
))}
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
>
<Link href={workspaceCanvasSettingsHref(workspaceId)} onClick={onNavigate}>
<IconLayoutGrid className="size-4 shrink-0" />
{t("canvases:openWorkspaceSettings")}
</Link>
</Button>
</div>
</section>
);
}
@@ -19,7 +19,11 @@ import type { ResolvedDestination } from "@/lib/navigation/types";
import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
import type { ShortcutActivityReader } from "@/hooks/domains/sidebar/use-shortcut-activity";
import { ShortcutSection } from "@/components/app-sidebar/shortcut-section";
import type { ShortcutActivation } from "@/components/app-sidebar/shortcut-section-actions";
import {
ShortcutRows,
type ShortcutActivation,
} from "@/components/app-sidebar/shortcut-section-actions";
import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs";
import {
selectNeedsYouInboxCount,
selectNeedsYouInboxHasMore,
@@ -27,12 +31,14 @@ import {
import { selectOfficeInboxCount } from "@/lib/state/slices/office/selectors";
import { NEEDS_YOU_INBOX_HREF } from "@/lib/navigation/needs-you-inbox-destination";
import { DestinationRows } from "./destination-rows";
const INTEGRATION_DESTINATION_IDS = new Set(["azure-devops", "github", "gitlab", "jira", "linear"]);
import { MobileAutomationsSection } from "./mobile-automations-section";
import { MobileCanvasesSection } from "./mobile-canvases-section";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
type MobileSidebarLayoutNavigationProps = {
quickActions?: ReactNode;
homeCoversListings?: boolean;
afterPrimary?: ReactNode;
onNavigate: () => void;
omitSections: Set<string>;
omitDestinations: string[];
@@ -44,12 +50,13 @@ type MobileLayoutNodeProps = {
node: ProjectedSidebarNode;
homeDestination?: ResolvedDestination;
destinationHrefs: Map<string, string | undefined>;
integrationEntries: ShortcutCatalogEntry[];
workspaceId?: string;
canvasEntries: ShortcutCatalogEntry[];
automationEntries: ShortcutCatalogEntry[];
catalog: ReturnType<typeof useSidebarLayoutNavigation>["catalog"];
omitSections: Set<string>;
omitDestinations: string[];
getActivity: ShortcutActivityReader;
refreshActivity: () => void;
onActivateShortcut: ShortcutActivation;
onNavigate: () => void;
};
@@ -165,13 +172,15 @@ function MobileRequiredRows({
(destination.id === "tasks" || destination.id === "threads") &&
!omitDestinations.includes(destination.id),
);
if (fixedDestinations.length === 0 && mode !== "office" && !(needsYouEnabled && workspaceId))
return null;
return (
<div className="flex flex-col gap-3" data-testid="mobile-sidebar-fixed-navigation">
{fixedDestinations.length > 0 && (
<DestinationRows
destinations={fixedDestinations}
onNavigate={onNavigate}
className="h-11 gap-3 px-3 text-sm"
className="h-11 gap-3 px-3 text-sm aria-[current=page]:bg-primary/10"
/>
)}
{mode === "office" && (
@@ -206,67 +215,88 @@ function MobileRequiredRows({
);
}
function MobileBuiltinNode({
function SavedMobileAutomationRows({
node,
quickActions,
homeCoversListings,
homeDestination,
integrationEntries,
canvasEntries,
automationEntries,
omitSections,
omitDestinations,
catalog,
workspaceId,
getActivity,
onActivateShortcut,
refreshActivity,
onNavigate,
}: MobileLayoutNodeProps) {
const { t } = useTranslation();
const entries = catalog.catalog.filter((entry) => entry.target.kind === "automation");
const activityError = catalog.automations.some((item) => getActivity(item.id)?.error);
return (
<div id="mobile-automations-body" className="space-y-2">
{catalog.loading && (
<p role="status" className="text-sm text-muted-foreground">
{t("common:loading")}
</p>
)}
{(catalog.error || activityError) && (
<div role="alert" className="space-y-2 text-sm">
<p>
{catalog.error
? t("automations:failedToLoadAutomations")
: t("automations:failedToLoadAutomationActivity")}
</p>
<Button
variant="outline"
className="h-11 cursor-pointer"
onClick={() => {
catalog.refresh();
refreshActivity();
}}
>
{t("automations:tryAgain")}
</Button>
</div>
)}
{!catalog.loading && !catalog.error && (
<ShortcutRows
shortcuts={resourceShortcuts(node, entries, []).shortcuts}
activity={getActivity}
mobile
onNavigate={onNavigate}
/>
)}
<Button asChild variant="outline" className="h-11 w-full cursor-pointer justify-start px-3">
<Link href={workspaceSettingsHref(workspaceId!, "automations")} onClick={onNavigate}>
{t("automations:setUpAnAutomation")}
</Link>
</Button>
</div>
);
}
function MobilePhoneResource(props: MobileLayoutNodeProps) {
const { node, workspaceId, canvasEntries, catalog, omitDestinations, onNavigate } = props;
switch (node.destinationId) {
case "home":
if (omitSections.has("primary") || omitDestinations.includes("home")) return null;
return homeDestination ? (
<>
<DestinationRows
destinations={[homeDestination]}
onNavigate={onNavigate}
homeCoversListings={homeCoversListings}
className="h-11 gap-3 px-3 text-sm"
/>
{quickActions}
</>
) : null;
case "new_task":
return omitDestinations.includes("new_task") ? null : (
<MobileNewTaskRow onNavigate={onNavigate} />
);
case "automations":
return (
<ShortcutSection
node={resourceShortcuts(node, automationEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
return workspaceId ? (
<MobileAutomationsSection workspaceId={workspaceId} onNavigate={onNavigate}>
<SavedMobileAutomationRows {...props} />
</MobileAutomationsSection>
) : null;
case "canvases":
return (
<ShortcutSection
node={resourceShortcuts(node, canvasEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
return workspaceId ? (
<MobileCanvasesSection
workspaceId={workspaceId}
entries={canvasEntries}
loading={catalog.canvasLoading}
error={catalog.canvasError}
onRetry={catalog.refresh}
onNavigate={onNavigate}
/>
);
) : null;
case "integrations":
if (omitSections.has("integrations")) return null;
return (
<ShortcutSection
node={resourceShortcuts(node, integrationEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
<MobileIntegrationsSection
onNavigate={onNavigate}
showSetup
collapsible
includePlugins={false}
omitDestinations={omitDestinations}
/>
);
default:
@@ -274,6 +304,60 @@ function MobileBuiltinNode({
}
}
function MobileBuiltinNode(props: MobileLayoutNodeProps) {
const {
node,
homeCoversListings,
homeDestination,
quickActions,
omitSections,
omitDestinations,
catalog,
getActivity,
onActivateShortcut,
onNavigate,
} = props;
if (node.destinationId === "home") {
if (omitSections.has("primary") || omitDestinations.includes("home")) return null;
return homeDestination ? (
<>
<DestinationRows
destinations={[homeDestination]}
onNavigate={onNavigate}
homeCoversListings={homeCoversListings}
className="h-11 gap-3 px-3 text-sm aria-[current=page]:bg-primary/10"
/>
{quickActions}
</>
) : null;
}
if (node.destinationId === "new_task")
return omitDestinations.includes("new_task") ? null : (
<MobileNewTaskRow onNavigate={onNavigate} />
);
if (node.destinationId === "integrations" && omitSections.has("integrations")) return null;
if (homeCoversListings) return <MobilePhoneResource {...props} />;
const entries = catalog.catalog.filter((entry) => {
if (node.destinationId === "automations") return entry.target.kind === "automation";
if (node.destinationId === "canvases") return entry.target.kind === "canvas";
return (
node.destinationId === "integrations" &&
entry.section === "integrations" &&
entry.source !== "plugin"
);
});
return (
<ShortcutSection
node={resourceShortcuts(node, entries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
}
function MobileLayoutNode(props: MobileLayoutNodeProps) {
const { node, omitSections, omitDestinations, destinationHrefs, onNavigate } = props;
if (node.kind === "plugin") {
@@ -303,6 +387,7 @@ function MobileLayoutNode(props: MobileLayoutNodeProps) {
export function MobileSidebarLayoutNavigation({
quickActions,
homeCoversListings,
afterPrimary,
onNavigate,
omitSections,
omitDestinations,
@@ -310,6 +395,7 @@ export function MobileSidebarLayoutNavigation({
const { workspaceId, catalog, projection, activity } = useSidebarLayoutNavigation({
active: true,
});
const workspaceMode = useOfficeModeState();
const openQuickChat = useQuickChatLauncher(workspaceId);
const openQuickTerminal = useQuickTerminalLauncher(workspaceId);
const primary = useStaticDestinations("mobileMenu", "primary");
@@ -332,50 +418,68 @@ export function MobileSidebarLayoutNavigation({
},
[onNavigate, openQuickChat, openQuickTerminal],
);
// The phone menu keeps the same destinations in Office and kanban modes.
// Office-specific required rows are added below, while user-selected
// visibility and order still come from the layout projection.
const visibleNodes = projection.nodes.filter((node) => node.visible);
// Phone workspace tools follow task navigation in their saved relative order.
// This presentation never writes over the desktop layout preference.
const visibleNodes = projection.nodes.filter(
(node) =>
node.visible &&
!(
homeCoversListings &&
workspaceMode === "kanban" &&
node.kind === "builtin" &&
node.destinationId === "new_task"
),
);
const hasVisibleHome =
!omitSections.has("primary") &&
!omitDestinations.includes("home") &&
visibleNodes.some((node) => node.destinationId === "home");
const homeDestination = primary.find((destination) => destination.id === "home");
const integrationEntries = catalog.catalog.filter(
(entry) =>
entry.target.kind === "destination" &&
entry.source !== "plugin" &&
INTEGRATION_DESTINATION_IDS.has(entry.target.id),
);
const canvasEntries = catalog.catalog.filter((entry) => entry.target.kind === "canvas");
const automationEntries = catalog.catalog.filter((entry) => entry.target.kind === "automation");
const beforeTasks = homeCoversListings
? visibleNodes.filter((node) => node.destinationId === "home")
: visibleNodes;
const afterTasks = homeCoversListings
? visibleNodes.filter((node) => node.destinationId !== "home")
: [];
const renderNode = (node: ProjectedSidebarNode) => (
<MobileLayoutNode
key={node.id}
node={node}
homeDestination={homeDestination}
homeCoversListings={homeCoversListings}
quickActions={quickActions}
destinationHrefs={destinationHrefs}
workspaceId={workspaceId}
canvasEntries={canvasEntries}
catalog={catalog}
omitSections={omitSections}
omitDestinations={omitDestinations}
getActivity={activity.getActivity}
refreshActivity={activity.refresh}
onActivateShortcut={activateShortcut}
onNavigate={onNavigate}
/>
);
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="mobile-sidebar-layout-navigation">
{!hasVisibleHome && quickActions}
{visibleNodes.map((node) => (
<MobileLayoutNode
key={node.id}
node={node}
homeDestination={homeDestination}
homeCoversListings={homeCoversListings}
quickActions={quickActions}
destinationHrefs={destinationHrefs}
integrationEntries={integrationEntries}
canvasEntries={canvasEntries}
automationEntries={automationEntries}
<div
className="flex min-w-0 flex-col gap-4 md:gap-6"
data-testid="mobile-sidebar-layout-navigation"
>
<div className="flex min-w-0 flex-col gap-2 md:gap-3">
{!hasVisibleHome && quickActions}
{beforeTasks.map(renderNode)}
<MobileRequiredRows
onNavigate={onNavigate}
omitSections={omitSections}
omitDestinations={omitDestinations}
getActivity={activity.getActivity}
onActivateShortcut={activateShortcut}
onNavigate={onNavigate}
/>
))}
<MobileRequiredRows
onNavigate={onNavigate}
omitSections={omitSections}
omitDestinations={omitDestinations}
/>
</div>
{afterPrimary}
{afterTasks.length > 0 && (
<div className="flex min-w-0 flex-col gap-3">{afterTasks.map(renderNode)}</div>
)}
</div>
);
}
@@ -2,7 +2,10 @@ import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { MobilePluginNavSection } from "./mobile-plugin-nav-section";
import {
MobilePluginNavSection,
type MobilePluginWorkspaceContext,
} from "./mobile-plugin-nav-section";
const HELLO_PATH = "/plugins/hello";
const HELLO_ITEM_TEST_ID = "mobile-plugin-nav-item-hello";
@@ -11,11 +14,17 @@ vi.mock("@/lib/routing/client-router", () => ({
usePathname: () => "/",
}));
function renderSection(onNavigate = () => {}, actions?: ReactNode, includeDestinations = true) {
function renderSection(
onNavigate = () => {},
actions?: ReactNode,
includeDestinations = true,
workspaceContext?: MobilePluginWorkspaceContext,
) {
return render(
<MobilePluginNavSection
actions={actions}
includeDestinations={includeDestinations}
workspaceContext={workspaceContext}
onNavigate={onNavigate}
/>,
);
@@ -27,6 +36,68 @@ afterEach(() => {
window.history.pushState({}, "", "/");
});
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("");
});
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"]) {
pluginRegistry.forPlugin("plugin-a").registerComponent(name, ({ slotProps }) => {
received.push(slotProps);
return <button data-testid={name}>{name}</button>;
});
}
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
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"]) {
expect(section.contains(screen.getByRole("button", { name: label }))).toBe(true);
}
expect(received).toContainEqual({ ...workspaceContext, presentation: "mobile" });
expect(received).toContainEqual({
workspaceId: "ws-1",
workspaceLabel: "Demo",
presentation: "mobile",
});
expect(screen.getByRole("heading", { name: "Workspace" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Task" })).toBeTruthy();
expect(screen.queryByTestId(HELLO_ITEM_TEST_ID)).toBeNull();
expect(screen.getAllByText("Plugins")).toHaveLength(1);
});
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>);
const { rerender, container } = renderSection(undefined, undefined, true, workspaceContext);
expect(screen.getByRole("button", { name: "Workspace control" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "Task" })).toBeNull();
pluginRegistry.unregisterPlugin("plugin-a");
rerender(<MobilePluginNavSection onNavigate={() => {}} workspaceContext={workspaceContext} />);
expect(container.innerHTML).toBe("");
});
it("omits sidebar actions when there is no active workspace", () => {
pluginRegistry
.forPlugin("plugin-a")
.registerComponent("sidebar-workspace-actions", () => <button>Workspace control</button>);
const { container } = renderSection(undefined, undefined, true, { currentPage: "kanban" });
expect(container.innerHTML).toBe("");
});
});
describe("MobilePluginNavSection actions", () => {
it("renders nothing when no plugin has registered a nav item", () => {
const { container } = renderSection();
@@ -2,10 +2,20 @@
import 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";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { resolveDestinations } from "@/lib/navigation/resolve-destinations";
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";
export type MobilePluginWorkspaceContext = {
workspaceId?: string;
workspaceLabel?: string;
currentPage: TaskListingPage;
};
type MobilePluginNavSectionProps = {
/** Closes the surrounding menu sheet once a plugin page is opened. */
@@ -14,6 +24,8 @@ type MobilePluginNavSectionProps = {
actions?: ReactNode;
/** Custom sidebar layouts already place plugin destinations themselves. */
includeDestinations?: boolean;
/** Phone app navigation groups workspace slots with page contributions. */
workspaceContext?: MobilePluginWorkspaceContext;
};
/**
@@ -28,9 +40,15 @@ export function MobilePluginNavSection({
onNavigate,
actions,
includeDestinations = true,
workspaceContext,
}: MobilePluginNavSectionProps) {
const { t } = useTranslation();
const registry = usePluginRegistry();
const { hasMainActions, hasSidebarActions } = workspaceSlotAvailability(
registry,
workspaceContext,
);
const hasWorkspaceActions = hasMainActions || hasSidebarActions;
// Resolved directly rather than through `useStaticDestinations`: this group's
// hrefs are static plugin paths, so it needs neither workspace context nor the
// availability subscription. Matches the desktop rail in `plugin-nav-items.tsx`.
@@ -43,12 +61,33 @@ export function MobilePluginNavSection({
})
: [];
if (!actions && destinations.length === 0) return null;
if (!actions && !hasWorkspaceActions && destinations.length === 0) return null;
return (
<div className="space-y-3" data-testid="mobile-plugin-nav-section">
<div className="text-sm font-medium">{t("common:plugins")}</div>
{actions}
<section
className={cn("space-y-3", workspaceContext && "border-t border-border pt-4")}
data-testid="mobile-plugin-nav-section"
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>
)}
{actions}
</div>
)}
{destinations.length > 0 && (
<DestinationRows
destinations={destinations}
@@ -56,6 +95,41 @@ export function MobilePluginNavSection({
pluginTestIdPrefix="mobile-plugin-nav-item-"
/>
)}
</section>
);
}
function WorkspacePluginControls({
context,
hasMainActions,
hasSidebarActions,
}: {
context: MobilePluginWorkspaceContext;
hasMainActions: boolean;
hasSidebarActions: boolean;
}) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasMainActions && <MainTopBarPluginActions {...context} presentation="mobile" />}
{hasSidebarActions && context.workspaceId && (
<AppSidebarWorkspaceActions
workspaceId={context.workspaceId}
workspaceLabel={context.workspaceLabel}
presentation="mobile"
/>
)}
</div>
);
}
function workspaceSlotAvailability(
registry: ReturnType<typeof usePluginRegistry>,
context?: MobilePluginWorkspaceContext,
) {
return {
hasMainActions: !!context && registry.getSlotRegistrations("main-top-bar").length > 0,
hasSidebarActions:
!!context?.workspaceId &&
registry.getSlotRegistrations("sidebar-workspace-actions").length > 0,
};
}
@@ -211,6 +211,6 @@ test("collapsed sections do not leave flexible space before Utilities", async ({
);
}),
)
.toBe(24);
.toBe(16);
}
});
@@ -78,9 +78,30 @@ test.describe("Mobile plugin menu actions", () => {
const plugin = menu.locator("#hello-main-top-bar");
const metrics = menu.getByTestId("app-status-metrics");
await expect(plugin).toHaveAccessibleName(`Hello ${pluginPage}`);
expect(
await plugin.evaluate((element) =>
Boolean(element.closest('[data-testid="mobile-plugin-nav-section"]')),
),
"Workspace controls belong to the labeled Plugins section",
).toBe(true);
expect(
await menu
.getByTestId("e2e-sidebar-workspace-actions")
.evaluate((element) =>
Boolean(element.closest('[data-testid="mobile-plugin-nav-section"]')),
),
"Sidebar plugin controls belong to the same Plugins section",
).toBe(true);
await expect(metrics).toBeVisible();
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await waitForFiniteAnimations(menu);
const pluginSection = menu.getByTestId("mobile-plugin-nav-section");
const pluginBox = await requireBox(pluginSection, "Plugins section");
const metricsBox = await requireBox(metrics, "system metrics");
expect(metricsBox.y).toBeGreaterThanOrEqual(pluginBox.y + pluginBox.height);
await expect(pluginSection.getByRole("heading", { name: "Task", exact: true })).toHaveCount(
0,
);
for (const target of [
plugin,
menu.getByTestId("mobile-quick-chat-button"),
@@ -119,9 +140,6 @@ test.describe("Mobile plugin menu actions", () => {
}) => {
test.setTimeout(120_000);
await installFixture(testPage);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
app_status_bar_enabled: true,
});
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Move plug-in controls into the mobile menu without crowding",
@@ -151,22 +169,6 @@ test.describe("Mobile plugin menu actions", () => {
expect(triggerBox.width).toBeGreaterThanOrEqual(44);
expect(triggerBox.height).toBeGreaterThanOrEqual(44);
await assertNoDocumentHorizontalOverflow(testPage, "task header with plugin contributions");
if (prCapture.capturing) {
const originalViewport = testPage.viewportSize();
if (!originalViewport) throw new Error("Mobile test page has no viewport");
const captureStyle = await testPage.addStyleTag({
content:
'[data-testid="mobile-task-layout"] > :not(:first-child) { visibility: hidden !important; }',
});
await testPage.setViewportSize({ width: originalViewport.width, height: 64 });
await prCapture.screenshot("mobile-task-plugin-header", {
caption:
"Long task title remains readable while session plug-in controls stay out of the fixed header",
});
await captureStyle.evaluate((element) => element.remove());
await testPage.setViewportSize(originalViewport);
}
await menuTrigger.tap();
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const pluginSection = menu.getByTestId("mobile-plugin-nav-section");
@@ -176,6 +178,19 @@ 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);
const workspaceAction = pluginSection.getByTestId("e2e-sidebar-workspace-actions");
await expect(workspaceAction).toHaveCount(1);
await workspaceAction.tap();
await expect(workspaceAction).toHaveAttribute("data-clicked", "true");
await expect(menu.locator("nav.overflow-y-auto")).toHaveCount(1);
const metrics = menu.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
const metricsBox = await requireBox(metrics, "system metrics");
const pluginBox = await requireBox(pluginSection, "Plugins section");
expect(metricsBox.y).toBeGreaterThanOrEqual(pluginBox.y + pluginBox.height);
await expect(status).toHaveAttribute("data-task-id", task.id);
await expect(status).toHaveAttribute("data-workspace-id", seedData.workspaceId);
await expect(status).toHaveAttribute("data-active-session-id", task.session_id);
@@ -195,13 +210,56 @@ test.describe("Mobile plugin menu actions", () => {
await expect(action).toHaveAttribute("data-activated", "true");
await expect(menu).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "task plugin menu");
await prCapture.screenshot("mobile-task-plugin-menu", {
caption:
"Session plug-in status and action share the mobile Plugins section with touch-sized controls",
});
for (const width of [393, 320, 767]) {
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"),
]) {
const box = await requireBox(control, "plugin control");
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.x).toBeGreaterThanOrEqual(sectionBox.x);
expect(box.x + box.width).toBeLessThanOrEqual(sectionBox.x + sectionBox.width);
}
await assertNoDocumentHorizontalOverflow(testPage, `plugin menu at ${width}px`);
if (width === 393) {
for (const colorScheme of ["dark", "light"] as const) {
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})`,
});
}
}
}
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(menuTrigger).toBeFocused();
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
app_status_bar_enabled: true,
});
await testPage.reload();
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);
if (prCapture.capturing) {
await testPage.setViewportSize({ width: 393, height: 851 });
await testPage.goto("/?home=overview");
await testPage.getByTestId("app-nav-trigger").tap();
const listingPlugins = menu.getByTestId("mobile-plugin-nav-section");
await expect(listingPlugins).toBeVisible();
await listingPlugins.evaluate((element) => element.scrollIntoView({ block: "start" }));
await waitForFiniteAnimations(menu);
await prCapture.screenshot("mobile-listing-plugin-menu", {
caption:
"Listing menu with a single plugin group; resources are available through Status when enabled",
});
}
});
});
@@ -1,6 +1,28 @@
import { expect, test } from "../../fixtures/test-base";
import type { Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import { installFixturePlugin, uninstallFixturePlugin } from "../../helpers/plugin-fixture";
let previousLayout: unknown;
test.beforeEach(async ({ testPage, apiClient, seedData }) => {
void testPage;
previousLayout = (await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
seedData.workspaceId
];
});
test.afterEach(async ({ apiClient, seedData }) => {
await uninstallFixturePlugin(apiClient);
const current = (await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
seedData.workspaceId
];
await apiClient.saveUserSettings({
sidebar_layout_state: {
workspace_id: seedData.workspaceId,
expected_revision: current?.revision ?? 0,
layout: previousLayout ?? null,
},
});
});
type MobileSidebarLayoutNode = {
id: string;
@@ -58,6 +80,95 @@ async function addMobileShortcut(page: Page, label: string): Promise<void> {
}
test.describe("Sidebar customization on phone", () => {
// @covers AC-UI-SIDEBAR-CUSTOMIZATION-005.5 AC-UI-MOBILE-MENU-007.4
test("first visibility edit keeps populated Tasks before workspace tools", async ({
testPage,
apiClient,
seedData,
}) => {
await installFixturePlugin(testPage);
for (const title of [
"Review checkout accessibility",
"Add order tracking",
"Polish receipt emails",
]) {
await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
}
await testPage.goto("/settings/preferences/layouts?tab=sidebar");
const visibility = testPage.getByTestId("sidebar-layout-node-canvases").getByRole("switch");
await expect(visibility).toBeChecked();
await visibility.tap();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.tap();
await expect(testPage.getByTestId("settings-floating-save")).toBeHidden();
await testPage.reload();
await expect(visibility).not.toBeChecked();
const saved = (await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
seedData.workspaceId
];
await testPage.goto("/tasks");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
const tasks = menu.getByTestId("mobile-navigation-tasks-toggle");
const automations = menu.getByRole("button", { name: "Automations", exact: true });
const plugins = menu.getByRole("region", { name: "Plugins", exact: true });
await expect(plugins).toHaveCount(1);
await expect(menu.locator("#hello-main-top-bar")).toHaveCount(1);
await expect(plugins.locator("#hello-main-top-bar")).toBeVisible();
const workspaceAction = plugins.getByTestId("e2e-sidebar-workspace-actions");
await expect(workspaceAction).toHaveCount(1);
await expect(menu.getByRole("link", { name: "Hello E2E", exact: true })).toHaveCount(1);
await expect(menu.getByRole("button", { name: "Canvases", exact: true })).toHaveCount(0);
await expect(menu.locator("[data-task-row-id]")).toHaveCount(3);
for (const width of [360, 393, 767]) {
await testPage.setViewportSize({ width, height: 851 });
expect((await tasks.boundingBox())!.y).toBeLessThan((await automations.boundingBox())!.y);
expect((await automations.boundingBox())!.y).toBeLessThan((await plugins.boundingBox())!.y);
expect(await menu.locator("nav").evaluate((el) => getComputedStyle(el).overflowY)).toBe(
"auto",
);
expect(
await menu
.getByTestId("mobile-task-switcher-list")
.evaluate((el) => getComputedStyle(el).overflowY),
).toBe("visible");
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
).toBe(true);
const box = (await menu.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
expect(box.height).toBeLessThanOrEqual(851);
}
await workspaceAction.tap();
await expect(workspaceAction).toHaveAttribute("data-clicked", "true");
await expect(menu).toBeVisible();
await automations.tap();
await expect(menu.getByRole("link", { name: "Set up an automation" })).toBeVisible();
await menu.getByRole("button", { name: "Integrations", exact: true }).tap();
const setup = menu.getByTestId("mobile-integration-settings");
await setup.scrollIntoViewIfNeeded();
const control = (await setup.boundingBox())!;
const surface = (await menu.boundingBox())!;
expect(control.height).toBeGreaterThanOrEqual(44);
expect(control.x).toBeGreaterThanOrEqual(surface.x);
expect(control.x + control.width).toBeLessThanOrEqual(surface.x + surface.width);
await setup.tap();
await expect(testPage).toHaveURL(
new RegExp(`/settings/workspaces/${seedData.workspaceId}/integrations`),
);
expect(
(await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
seedData.workspaceId
],
).toEqual(saved);
});
test("uses the focused editor flow and preserves touch-sized reorder controls", async ({
testPage,
apiClient,
@@ -116,6 +227,11 @@ for (const homeVisible of [true, false]) {
seedData,
}) => {
await seedMobileLayout(apiClient, seedData.workspaceId, homeVisible);
await apiClient.mockGitHubSetUser("navigation-demo");
const task = await apiClient.seedTask(seedData.workspaceId, "Polish checkout summary", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto("/tasks");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
@@ -136,12 +252,69 @@ for (const homeVisible of [true, false]) {
expect(order.indexOf("mobile-quick-chat-button")).toBe(order.indexOf("Home") + 1);
}
await expect(menu.getByRole("link", { name: /^(Tasks|Threads)$/ })).toHaveCount(0);
await expect(menu.getByTestId("mobile-navigation-tasks-toggle")).toBeVisible();
await expect(menu.getByTestId("mobile-automations-section")).toHaveCount(0);
const integrations = layout.getByTestId("mobile-shortcut-section-toggle-integrations");
await expect(layout.getByRole("button", { name: "New Task", exact: true })).toHaveCount(0);
const tasks = menu.getByTestId("mobile-navigation-tasks-toggle");
await expect(tasks).toBeVisible();
const automations = menu.getByRole("button", { name: "Automations", exact: true });
// @covers AC-UI-MOBILE-MENU-007.2 AC-UI-SIDEBAR-CUSTOMIZATION-005.5
expect((await tasks.boundingBox())!.y).toBeLessThan((await automations.boundingBox())!.y);
const integrations = layout.getByRole("button", { name: "Integrations", exact: true });
const github = layout.getByRole("link", { name: "GitHub", exact: true });
await expect(integrations).toHaveAttribute("aria-expanded", "false");
await expect(github).toBeHidden();
await integrations.tap();
await expect(integrations).toHaveAttribute("aria-expanded", "true");
await expect(github).toHaveText("GitHub");
await expect(menu.getByTestId("mobile-integration-settings")).toBeVisible();
await expect(menu.getByTestId("mobile-customize-sidebar-button")).toBeVisible();
await menu.getByText("Polish checkout summary", { exact: true }).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`));
});
}
// @covers AC-UI-SIDEBAR-CUSTOMIZATION-005.5
test("saved canvas disclosure recovers a failed read and retains settings", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const release = await backend.useEnv({ KANDEV_FEATURES_CANVASES: "true" });
try {
await seedMobileLayout(apiClient, seedData.workspaceId);
let unavailable = true;
await testPage.route("**/api/v1/workspaces/*/canvases", (route) =>
unavailable
? route.fulfill({ status: 503, json: { error: "unavailable" } })
: route.continue(),
);
await testPage.goto("/tasks");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
const toggle = menu.getByRole("button", { name: "Canvases", exact: true });
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await toggle.tap();
await expect(menu.getByRole("alert")).toContainText("Request failed");
await expect(menu.getByRole("link", { name: "Open canvas settings" })).toBeVisible();
unavailable = false;
await menu.getByRole("button", { name: "Try again", exact: true }).tap();
await expect(menu.getByRole("alert")).toHaveCount(0);
await menu.getByRole("link", { name: "Open canvas settings" }).tap();
await expect(testPage).toHaveURL(
new RegExp(`/settings/workspaces/${seedData.workspaceId}/canvases`),
);
await testPage.goto("/settings/preferences/layouts?tab=sidebar");
await testPage.getByTestId("sidebar-layout-node-canvases").getByRole("switch").tap();
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.tap();
await expect(testPage.getByTestId("settings-floating-save")).toBeHidden();
await testPage.goto("/tasks");
await testPage.getByTestId("app-nav-trigger").tap();
await expect(menu.getByRole("button", { name: "Canvases", exact: true })).toHaveCount(0);
await expect(menu.getByRole("link", { name: "Open canvas settings" })).toHaveCount(0);
} finally {
await release();
}
});
@@ -104,6 +104,8 @@ test.describe("Mobile task autopilot", () => {
);
const childTask = await apiClient.getTask(child.id);
expect(childTask.autopilot).toBe(true);
const childSessionId = child.session_id;
if (!childSessionId) throw new Error("mobile autopilot child did not return a session ID");
await testPage.goto(`/t/${parent.id}`);
const parentSession = new SessionPage(testPage);
@@ -133,12 +135,14 @@ test.describe("Mobile task autopilot", () => {
await assertNoDocumentHorizontalOverflow(testPage, "mobile autopilot task switcher");
await waitForParentQuestion(apiClient, parent.id);
// The answer turn can finish before polling observes RUNNING. Its durable
// turn remains observable after the child returns to WAITING_FOR_INPUT.
await expect
.poll(async () => (await apiClient.listTaskSessions(child.id)).sessions[0]?.state ?? "", {
.poll(async () => (await apiClient.listSessionTurns(childSessionId)).turns.length, {
timeout: 60_000,
message: "the parent answer should resume the mobile child",
message: "the parent answer should create a mobile child turn",
})
.not.toBe("WAITING_FOR_INPUT");
.toBeGreaterThanOrEqual(2);
await testPage.goto(`/t/${child.id}`);
const childSession = new SessionPage(testPage);
@@ -0,0 +1,125 @@
---
created: 2026-09-23
status: implemented
requirements:
- REQ-UI-MOBILE-MENU-007
- REQ-UI-MOBILE-TASK-CHROME-001
system_design:
- ../../specs/ui/system-design/unified-mobile-navigation.md
- ../../specs/ui/system-design/mobile-task-chrome.md
legacy_specs: []
---
# Implementation Plan: Coherent Mobile Plugin Menu
## Overview
Group the existing phone plugin controls in one place. The current menu renders
workspace widgets, system metrics, Automations, and then more plugin widgets.
Preserve every slot's context and actions while removing that fragmented hierarchy.
The user requested autonomous delivery through a PR with screenshots.
## Scope
Phone app-menu composition, plugin containment, focused regressions, and PR
screenshots. Desktop/tablet layout, plugin APIs, resource preferences, and
plugin implementations remain outside this change.
## Technical approach
`AppNavSheet` supplies the phone workspace context to `MobilePluginNavSection`
through `AppNavSections`. That section composes main-top-bar and sidebar workspace
actions with task actions and existing plugin destinations. When workspace and
task contributions coexist, use localized context labels to explain their scope.
Gate empty groups on registrations, including saved sidebar layouts that already
own plugin destinations. Keep canvases in the workspace section. Move fallback
`StatusSurfaceMetrics` after Integrations and before Utilities, preserving its
existing preference and subscription behavior. Normalize phone host buttons to
44px without forcing square dimensions onto labeled controls.
## ASCII UI preview
### UI-01: Phone app navigation, plugins installed
```text
Before: Tasks > | workspace widgets | Metrics | Automations > | Plugins
After:
Menu x (fixed)
Workspace picker
Home
[Quick Chat] [Quick terminal]
Tasks > +
Automations >
Plugins
Workspace
[workspace actions and status, wrapping]
Task
[task actions and status, wrapping]
[plugin destinations]
Integrations >
[System metrics: CPU / Memory / Disk]
Utilities
```
One safe-area-aware internal scroller; no additional surface. Context labels
appear only when both contexts exist. With no plugin actions or destinations,
the entire Plugins section is absent. Metrics remain opt-in and are only in
this menu when the app status bar is disabled. Spacing is illustrative.
UI-01D desktop/tablet: existing sidebar, inline plugin toolbar controls, and
resource/status surfaces retain their composition. The phone inset Drawer is
structurally absent there.
## Tests
- `app-nav-sheet.test.tsx`: all phone contributions share one section, context
remains correct, and empty sections disappear.
- Existing plugin-section and main-top-bar unit tests preserve destinations,
desktop context, and mobile presentation.
## E2E tests
`mobile-plugin-topbar.spec.ts` covers the real packaged plugin on listing and
task routes, grouped placement, task/workspace context, actions, 44px targets,
resource order, one scroller, containment, dismissal, and screenshots.
`mobile-resource-metrics-display.spec.ts` preserves preferences and the
393/320/767/768px boundary. `mobile-menu-hierarchy.spec.ts` retains quick-action,
disclosure, navigation, and heading-alignment checks after the section-gap change. These map to AC-UI-MOBILE-MENU-007.2/.4 and
AC-UI-MOBILE-TASK-CHROME-001.6/.7.
## Work orders
- [x] [Task 01: Group phone plugin controls](task-01-group-phone-plugins.md)
## Verification results
- RED: navigation unit and managed mobile browser assertions reproduced workspace
contributions outside Plugins before production changes.
- GREEN: 44 focused Vitest tests passed; web typecheck, targeted ESLint (zero
warnings), Prettier, and the i18n ratchet passed.
- Final rendered checks: 15 mobile-chrome scenarios passed across plugin menus,
resource preferences, and menu hierarchy after a fresh `pnpm run build:e2e`.
The final managed run used `--host --no-build` with that build and the already
rebuilt backend/plugin package. Phone coverage includes 320/393/767px and the
existing 768px desktop boundary, both resource locations, Portuguese labels,
quick actions, plugin activation, and focus return.
- Four fresh screenshots were captured with isolated synthetic data. Dark/light
task views, the listing menu, and the no-plugin resource view are in the
ignored PR-asset manifest. The phone drawer is absent on desktop; no unrelated
desktop screenshot is required.
- Public-doc validation: 62 tests and 47 pages passed. Specification catalog and
lint passed (300 decisions, 1,131 specifications); diff checks passed.
- PR review added direct coverage of the status-bar preference and semantic
Plugins-region queries. CI exposed a mobile autopilot assertion that polled
a transient state; it now checks the durable resumed turn, matching desktop.
The affected tests and nearby CI ordering passed with one worker, retries
disabled, and CPU affinity restricted to two cores. An intermittent
history-recovery failure remains open; its evidence and an unsuccessful
fixture experiment are handed to PR #3890 for combined verification.
## Risks
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.
@@ -0,0 +1,147 @@
---
id: "01-group-phone-plugins"
title: "Group phone plugin controls"
status: done
wave: 1
depends_on: []
plan: plan.md
requirements:
- REQ-UI-MOBILE-MENU-007
- REQ-UI-MOBILE-TASK-CHROME-001
acceptance_criteria:
- AC-UI-MOBILE-MENU-007.2
- AC-UI-MOBILE-MENU-007.4
- AC-UI-MOBILE-TASK-CHROME-001.6
- AC-UI-MOBILE-TASK-CHROME-001.7
system_design:
- ../../specs/ui/system-design/unified-mobile-navigation.md
- ../../specs/ui/system-design/mobile-task-chrome.md
---
# Task 01: Group Phone Plugin Controls
## Summary
Compose workspace and task plugin widgets inside the same phone Plugins section,
then place fallback metrics after navigation. Preserve plugin context and all
existing controls; publish synthetic mobile screenshots with the PR.
## In scope
Phone composition, empty-state gating, touch sizing, unit/E2E regressions,
scoped guidance, public navigation copy, and isolated PR capture.
## Out of scope
Plugin SDK/backend changes, plugin deduplication, new settings, and desktop redesign.
## Acceptance
1. Main-toolbar, sidebar workspace, and task contributions appear under one
Plugins heading. Context labels distinguish workspace and task groups when
both exist. Empty sections disappear; saved-layout links are not duplicated.
2. Resources no longer interrupt plugin controls. Existing preference gates,
context, actions, focus return, and the sole menu scroller remain intact.
3. Touch controls are contained and at least 44px; focused tests pass, and
fresh isolated phone screenshots are inspected before PR publication.
## ASCII UI preview
UI-01 excerpt from [the plan](plan.md#ui-01-phone-app-navigation-plugins-installed):
```text
Tasks > / Automations >
Plugins
Workspace: [controls]
Task: [controls]
[destinations]
Integrations >
System metrics
Utilities
```
The menu keeps its fixed header and single internal scroller. UI-01D keeps
desktop/tablet controls in their existing sidebar and inline toolbars.
Maps to AC-UI-MOBILE-MENU-007.2/.4 and AC-UI-MOBILE-TASK-CHROME-001.6/.7.
## Files likely touched
- `apps/web/components/navigation/app-nav-{sheet,sections}.tsx` and existing tests.
- `apps/web/components/plugins/mobile-plugin-nav-section.tsx` and existing tests.
- `apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx`.
- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx`.
- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`.
- Scoped web guidance and existing navigation/plugin public documentation.
## Verification
```bash
(cd apps/web && pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/plugins/mobile-plugin-nav-section.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && CAPTURE_PR_ASSETS=1 pnpm e2e:run --host --project mobile-chrome tests/plugins/mobile-plugin-topbar.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts)
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 targeted ESLint/Prettier on changed frontend files as well.
## Dependencies
None. Existing plugin-menu and unified-navigation packages are implemented.
## Parallelism
Sequential, in the primary session.
## Inputs
- Unified mobile navigation requirements/design, action-first composition.
- Mobile task chrome requirements/design, plugin presentation/context.
- Existing packaged E2E plugin and mobile plugin/resource scenarios.
## Risks
Opaque plugin markup may be wider than the menu. Preserve existing mobile
containment and test host buttons and raw sidebar controls separately.
## Results
- RED: the unit grouping assertion failed on `closest(...) === null`, and the
browser assertion returned false for workspace controls inside Plugins.
- Focused Vitest command: 44 tests passed. Typecheck and changed-file
ESLint/Prettier passed without warnings; the i18n ratchet passed.
- Final browser command: `pnpm run build:e2e`, followed by the listed managed
capture command with `--no-build`, passed all 15 scenarios. Backend and fixture
packages were freshly built in the preceding managed runs. The existing
hierarchy assertion was updated from the old 24px gap to the intended 16px.
- Four PNGs captured in the ignored PR-asset manifest using isolated E2E data;
dark/light task views were visually checked against UI-01. Listing-only and
no-plugin states retain resources and navigation without an empty plugin group.
- Catalog/specification lint passed. Public-doc validators passed 62 tests and
47 pages. `git diff --check` passed. Desktop/tablet composition is covered by
retained slot tests and the resource suite's 768px boundary; this phone-only
surface does not require an unrelated desktop screenshot.
### PR CI and review follow-up
- Added direct AppNavSheet coverage of both status-bar preference branches and
selected the Plugins region by its accessible name in grouping assertions.
- The mobile autopilot CI failure reproduced locally with retries disabled.
Its resumed child can finish before a state poll sees RUNNING; assert the
durable second turn, as the desktop test already does.
- The history-recovery flake did not reproduce in six fresh two-core runs or
after its four preceding CI specs. An experiment that settled the initial
turn and avoided the reload-capable entry helper still failed on repetition
two; it was not retained. The cause remains unresolved. Failure artifacts
and the experimental patch are handed to the owner of PR #3890, which will
consolidate this work and own combined verification and new screenshots.
- Reproduction used `taskset -c 0,1 pnpm e2e:run --host --no-build --project
mobile-chrome` with `--retries=0 --max-failures=1`. Six isolated history runs
passed, followed by all ten tests across display settings, automation
webhooks, PR link copying, port forwarding, history recovery, and autopilot.
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.
+109
View File
@@ -0,0 +1,109 @@
---
created: 2026-09-23
status: done
requirements:
- REQ-UI-MOBILE-MENU-007
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
system_design:
- ../../specs/ui/system-design/sidebar-customization.md
- ../../specs/ui/system-design/unified-mobile-navigation.md
legacy_specs: []
---
# Mobile saved navigation
## Overview
Restore the phone menu hierarchy after the first sidebar customization. The user
requested unattended implementation through a PR with seeded screenshots, so this
package records the work within the authorized delivery session.
## Root cause and scope
`useHasSavedSidebarLayout` switches rendering at revision greater than zero.
`AppNavSections` inserts the entire saved layout before `afterPrimary`, moving
workspace tools ahead of Tasks. Built-in sections also reuse `ShortcutSection`'s
icon strip instead of the phone disclosures and omit setup/recovery paths.
Keep the shared drawer, saved layout data, domain availability, task controller,
desktop composition, and custom shortcut actions. Correct phone ordering, reuse
native built-in disclosures, and preserve labelled setup and navigation paths.
No backend, new preference, credential, or task-lifecycle changes.
## Mobile design contract
The hamburger opens the shipped `AppNavSheet` inset drawer for a temporary
navigation choice. Home/quick actions lead into the existing task list; optional
workspace tools follow. Tasks is the primary navigation action. The fixed header,
dynamic viewport bounds, one content scroller, safe-area padding, and 44px touch
targets remain. Desktop shares layout state while retaining its own composition.
## ASCII UI preview
UI-01: phone menu after saving a sidebar toggle, entered from Home or a task.
```text
Before After
Menu x Menu x
Workspace Workspace
Home Home
Quick Chat | Quick terminal Quick Chat | Quick terminal
New Task Tasks v +
Automations [task rows and filters]
Canvases Automations >
Integrations Canvases >
[unlabelled icon strip] Integrations >
Tasks v + [custom groups]
[task rows below fold] Utilities
Expanded Integrations:
Integrations v
[GitHub icon] GitHub
[GitLab icon] GitLab
Integration settings
```
Header stays fixed; all content shares one scroller. Section ordering and named
links are requirements; spacing is illustrative. Empty integrations show settings.
UI-02: desktop retains saved nodes/groups above Tasks and existing icon shortcuts.
## Tests and E2E
`settings/mobile-sidebar-customization.spec.ts` covers saved Home visibility,
first toggle save, task-before-tools order, labelled integration links, setup,
touch containment, scroll ownership, and task navigation (AC-UI-MOBILE-MENU-007.2,
007.3, 007.4 and AC-UI-SIDEBAR-CUSTOMIZATION-005.5).
Existing mobile menu hierarchy/integration and desktop customization tests cover
default layout, quick actions, launch handoff, and unchanged desktop behavior.
## Work orders
- [x] [Task 01: Restore saved phone navigation](task-01-restore-phone-navigation.md)
- [x] [Task 02: Consolidate mobile plugin navigation](task-02-consolidate-plugin-navigation.md)
## Verification results
Implemented with regression-first Playwright coverage. Saved layouts now compose
Home and quick actions, Tasks, then workspace tools. Built-in disclosures retain
setup and recovery actions; regular task menus use the existing create button.
The legacy Canvas block defers to the saved layout, including hidden Canvases.
Validation: 16 mobile regressions, 3 desktop regressions, and 58 focused unit tests
passed. Typecheck, focused ESLint, localization checks, docs catalog/spec checks,
and harness validation passed. See the work order for exact commands and results.
Seven screenshots use this branch's production build and a disposable mock-backed
workspace with four seeded tasks and GitHub/GitLab integrations. Captures cover
393px dark/light Tasks and Integrations, 360px and 767px phones, and 1280px desktop.
PR publication and exact-head CI/review status are tracked in the PR.
The user subsequently requested consolidation with PR #3889. Task 02 records
the combined 22 mobile, 3 desktop, and 77 unit checks plus nine replacement
screenshots with an installed, functional demonstration plugin. The new capture
uses clean implementation commit `7de0b0f9c` and supersedes the original images.
## Risks
- Plugin links must not duplicate or bypass saved visibility.
- Hidden Home must retain quick actions; task selection must retain its controller.
- Mobile section expansion must not introduce another vertical scroll region.
@@ -0,0 +1,127 @@
---
id: "01-restore-phone-navigation"
title: "Restore saved phone navigation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-MOBILE-MENU-007
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
acceptance_criteria:
- AC-UI-MOBILE-MENU-007.2
- AC-UI-MOBILE-MENU-007.3
- AC-UI-MOBILE-MENU-007.4
- AC-UI-SIDEBAR-CUSTOMIZATION-005.5
system_design:
- ../../specs/ui/system-design/sidebar-customization.md
- ../../specs/ui/system-design/unified-mobile-navigation.md
---
# Task 01: Restore saved phone navigation
## Summary and scope
Correct the saved phone menu's placement of Tasks and built-in resource sections.
Preserve custom groups, saved visibility, plugin ownership, desktop order, and
domain availability. No backend or persistence changes.
## Acceptance
- First sidebar toggle save and reload preserve task-before-tools ordering.
- Integrations and Automations retain labelled disclosures and empty setup;
Canvases and custom groups remain usable without anonymous built-in icon strips.
- Production-build phone and desktop tests pass; isolated seeded screenshots
demonstrate populated Tasks and expanded integrations in dark and light themes.
## ASCII UI preview
UI-01: phone menu, excerpt from the [full preview](plan.md#ascii-ui-preview).
```text
Menu x
Workspace
Home
Quick Chat | Quick terminal
Tasks v +
[task rows]
Automations >
Canvases >
Integrations v
GitHub
GitLab
Integration settings
Utilities
```
Fixed heading; one scroller; 44px touch controls. UI-02 desktop retains saved
nodes above Tasks. Covers the acceptance criteria in frontmatter.
## Files likely touched
- `apps/web/components/navigation/app-nav-sections.tsx`
- `apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx`
- `apps/web/components/navigation/mobile-canvases-section.tsx`
- `apps/web/components/integrations/integrations-menu.tsx`
- `apps/web/components/app-sidebar/shortcut-section.tsx`
- `apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts`
- `apps/web/AGENTS.md` and the owning specifications/public navigation guide.
## Verification
```bash
make build-web
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/settings/mobile-sidebar-customization.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts tests/integrations/mobile-integrations-nav.spec.ts -- --retries=0)
(cd apps/web && pnpm e2e:run --no-build --project chromium tests/settings/sidebar-customization.spec.ts -- --retries=0)
(cd apps/web && pnpm exec vitest run components/integrations/integrations-menu.test.ts components/navigation/app-nav-sheet.test.tsx components/navigation/mobile-automations-section.test.tsx components/navigation/mobile-canvases-section.test.tsx components/app-sidebar/shortcut-section.test.tsx components/kanban/mobile-menu-utility-actions.test.tsx components/navigation/destination-rows.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
git diff --check
```
Run focused lint/unit tests for changed files and any new helper. Screenshot
inspection covers 393px, narrow phones, and 767px; confirm desktop at 768px+.
## Dependencies and parallelism
None. Sequential implementation in the primary session.
## Risks
Saved plugin nodes and independent custom groups must preserve their visibility;
task/dialog state remains owned by the existing retained controller.
## Results
- Red: saved Home visible/hidden initially placed Automations above Tasks. The
populated first-toggle flow now covers persisted visibility, one menu scroller,
44px targets, settings navigation, and task activation.
- Red: the redundant New Task row was visible; regular menus now keep task
creation in the existing task heading, while Office retains its creation entry.
- Red: Canvas settings appeared twice. The legacy workspace-actions block now
defers to saved layout rendering; hiding Canvases survives save/reload.
- Green: the managed production-build mobile command ran with the disposable
screenshot spec in the same invocation: 17 passed (16 permanent regressions
plus capture), retries disabled. A final capture-only run regenerated all seven
assets after visual refinement. The desktop command passed all 3 tests.
- Green: 58 focused unit tests across the seven listed suites. Typecheck, focused
ESLint, `i18n:check`, docs catalog validation, all spec lint, public-docs
validation, harness validation, and `git diff --check` passed.
- Visual inspection: seeded phone task list, expanded provider/settings rows,
dark and light themes, 360px/393px/767px phones, and the 1280px desktop sidebar.
Runtime data and synthetic identities belonged to disposable E2E backends;
screenshots use actual rendered UI and no DOM/CSS alterations.
- Public navigation documentation, sidebar requirements/design, and scoped
frontend guidance updated with the phone composition contract.
### PR accessibility remediation
A regression test reproduced the Canvas disclosure pointing to a missing panel
while collapsed. The panel now remains mounted with `hidden`, preserving its
accessible relationship throughout open/close transitions. The 58 focused unit
tests, 16 mobile regressions, typecheck, and affected ESLint pass after the fix.
The shared automation identifier remains paired with its owning disclosure, and
canvas URLs remain guaranteed by `buildShortcutCatalog` and `canvasHref`; no
placeholder navigation is introduced.
@@ -0,0 +1,101 @@
---
id: "02-consolidate-plugin-navigation"
title: "Consolidate mobile plugin navigation"
status: done
wave: 2
depends_on: ["01-restore-phone-navigation"]
plan: "plan.md"
requirements:
- REQ-UI-MOBILE-MENU-007
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
acceptance_criteria:
- AC-UI-MOBILE-MENU-007.2
- AC-UI-MOBILE-MENU-007.4
- AC-UI-SIDEBAR-CUSTOMIZATION-005.5
system_design:
- ../../specs/ui/system-design/unified-mobile-navigation.md
- ../../specs/ui/system-design/sidebar-customization.md
---
# Task 02: Consolidate mobile plugin navigation
## Scope
Combine the plugin grouping and resource placement from PR #3889 with the saved
navigation fixes in PR #3890. The user requested one PR, adapted shared
components, and new screenshots populated with tasks and installed plugins.
The source handoff is `b48359592` plus review/CI follow-up `e27b2b466`.
## Acceptance and ASCII UI preview
```text
Menu x
Workspace / Home / quick actions
Tasks v +
[populated task rows]
Saved optional tools and plugin destinations
Integrations v
GitHub / GitLab / Integration settings
Plugins
Workspace: Project brief / Release notes
Task: Review checklist
System metrics
Utilities
```
This saved-layout excerpt covers the criteria above. Default layouts retain
their existing destination order. Plugin controls share one section; saved
destinations render once. Workspace/task context, the single scroller, 44px phone
targets, desktop composition, and metrics preferences remain intact.
## Ownership, dependencies, and risks
Work stays in the primary session. Task 01 and the committed peer handoff are
the inputs. Reconcile AppNavSheet, AppNavSections, workspace actions, scoped
guidance, and the unified navigation design. Plugin-owned controls must retain
their context, including the workspace label, without forced square sizing.
No plugin SDK, backend, release-flag default, or persistence changes.
## Verification
```bash
make build-web
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/settings/mobile-sidebar-customization.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts tests/integrations/mobile-integrations-nav.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts tests/task/mobile-autopilot-mode.spec.ts -- --retries=0)
(cd apps/web && pnpm e2e:run --no-build --project chromium tests/settings/sidebar-customization.spec.ts -- --retries=0)
(cd apps/web && pnpm run typecheck)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
node scripts/validate-public-docs.mjs
git diff --check
```
Run the nine focused unit suites from Task 01 plus mobile-plugin-nav-section and
main-top-bar-plugin-actions, and focused ESLint/Prettier. Capture from a fresh
isolated E2E backend using this PR's production build. Seed four storefront
tasks, synthetic GitHub/GitLab identities, and a packaged demonstration plugin
with functioning project notes, release notes, and a task review checklist.
Enable Canvases only in the disposable capture runtime. Inspect both themes,
320px/393px/767px phones, and a 1280px desktop. Publish media separately from code.
## Results
- Reconciled both committed peer changes while preserving task-first saved
layouts, native built-in disclosures, and saved plugin destination ownership.
- RED/GREEN: moved sidebar controls omitted the workspace label. The grouping
test reproduces the omission and now passes with the full context forwarded.
- 22 combined mobile scenarios passed, including the first saved visibility
edit with installed plugins, task controls, metrics preference branches,
320px/767px containment, the 768px boundary, and the autopilot assertion fix.
- All three desktop sidebar regressions passed after integration.
- 77 focused unit cases across nine suites pass on the combined implementation.
Typecheck, affected lint/formatting, catalog/specification checks, and public
documentation validation passed.
- After two rehearsals, the final capture ran from clean implementation commit
`7de0b0f9c` with a fresh production build. All nine screenshots were inspected:
populated Tasks, integrations, workspace/task plugins, both themes, narrow and
wide phones, and desktop. The real plugin checklist was exercised before capture.
Temporary runtime data, ports, backend process, and capture spec were removed.
Publication and current-head CI/review status are tracked in PR #3890.
- The peer's intermittent history-recovery scenario passed three fresh diagnostic
runs with retries disabled. Each observed the intended two dropped replies.
No speculative fixture change is included; final CI remains the delivery gate.
+3 -1
View File
@@ -2098,7 +2098,9 @@ type MainTopBarSlotProps = {
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 listing menu's **Plugins** section. The host wraps
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
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
+2
View File
@@ -225,6 +225,8 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl
Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. On a phone, the hamburger opens the same app menu from Home, listings, and the workbench. Tap the task title and chevron to switch tasks; the picker opens as an inset bottom card. Tap the **Kanban**, **Threads**, or **List** title dropdown for view options, search, filters, and display settings. The app menu uses **Home** for all listing modes, with **Quick Chat** and **Quick terminal** directly below it. Its collapsible **Tasks** section contains saved views, filters, and task actions; the adjacent **+** creates a task even when the section is collapsed. **Automations** and **Integrations** start collapsed; expand their headings to browse automations or connected providers. Integrations includes settings even before a provider is connected. **Utilities** follows these sections, with Settings before Stats. The current-session control shows the active agent's icon and name.
The phone menu groups plugin controls in one **Plugins** section. When both workspace and task controls are available, **Workspace** and **Task** labels distinguish them. Optional **System metrics** appear after navigation, before Utilities, when the app status bar is disabled.
Press **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere to search the
contents of every file in the active task workspace. Results are grouped by
repository and show the repository-relative path, line number, and matching
+4 -1
View File
@@ -118,7 +118,10 @@ icons show running, idle, or paused activity. Tasks and required inbox entries r
their fixed navigation area, and hiding a shortcut does not disable the underlying feature.
On a phone, open the menu and choose **Sidebar** to edit the same layout. Use the move controls to
reorder entries or move a shortcut to another section. **Restore defaults** resets the draft for the
reorder entries or move a shortcut to another section. Home and quick actions stay above Tasks;
customized workspace tools and shortcut sections follow the task list. Expand **Integrations** to
see named provider links and integration settings, including when no provider is configured.
**Restore defaults** resets the draft for the
active workspace; the shared **Save changes** action persists it. If another client saves first, the
editor keeps your draft and reports the conflict so you can reconcile it.
@@ -58,6 +58,7 @@ A shortcut is a reference to an existing destination or supported host action.
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.2:** All editing and navigation operations shall work without dragging, hovering, or long pressing. Touch targets shall measure at least 44 pixels.
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.3:** Phone surfaces shall fit the viewport, contain long-content scrolling, and clear safe areas. Desktop and phone shall share layout data without persisting responsive overflow choices.
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.4:** Controls shall have accessible names, keyboard operation, visible focus, and predictable focus return. New interface copy shall support every shipped locale.
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.5:** Saving or resetting a layout shall preserve the phone menu hierarchy: visible Home and quick actions, required task/local navigation, then customizable workspace tools and shortcut groups. Workspace tools retain their saved relative order and visibility. Built-in Automations, Canvases, and Integrations shall use labelled disclosures with visible expansion cues and labelled destinations; integration and automation setup remain reachable when empty. The Tasks heading retains the create-task action in regular workspaces, without a duplicate built-in New Task row. Office keeps its existing creation entry. Phone composition shall not rewrite the saved desktop order.
## Scope boundaries
@@ -67,9 +68,12 @@ buttons, new Slack behavior, Run automation actions, arbitrary URLs/scripts,
shared team layouts, and task-list filtering changes are excluded.
Office-only sections keep their existing composition in this version. Common
Home, New Task, plugin links, and shortcut groups remain customizable in Office.
Required inbox entries and the Tasks region remain fixed. Shortcut groups belong
above that region. The editor identifies these fixed entries.
Required inbox entries and the Tasks region remain fixed. Desktop shortcut groups
belong above that region; phone groups follow task navigation. The editor identifies
these fixed entries. Phone composition follows the
[unified mobile navigation contract](unified-mobile-navigation.md).
## Implementation plans
- [Sidebar customization](../../../plans/sidebar-customization/plan.md)
- [Mobile saved navigation](../../../plans/mobile-saved-navigation/plan.md)
@@ -175,8 +175,11 @@ the full task list.
move those actions below the task list. Existing workspace context, activity
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,
canvases, and page-local destinations shall remain reachable. Utilities shall
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
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
conditional status and health affordances shall retain their behavior.
- **AC-UI-MOBILE-MENU-007.3:** Integrations shall use a labelled, keyboard- and
@@ -213,3 +216,4 @@ the full task list.
## Implementation plans
- [Unified mobile navigation](../../../plans/unified-mobile-navigation/plan.md)
- [Coherent mobile plugin menu](../../../plans/mobile-plugin-menu-coherence/plan.md)
@@ -125,7 +125,7 @@ Newly registered plugin destinations append in their canonical location until
customized. Retain hidden entries rather than treating omission as hidden.
Unknown future node kinds must not be written back by an older editor.
Render optional nodes above the fixed Tasks region in regular workspaces.
On desktop, render optional nodes above the fixed Tasks region in regular workspaces.
Keep Office-only regions in their existing order; apply common customizable
nodes before them. Required Inbox/Needs you entries retain their existing mode
and feature gates and cannot be hidden in this editor.
@@ -226,6 +226,23 @@ A phone group uses a full-width disclosure row, followed by a compact icon strip
expansion reveals labelled shortcuts beneath it. The extra line preserves 44px
hit targets and name space. More contains overflow, without horizontal page scroll.
The saved-layout renderer places visible Home and quick actions before the
existing `afterPrimary` task/local-navigation outlet. Remaining visible nodes
follow that outlet in saved relative order. This is an effective phone projection;
neither layout revisions nor desktop ordering change. Wider sheet consumers retain
their existing ordering. Required inbox destinations remain reachable. Regular phone workspaces use the Tasks heading plus for built-in task creation; Office and explicit custom New Task shortcuts keep their existing launch controls.
Built-in resource sections do not use the custom group's icon-strip presentation.
Reuse `MobileAutomationsSection` and `MobileIntegrationsSection` for their labelled
disclosures, availability, loading/retry, and setup paths. Saved automation rows
consume the existing catalog and activity controller through the disclosure body
slot, avoiding a second polling loop. Saved integration plugin
nodes retain their own ordering and visibility, so the built-in integration section
excludes those independently rendered plugin links. Canvases use a labelled phone
disclosure and the shared catalog, including loading/error state and workspace settings.
Custom mixed groups retain direct shortcuts and automation activity, with an explicit
chevron on the disclosure. The shared app drawer remains the only vertical scroller.
The editor uses direct full-page settings navigation because it is a multi-step
editing task. Show the layout list, then a focused section editor, then a picker;
Back returns to the draft without losing changes. Retain the shared save control
@@ -260,3 +277,4 @@ tradeoffs fit this document; no additional ADR is required.
## Delivery
- [Plan and work orders](../../../plans/sidebar-customization/plan.md)
- [Mobile saved navigation repair](../../../plans/mobile-saved-navigation/plan.md)
@@ -294,18 +294,40 @@ quick-action row; use min-width zero and wrapping labels with minimum 44px heigh
Do not move plugin content, search or metrics into that row.
Phone sequence: workspace picker; Home and quick actions; existing page-local
navigation; Tasks; existing workspace plugin/canvas and fallback metric content;
Automations; existing plugin navigation groups; Integrations; Utilities. Optional
navigation; Tasks; canvases; Automations; Plugins; Integrations; optional fallback
system metrics; Utilities. Optional
extension slots remain reachable without breaking the adjacency of Home/quick
actions or the relative order of the named sections. Office retains its local
navigation and suppression of the Kanban task and automation sections.
A saved sidebar layout owns its configured section order and visibility instead
of the default sequence. Keep phone quick actions immediately after its Home row,
or at the top when Home is hidden. Home still covers all listing modes. Retain
the embedded Tasks section and Utilities outside the configurable projection;
do not duplicate default Automations, plugin groups, or Integrations when the
saved layout renders those sections.
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
and opts out of rendering plugin actions only for phone app navigation.
`NavigationMetrics` retains the status-bar preference gate and reuses
`StatusSurfaceMetrics` after navigation. No new subscription, metric setting,
or resource card is introduced. The phone scroller uses a 16px section gap;
plugin controls wrap within its width. Main-toolbar host buttons retain a 44px
minimum touch target without forcing labeled buttons into square icon geometry.
Wider consumers of the existing listing menu and inline plugin slots retain
their composition. The delivery record is the
[coherent mobile plugin menu plan](../../../plans/mobile-plugin-menu-coherence/plan.md).
A saved sidebar layout retains visibility and the relative order of optional
tools and plugin destinations. On phones, keep Home and quick actions before
the embedded Tasks section, followed by those optional nodes; when Home is
hidden, quick actions remain first. Home still covers all listing modes. Keep
workspace/task plugin controls together after the configurable projection,
followed by metrics and Utilities. Do not duplicate saved plugin destinations,
Automations, Canvases, or Integrations in default sections, or persist this phone
composition over the desktop layout. See the
[sidebar customization design](sidebar-customization.md).
Add an explicit phone disclosure option to `MobileIntegrationsSection`; leave
its default/wider behavior unchanged. Use a local initially-false expanded state,