fix(ui): polish mobile navigation and plugins (#3890)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
+2
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user