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);