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);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
created: 2026-09-23
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-MOBILE-TASK-CHROME-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
- ../../specs/ui/system-design/mobile-task-chrome.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Coherent Mobile Plugin Menu
|
||||
|
||||
## Overview
|
||||
|
||||
Group the existing phone plugin controls in one place. The current menu renders
|
||||
workspace widgets, system metrics, Automations, and then more plugin widgets.
|
||||
Preserve every slot's context and actions while removing that fragmented hierarchy.
|
||||
The user requested autonomous delivery through a PR with screenshots.
|
||||
|
||||
## Scope
|
||||
|
||||
Phone app-menu composition, plugin containment, focused regressions, and PR
|
||||
screenshots. Desktop/tablet layout, plugin APIs, resource preferences, and
|
||||
plugin implementations remain outside this change.
|
||||
|
||||
## Technical approach
|
||||
|
||||
`AppNavSheet` supplies the phone workspace context to `MobilePluginNavSection`
|
||||
through `AppNavSections`. That section composes main-top-bar and sidebar workspace
|
||||
actions with task actions and existing plugin destinations. When workspace and
|
||||
task contributions coexist, use localized context labels to explain their scope.
|
||||
Gate empty groups on registrations, including saved sidebar layouts that already
|
||||
own plugin destinations. Keep canvases in the workspace section. Move fallback
|
||||
`StatusSurfaceMetrics` after Integrations and before Utilities, preserving its
|
||||
existing preference and subscription behavior. Normalize phone host buttons to
|
||||
44px without forcing square dimensions onto labeled controls.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
### UI-01: Phone app navigation, plugins installed
|
||||
|
||||
```text
|
||||
Before: Tasks > | workspace widgets | Metrics | Automations > | Plugins
|
||||
|
||||
After:
|
||||
Menu x (fixed)
|
||||
Workspace picker
|
||||
Home
|
||||
[Quick Chat] [Quick terminal]
|
||||
Tasks > +
|
||||
Automations >
|
||||
Plugins
|
||||
Workspace
|
||||
[workspace actions and status, wrapping]
|
||||
Task
|
||||
[task actions and status, wrapping]
|
||||
[plugin destinations]
|
||||
Integrations >
|
||||
[System metrics: CPU / Memory / Disk]
|
||||
Utilities
|
||||
```
|
||||
|
||||
One safe-area-aware internal scroller; no additional surface. Context labels
|
||||
appear only when both contexts exist. With no plugin actions or destinations,
|
||||
the entire Plugins section is absent. Metrics remain opt-in and are only in
|
||||
this menu when the app status bar is disabled. Spacing is illustrative.
|
||||
|
||||
UI-01D desktop/tablet: existing sidebar, inline plugin toolbar controls, and
|
||||
resource/status surfaces retain their composition. The phone inset Drawer is
|
||||
structurally absent there.
|
||||
|
||||
## Tests
|
||||
|
||||
- `app-nav-sheet.test.tsx`: all phone contributions share one section, context
|
||||
remains correct, and empty sections disappear.
|
||||
- Existing plugin-section and main-top-bar unit tests preserve destinations,
|
||||
desktop context, and mobile presentation.
|
||||
|
||||
## E2E tests
|
||||
|
||||
`mobile-plugin-topbar.spec.ts` covers the real packaged plugin on listing and
|
||||
task routes, grouped placement, task/workspace context, actions, 44px targets,
|
||||
resource order, one scroller, containment, dismissal, and screenshots.
|
||||
`mobile-resource-metrics-display.spec.ts` preserves preferences and the
|
||||
393/320/767/768px boundary. `mobile-menu-hierarchy.spec.ts` retains quick-action,
|
||||
disclosure, navigation, and heading-alignment checks after the section-gap change. These map to AC-UI-MOBILE-MENU-007.2/.4 and
|
||||
AC-UI-MOBILE-TASK-CHROME-001.6/.7.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Group phone plugin controls](task-01-group-phone-plugins.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- RED: navigation unit and managed mobile browser assertions reproduced workspace
|
||||
contributions outside Plugins before production changes.
|
||||
- GREEN: 44 focused Vitest tests passed; web typecheck, targeted ESLint (zero
|
||||
warnings), Prettier, and the i18n ratchet passed.
|
||||
- Final rendered checks: 15 mobile-chrome scenarios passed across plugin menus,
|
||||
resource preferences, and menu hierarchy after a fresh `pnpm run build:e2e`.
|
||||
The final managed run used `--host --no-build` with that build and the already
|
||||
rebuilt backend/plugin package. Phone coverage includes 320/393/767px and the
|
||||
existing 768px desktop boundary, both resource locations, Portuguese labels,
|
||||
quick actions, plugin activation, and focus return.
|
||||
- Four fresh screenshots were captured with isolated synthetic data. Dark/light
|
||||
task views, the listing menu, and the no-plugin resource view are in the
|
||||
ignored PR-asset manifest. The phone drawer is absent on desktop; no unrelated
|
||||
desktop screenshot is required.
|
||||
- Public-doc validation: 62 tests and 47 pages passed. Specification catalog and
|
||||
lint passed (300 decisions, 1,131 specifications); diff checks passed.
|
||||
- PR review added direct coverage of the status-bar preference and semantic
|
||||
Plugins-region queries. CI exposed a mobile autopilot assertion that polled
|
||||
a transient state; it now checks the durable resumed turn, matching desktop.
|
||||
The affected tests and nearby CI ordering passed with one worker, retries
|
||||
disabled, and CPU affinity restricted to two cores. An intermittent
|
||||
history-recovery failure remains open; its evidence and an unsuccessful
|
||||
fixture experiment are handed to PR #3890 for combined verification.
|
||||
|
||||
## Risks
|
||||
|
||||
Plugins own their markup. Preserve opaque controls and separate workspace/task
|
||||
contexts rather than deduplicating contributions by appearance. Saved sidebar
|
||||
layouts already own destinations and must not gain duplicate links.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
id: "01-group-phone-plugins"
|
||||
title: "Group phone plugin controls"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: plan.md
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-MOBILE-TASK-CHROME-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-MOBILE-MENU-007.2
|
||||
- AC-UI-MOBILE-MENU-007.4
|
||||
- AC-UI-MOBILE-TASK-CHROME-001.6
|
||||
- AC-UI-MOBILE-TASK-CHROME-001.7
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
- ../../specs/ui/system-design/mobile-task-chrome.md
|
||||
---
|
||||
|
||||
# Task 01: Group Phone Plugin Controls
|
||||
|
||||
## Summary
|
||||
|
||||
Compose workspace and task plugin widgets inside the same phone Plugins section,
|
||||
then place fallback metrics after navigation. Preserve plugin context and all
|
||||
existing controls; publish synthetic mobile screenshots with the PR.
|
||||
|
||||
## In scope
|
||||
|
||||
Phone composition, empty-state gating, touch sizing, unit/E2E regressions,
|
||||
scoped guidance, public navigation copy, and isolated PR capture.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Plugin SDK/backend changes, plugin deduplication, new settings, and desktop redesign.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Main-toolbar, sidebar workspace, and task contributions appear under one
|
||||
Plugins heading. Context labels distinguish workspace and task groups when
|
||||
both exist. Empty sections disappear; saved-layout links are not duplicated.
|
||||
2. Resources no longer interrupt plugin controls. Existing preference gates,
|
||||
context, actions, focus return, and the sole menu scroller remain intact.
|
||||
3. Touch controls are contained and at least 44px; focused tests pass, and
|
||||
fresh isolated phone screenshots are inspected before PR publication.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01 excerpt from [the plan](plan.md#ui-01-phone-app-navigation-plugins-installed):
|
||||
|
||||
```text
|
||||
Tasks > / Automations >
|
||||
Plugins
|
||||
Workspace: [controls]
|
||||
Task: [controls]
|
||||
[destinations]
|
||||
Integrations >
|
||||
System metrics
|
||||
Utilities
|
||||
```
|
||||
|
||||
The menu keeps its fixed header and single internal scroller. UI-01D keeps
|
||||
desktop/tablet controls in their existing sidebar and inline toolbars.
|
||||
Maps to AC-UI-MOBILE-MENU-007.2/.4 and AC-UI-MOBILE-TASK-CHROME-001.6/.7.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/navigation/app-nav-{sheet,sections}.tsx` and existing tests.
|
||||
- `apps/web/components/plugins/mobile-plugin-nav-section.tsx` and existing tests.
|
||||
- `apps/web/components/app-sidebar/app-sidebar-workspace-actions.tsx`.
|
||||
- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx`.
|
||||
- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`.
|
||||
- Scoped web guidance and existing navigation/plugin public documentation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/plugins/mobile-plugin-nav-section.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && CAPTURE_PR_ASSETS=1 pnpm e2e:run --host --project mobile-chrome tests/plugins/mobile-plugin-topbar.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run targeted ESLint/Prettier on changed frontend files as well.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Existing plugin-menu and unified-navigation packages are implemented.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential, in the primary session.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Unified mobile navigation requirements/design, action-first composition.
|
||||
- Mobile task chrome requirements/design, plugin presentation/context.
|
||||
- Existing packaged E2E plugin and mobile plugin/resource scenarios.
|
||||
|
||||
## Risks
|
||||
|
||||
Opaque plugin markup may be wider than the menu. Preserve existing mobile
|
||||
containment and test host buttons and raw sidebar controls separately.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the unit grouping assertion failed on `closest(...) === null`, and the
|
||||
browser assertion returned false for workspace controls inside Plugins.
|
||||
- Focused Vitest command: 44 tests passed. Typecheck and changed-file
|
||||
ESLint/Prettier passed without warnings; the i18n ratchet passed.
|
||||
- Final browser command: `pnpm run build:e2e`, followed by the listed managed
|
||||
capture command with `--no-build`, passed all 15 scenarios. Backend and fixture
|
||||
packages were freshly built in the preceding managed runs. The existing
|
||||
hierarchy assertion was updated from the old 24px gap to the intended 16px.
|
||||
- Four PNGs captured in the ignored PR-asset manifest using isolated E2E data;
|
||||
dark/light task views were visually checked against UI-01. Listing-only and
|
||||
no-plugin states retain resources and navigation without an empty plugin group.
|
||||
- Catalog/specification lint passed. Public-doc validators passed 62 tests and
|
||||
47 pages. `git diff --check` passed. Desktop/tablet composition is covered by
|
||||
retained slot tests and the resource suite's 768px boundary; this phone-only
|
||||
surface does not require an unrelated desktop screenshot.
|
||||
|
||||
### PR CI and review follow-up
|
||||
|
||||
- Added direct AppNavSheet coverage of both status-bar preference branches and
|
||||
selected the Plugins region by its accessible name in grouping assertions.
|
||||
- The mobile autopilot CI failure reproduced locally with retries disabled.
|
||||
Its resumed child can finish before a state poll sees RUNNING; assert the
|
||||
durable second turn, as the desktop test already does.
|
||||
- The history-recovery flake did not reproduce in six fresh two-core runs or
|
||||
after its four preceding CI specs. An experiment that settled the initial
|
||||
turn and avoided the reload-capable entry helper still failed on repetition
|
||||
two; it was not retained. The cause remains unresolved. Failure artifacts
|
||||
and the experimental patch are handed to the owner of PR #3890, which will
|
||||
consolidate this work and own combined verification and new screenshots.
|
||||
- Reproduction used `taskset -c 0,1 pnpm e2e:run --host --no-build --project
|
||||
mobile-chrome` with `--retries=0 --max-failures=1`. Six isolated history runs
|
||||
passed, followed by all ten tests across display settings, automation
|
||||
webhooks, PR link copying, port forwarding, history recovery, and autopilot.
|
||||
The history suite's retry override was temporarily removed for these runs
|
||||
and restored when the unsuccessful experiment was removed.
|
||||
No production behavior or screenshot changed during this follow-up.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
created: 2026-09-23
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/sidebar-customization.md
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Mobile saved navigation
|
||||
|
||||
## Overview
|
||||
|
||||
Restore the phone menu hierarchy after the first sidebar customization. The user
|
||||
requested unattended implementation through a PR with seeded screenshots, so this
|
||||
package records the work within the authorized delivery session.
|
||||
|
||||
## Root cause and scope
|
||||
|
||||
`useHasSavedSidebarLayout` switches rendering at revision greater than zero.
|
||||
`AppNavSections` inserts the entire saved layout before `afterPrimary`, moving
|
||||
workspace tools ahead of Tasks. Built-in sections also reuse `ShortcutSection`'s
|
||||
icon strip instead of the phone disclosures and omit setup/recovery paths.
|
||||
|
||||
Keep the shared drawer, saved layout data, domain availability, task controller,
|
||||
desktop composition, and custom shortcut actions. Correct phone ordering, reuse
|
||||
native built-in disclosures, and preserve labelled setup and navigation paths.
|
||||
No backend, new preference, credential, or task-lifecycle changes.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
The hamburger opens the shipped `AppNavSheet` inset drawer for a temporary
|
||||
navigation choice. Home/quick actions lead into the existing task list; optional
|
||||
workspace tools follow. Tasks is the primary navigation action. The fixed header,
|
||||
dynamic viewport bounds, one content scroller, safe-area padding, and 44px touch
|
||||
targets remain. Desktop shares layout state while retaining its own composition.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: phone menu after saving a sidebar toggle, entered from Home or a task.
|
||||
|
||||
```text
|
||||
Before After
|
||||
Menu x Menu x
|
||||
Workspace Workspace
|
||||
Home Home
|
||||
Quick Chat | Quick terminal Quick Chat | Quick terminal
|
||||
New Task Tasks v +
|
||||
Automations [task rows and filters]
|
||||
Canvases Automations >
|
||||
Integrations Canvases >
|
||||
[unlabelled icon strip] Integrations >
|
||||
Tasks v + [custom groups]
|
||||
[task rows below fold] Utilities
|
||||
|
||||
Expanded Integrations:
|
||||
Integrations v
|
||||
[GitHub icon] GitHub
|
||||
[GitLab icon] GitLab
|
||||
Integration settings
|
||||
```
|
||||
|
||||
Header stays fixed; all content shares one scroller. Section ordering and named
|
||||
links are requirements; spacing is illustrative. Empty integrations show settings.
|
||||
UI-02: desktop retains saved nodes/groups above Tasks and existing icon shortcuts.
|
||||
|
||||
## Tests and E2E
|
||||
|
||||
`settings/mobile-sidebar-customization.spec.ts` covers saved Home visibility,
|
||||
first toggle save, task-before-tools order, labelled integration links, setup,
|
||||
touch containment, scroll ownership, and task navigation (AC-UI-MOBILE-MENU-007.2,
|
||||
007.3, 007.4 and AC-UI-SIDEBAR-CUSTOMIZATION-005.5).
|
||||
Existing mobile menu hierarchy/integration and desktop customization tests cover
|
||||
default layout, quick actions, launch handoff, and unchanged desktop behavior.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore saved phone navigation](task-01-restore-phone-navigation.md)
|
||||
- [x] [Task 02: Consolidate mobile plugin navigation](task-02-consolidate-plugin-navigation.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implemented with regression-first Playwright coverage. Saved layouts now compose
|
||||
Home and quick actions, Tasks, then workspace tools. Built-in disclosures retain
|
||||
setup and recovery actions; regular task menus use the existing create button.
|
||||
The legacy Canvas block defers to the saved layout, including hidden Canvases.
|
||||
|
||||
Validation: 16 mobile regressions, 3 desktop regressions, and 58 focused unit tests
|
||||
passed. Typecheck, focused ESLint, localization checks, docs catalog/spec checks,
|
||||
and harness validation passed. See the work order for exact commands and results.
|
||||
|
||||
Seven screenshots use this branch's production build and a disposable mock-backed
|
||||
workspace with four seeded tasks and GitHub/GitLab integrations. Captures cover
|
||||
393px dark/light Tasks and Integrations, 360px and 767px phones, and 1280px desktop.
|
||||
PR publication and exact-head CI/review status are tracked in the PR.
|
||||
|
||||
The user subsequently requested consolidation with PR #3889. Task 02 records
|
||||
the combined 22 mobile, 3 desktop, and 77 unit checks plus nine replacement
|
||||
screenshots with an installed, functional demonstration plugin. The new capture
|
||||
uses clean implementation commit `7de0b0f9c` and supersedes the original images.
|
||||
|
||||
## Risks
|
||||
|
||||
- Plugin links must not duplicate or bypass saved visibility.
|
||||
- Hidden Home must retain quick actions; task selection must retain its controller.
|
||||
- Mobile section expansion must not introduce another vertical scroll region.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
id: "01-restore-phone-navigation"
|
||||
title: "Restore saved phone navigation"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
|
||||
acceptance_criteria:
|
||||
- AC-UI-MOBILE-MENU-007.2
|
||||
- AC-UI-MOBILE-MENU-007.3
|
||||
- AC-UI-MOBILE-MENU-007.4
|
||||
- AC-UI-SIDEBAR-CUSTOMIZATION-005.5
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/sidebar-customization.md
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
---
|
||||
|
||||
# Task 01: Restore saved phone navigation
|
||||
|
||||
## Summary and scope
|
||||
|
||||
Correct the saved phone menu's placement of Tasks and built-in resource sections.
|
||||
Preserve custom groups, saved visibility, plugin ownership, desktop order, and
|
||||
domain availability. No backend or persistence changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- First sidebar toggle save and reload preserve task-before-tools ordering.
|
||||
- Integrations and Automations retain labelled disclosures and empty setup;
|
||||
Canvases and custom groups remain usable without anonymous built-in icon strips.
|
||||
- Production-build phone and desktop tests pass; isolated seeded screenshots
|
||||
demonstrate populated Tasks and expanded integrations in dark and light themes.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: phone menu, excerpt from the [full preview](plan.md#ascii-ui-preview).
|
||||
|
||||
```text
|
||||
Menu x
|
||||
Workspace
|
||||
Home
|
||||
Quick Chat | Quick terminal
|
||||
Tasks v +
|
||||
[task rows]
|
||||
Automations >
|
||||
Canvases >
|
||||
Integrations v
|
||||
GitHub
|
||||
GitLab
|
||||
Integration settings
|
||||
Utilities
|
||||
```
|
||||
|
||||
Fixed heading; one scroller; 44px touch controls. UI-02 desktop retains saved
|
||||
nodes above Tasks. Covers the acceptance criteria in frontmatter.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/navigation/app-nav-sections.tsx`
|
||||
- `apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx`
|
||||
- `apps/web/components/navigation/mobile-canvases-section.tsx`
|
||||
- `apps/web/components/integrations/integrations-menu.tsx`
|
||||
- `apps/web/components/app-sidebar/shortcut-section.tsx`
|
||||
- `apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts`
|
||||
- `apps/web/AGENTS.md` and the owning specifications/public navigation guide.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make build-web
|
||||
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/settings/mobile-sidebar-customization.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts tests/integrations/mobile-integrations-nav.spec.ts -- --retries=0)
|
||||
(cd apps/web && pnpm e2e:run --no-build --project chromium tests/settings/sidebar-customization.spec.ts -- --retries=0)
|
||||
(cd apps/web && pnpm exec vitest run components/integrations/integrations-menu.test.ts components/navigation/app-nav-sheet.test.tsx components/navigation/mobile-automations-section.test.tsx components/navigation/mobile-canvases-section.test.tsx components/app-sidebar/shortcut-section.test.tsx components/kanban/mobile-menu-utility-actions.test.tsx components/navigation/destination-rows.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run focused lint/unit tests for changed files and any new helper. Screenshot
|
||||
inspection covers 393px, narrow phones, and 767px; confirm desktop at 768px+.
|
||||
|
||||
## Dependencies and parallelism
|
||||
|
||||
None. Sequential implementation in the primary session.
|
||||
|
||||
## Risks
|
||||
|
||||
Saved plugin nodes and independent custom groups must preserve their visibility;
|
||||
task/dialog state remains owned by the existing retained controller.
|
||||
|
||||
## Results
|
||||
|
||||
- Red: saved Home visible/hidden initially placed Automations above Tasks. The
|
||||
populated first-toggle flow now covers persisted visibility, one menu scroller,
|
||||
44px targets, settings navigation, and task activation.
|
||||
- Red: the redundant New Task row was visible; regular menus now keep task
|
||||
creation in the existing task heading, while Office retains its creation entry.
|
||||
- Red: Canvas settings appeared twice. The legacy workspace-actions block now
|
||||
defers to saved layout rendering; hiding Canvases survives save/reload.
|
||||
- Green: the managed production-build mobile command ran with the disposable
|
||||
screenshot spec in the same invocation: 17 passed (16 permanent regressions
|
||||
plus capture), retries disabled. A final capture-only run regenerated all seven
|
||||
assets after visual refinement. The desktop command passed all 3 tests.
|
||||
- Green: 58 focused unit tests across the seven listed suites. Typecheck, focused
|
||||
ESLint, `i18n:check`, docs catalog validation, all spec lint, public-docs
|
||||
validation, harness validation, and `git diff --check` passed.
|
||||
- Visual inspection: seeded phone task list, expanded provider/settings rows,
|
||||
dark and light themes, 360px/393px/767px phones, and the 1280px desktop sidebar.
|
||||
Runtime data and synthetic identities belonged to disposable E2E backends;
|
||||
screenshots use actual rendered UI and no DOM/CSS alterations.
|
||||
- Public navigation documentation, sidebar requirements/design, and scoped
|
||||
frontend guidance updated with the phone composition contract.
|
||||
|
||||
### PR accessibility remediation
|
||||
|
||||
A regression test reproduced the Canvas disclosure pointing to a missing panel
|
||||
while collapsed. The panel now remains mounted with `hidden`, preserving its
|
||||
accessible relationship throughout open/close transitions. The 58 focused unit
|
||||
tests, 16 mobile regressions, typecheck, and affected ESLint pass after the fix.
|
||||
The shared automation identifier remains paired with its owning disclosure, and
|
||||
canvas URLs remain guaranteed by `buildShortcutCatalog` and `canvasHref`; no
|
||||
placeholder navigation is introduced.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: "02-consolidate-plugin-navigation"
|
||||
title: "Consolidate mobile plugin navigation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-restore-phone-navigation"]
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-MOBILE-MENU-007
|
||||
- REQ-UI-SIDEBAR-CUSTOMIZATION-005
|
||||
acceptance_criteria:
|
||||
- AC-UI-MOBILE-MENU-007.2
|
||||
- AC-UI-MOBILE-MENU-007.4
|
||||
- AC-UI-SIDEBAR-CUSTOMIZATION-005.5
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/unified-mobile-navigation.md
|
||||
- ../../specs/ui/system-design/sidebar-customization.md
|
||||
---
|
||||
|
||||
# Task 02: Consolidate mobile plugin navigation
|
||||
|
||||
## Scope
|
||||
|
||||
Combine the plugin grouping and resource placement from PR #3889 with the saved
|
||||
navigation fixes in PR #3890. The user requested one PR, adapted shared
|
||||
components, and new screenshots populated with tasks and installed plugins.
|
||||
The source handoff is `b48359592` plus review/CI follow-up `e27b2b466`.
|
||||
|
||||
## Acceptance and ASCII UI preview
|
||||
|
||||
```text
|
||||
Menu x
|
||||
Workspace / Home / quick actions
|
||||
Tasks v +
|
||||
[populated task rows]
|
||||
Saved optional tools and plugin destinations
|
||||
Integrations v
|
||||
GitHub / GitLab / Integration settings
|
||||
Plugins
|
||||
Workspace: Project brief / Release notes
|
||||
Task: Review checklist
|
||||
System metrics
|
||||
Utilities
|
||||
```
|
||||
|
||||
This saved-layout excerpt covers the criteria above. Default layouts retain
|
||||
their existing destination order. Plugin controls share one section; saved
|
||||
destinations render once. Workspace/task context, the single scroller, 44px phone
|
||||
targets, desktop composition, and metrics preferences remain intact.
|
||||
|
||||
## Ownership, dependencies, and risks
|
||||
|
||||
Work stays in the primary session. Task 01 and the committed peer handoff are
|
||||
the inputs. Reconcile AppNavSheet, AppNavSections, workspace actions, scoped
|
||||
guidance, and the unified navigation design. Plugin-owned controls must retain
|
||||
their context, including the workspace label, without forced square sizing.
|
||||
No plugin SDK, backend, release-flag default, or persistence changes.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make build-web
|
||||
(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/settings/mobile-sidebar-customization.spec.ts tests/layout/mobile-menu-hierarchy.spec.ts tests/integrations/mobile-integrations-nav.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts tests/task/mobile-autopilot-mode.spec.ts -- --retries=0)
|
||||
(cd apps/web && pnpm e2e:run --no-build --project chromium tests/settings/sidebar-customization.spec.ts -- --retries=0)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run the nine focused unit suites from Task 01 plus mobile-plugin-nav-section and
|
||||
main-top-bar-plugin-actions, and focused ESLint/Prettier. Capture from a fresh
|
||||
isolated E2E backend using this PR's production build. Seed four storefront
|
||||
tasks, synthetic GitHub/GitLab identities, and a packaged demonstration plugin
|
||||
with functioning project notes, release notes, and a task review checklist.
|
||||
Enable Canvases only in the disposable capture runtime. Inspect both themes,
|
||||
320px/393px/767px phones, and a 1280px desktop. Publish media separately from code.
|
||||
|
||||
## Results
|
||||
|
||||
- Reconciled both committed peer changes while preserving task-first saved
|
||||
layouts, native built-in disclosures, and saved plugin destination ownership.
|
||||
- RED/GREEN: moved sidebar controls omitted the workspace label. The grouping
|
||||
test reproduces the omission and now passes with the full context forwarded.
|
||||
- 22 combined mobile scenarios passed, including the first saved visibility
|
||||
edit with installed plugins, task controls, metrics preference branches,
|
||||
320px/767px containment, the 768px boundary, and the autopilot assertion fix.
|
||||
- All three desktop sidebar regressions passed after integration.
|
||||
- 77 focused unit cases across nine suites pass on the combined implementation.
|
||||
Typecheck, affected lint/formatting, catalog/specification checks, and public
|
||||
documentation validation passed.
|
||||
- After two rehearsals, the final capture ran from clean implementation commit
|
||||
`7de0b0f9c` with a fresh production build. All nine screenshots were inspected:
|
||||
populated Tasks, integrations, workspace/task plugins, both themes, narrow and
|
||||
wide phones, and desktop. The real plugin checklist was exercised before capture.
|
||||
Temporary runtime data, ports, backend process, and capture spec were removed.
|
||||
Publication and current-head CI/review status are tracked in PR #3890.
|
||||
- The peer's intermittent history-recovery scenario passed three fresh diagnostic
|
||||
runs with retries disabled. Each observed the intended two dropped replies.
|
||||
No speculative fixture change is included; final CI remains the delivery gate.
|
||||
@@ -2098,7 +2098,9 @@ type MainTopBarSlotProps = {
|
||||
Because the bar is not scoped to a task, no task/session ids are provided. On
|
||||
desktop, keep contributions to small badges or icon buttons in the compact
|
||||
horizontal strip. On a phone, `presentation` is `"mobile"`; the contribution
|
||||
renders in the shared listing menu's **Plugins** section. The host wraps
|
||||
renders in the shared app menu's **Plugins** section alongside sidebar workspace
|
||||
actions. When task contributions are also present, **Workspace** and **Task**
|
||||
labels distinguish their context. The host wraps
|
||||
contributions within the menu width and gives `host.ui.Button` controls a
|
||||
minimum 44px active target. Use `host.ui.Button` for documented icon actions;
|
||||
the host normalizes their SVG icons to 16px. Desktop contributions keep their
|
||||
|
||||
@@ -225,6 +225,8 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl
|
||||
|
||||
Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. On a phone, the hamburger opens the same app menu from Home, listings, and the workbench. Tap the task title and chevron to switch tasks; the picker opens as an inset bottom card. Tap the **Kanban**, **Threads**, or **List** title dropdown for view options, search, filters, and display settings. The app menu uses **Home** for all listing modes, with **Quick Chat** and **Quick terminal** directly below it. Its collapsible **Tasks** section contains saved views, filters, and task actions; the adjacent **+** creates a task even when the section is collapsed. **Automations** and **Integrations** start collapsed; expand their headings to browse automations or connected providers. Integrations includes settings even before a provider is connected. **Utilities** follows these sections, with Settings before Stats. The current-session control shows the active agent's icon and name.
|
||||
|
||||
The phone menu groups plugin controls in one **Plugins** section. When both workspace and task controls are available, **Workspace** and **Task** labels distinguish them. Optional **System metrics** appear after navigation, before Utilities, when the app status bar is disabled.
|
||||
|
||||
Press **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere to search the
|
||||
contents of every file in the active task workspace. Results are grouped by
|
||||
repository and show the repository-relative path, line number, and matching
|
||||
|
||||
@@ -118,7 +118,10 @@ icons show running, idle, or paused activity. Tasks and required inbox entries r
|
||||
their fixed navigation area, and hiding a shortcut does not disable the underlying feature.
|
||||
|
||||
On a phone, open the menu and choose **Sidebar** to edit the same layout. Use the move controls to
|
||||
reorder entries or move a shortcut to another section. **Restore defaults** resets the draft for the
|
||||
reorder entries or move a shortcut to another section. Home and quick actions stay above Tasks;
|
||||
customized workspace tools and shortcut sections follow the task list. Expand **Integrations** to
|
||||
see named provider links and integration settings, including when no provider is configured.
|
||||
**Restore defaults** resets the draft for the
|
||||
active workspace; the shared **Save changes** action persists it. If another client saves first, the
|
||||
editor keeps your draft and reports the conflict so you can reconcile it.
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ A shortcut is a reference to an existing destination or supported host action.
|
||||
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.2:** All editing and navigation operations shall work without dragging, hovering, or long pressing. Touch targets shall measure at least 44 pixels.
|
||||
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.3:** Phone surfaces shall fit the viewport, contain long-content scrolling, and clear safe areas. Desktop and phone shall share layout data without persisting responsive overflow choices.
|
||||
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.4:** Controls shall have accessible names, keyboard operation, visible focus, and predictable focus return. New interface copy shall support every shipped locale.
|
||||
- **AC-UI-SIDEBAR-CUSTOMIZATION-005.5:** Saving or resetting a layout shall preserve the phone menu hierarchy: visible Home and quick actions, required task/local navigation, then customizable workspace tools and shortcut groups. Workspace tools retain their saved relative order and visibility. Built-in Automations, Canvases, and Integrations shall use labelled disclosures with visible expansion cues and labelled destinations; integration and automation setup remain reachable when empty. The Tasks heading retains the create-task action in regular workspaces, without a duplicate built-in New Task row. Office keeps its existing creation entry. Phone composition shall not rewrite the saved desktop order.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
@@ -67,9 +68,12 @@ buttons, new Slack behavior, Run automation actions, arbitrary URLs/scripts,
|
||||
shared team layouts, and task-list filtering changes are excluded.
|
||||
Office-only sections keep their existing composition in this version. Common
|
||||
Home, New Task, plugin links, and shortcut groups remain customizable in Office.
|
||||
Required inbox entries and the Tasks region remain fixed. Shortcut groups belong
|
||||
above that region. The editor identifies these fixed entries.
|
||||
Required inbox entries and the Tasks region remain fixed. Desktop shortcut groups
|
||||
belong above that region; phone groups follow task navigation. The editor identifies
|
||||
these fixed entries. Phone composition follows the
|
||||
[unified mobile navigation contract](unified-mobile-navigation.md).
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Sidebar customization](../../../plans/sidebar-customization/plan.md)
|
||||
- [Mobile saved navigation](../../../plans/mobile-saved-navigation/plan.md)
|
||||
|
||||
@@ -175,8 +175,11 @@ the full task list.
|
||||
move those actions below the task list. Existing workspace context, activity
|
||||
indicators, launch behavior, and focus handoff shall be retained.
|
||||
- **AC-UI-MOBILE-MENU-007.2:** Regular workspace sections shall follow Tasks,
|
||||
Automations, Integrations, Utilities order. Existing eligible plugin actions,
|
||||
canvases, and page-local destinations shall remain reachable. Utilities shall
|
||||
Automations, Integrations, Utilities order. Existing eligible plugin actions
|
||||
shall share one Plugins section, with workspace and task context distinguished
|
||||
when both are present. Optional system metrics shall follow navigation instead
|
||||
of separating plugin controls. Canvases and page-local destinations shall
|
||||
remain reachable. Utilities shall
|
||||
place Settings before Stats, followed by theme and support actions; existing
|
||||
conditional status and health affordances shall retain their behavior.
|
||||
- **AC-UI-MOBILE-MENU-007.3:** Integrations shall use a labelled, keyboard- and
|
||||
@@ -213,3 +216,4 @@ the full task list.
|
||||
## Implementation plans
|
||||
|
||||
- [Unified mobile navigation](../../../plans/unified-mobile-navigation/plan.md)
|
||||
- [Coherent mobile plugin menu](../../../plans/mobile-plugin-menu-coherence/plan.md)
|
||||
|
||||
@@ -125,7 +125,7 @@ Newly registered plugin destinations append in their canonical location until
|
||||
customized. Retain hidden entries rather than treating omission as hidden.
|
||||
Unknown future node kinds must not be written back by an older editor.
|
||||
|
||||
Render optional nodes above the fixed Tasks region in regular workspaces.
|
||||
On desktop, render optional nodes above the fixed Tasks region in regular workspaces.
|
||||
Keep Office-only regions in their existing order; apply common customizable
|
||||
nodes before them. Required Inbox/Needs you entries retain their existing mode
|
||||
and feature gates and cannot be hidden in this editor.
|
||||
@@ -226,6 +226,23 @@ A phone group uses a full-width disclosure row, followed by a compact icon strip
|
||||
expansion reveals labelled shortcuts beneath it. The extra line preserves 44px
|
||||
hit targets and name space. More contains overflow, without horizontal page scroll.
|
||||
|
||||
The saved-layout renderer places visible Home and quick actions before the
|
||||
existing `afterPrimary` task/local-navigation outlet. Remaining visible nodes
|
||||
follow that outlet in saved relative order. This is an effective phone projection;
|
||||
neither layout revisions nor desktop ordering change. Wider sheet consumers retain
|
||||
their existing ordering. Required inbox destinations remain reachable. Regular phone workspaces use the Tasks heading plus for built-in task creation; Office and explicit custom New Task shortcuts keep their existing launch controls.
|
||||
|
||||
Built-in resource sections do not use the custom group's icon-strip presentation.
|
||||
Reuse `MobileAutomationsSection` and `MobileIntegrationsSection` for their labelled
|
||||
disclosures, availability, loading/retry, and setup paths. Saved automation rows
|
||||
consume the existing catalog and activity controller through the disclosure body
|
||||
slot, avoiding a second polling loop. Saved integration plugin
|
||||
nodes retain their own ordering and visibility, so the built-in integration section
|
||||
excludes those independently rendered plugin links. Canvases use a labelled phone
|
||||
disclosure and the shared catalog, including loading/error state and workspace settings.
|
||||
Custom mixed groups retain direct shortcuts and automation activity, with an explicit
|
||||
chevron on the disclosure. The shared app drawer remains the only vertical scroller.
|
||||
|
||||
The editor uses direct full-page settings navigation because it is a multi-step
|
||||
editing task. Show the layout list, then a focused section editor, then a picker;
|
||||
Back returns to the draft without losing changes. Retain the shared save control
|
||||
@@ -260,3 +277,4 @@ tradeoffs fit this document; no additional ADR is required.
|
||||
## Delivery
|
||||
|
||||
- [Plan and work orders](../../../plans/sidebar-customization/plan.md)
|
||||
- [Mobile saved navigation repair](../../../plans/mobile-saved-navigation/plan.md)
|
||||
|
||||
@@ -294,18 +294,40 @@ quick-action row; use min-width zero and wrapping labels with minimum 44px heigh
|
||||
Do not move plugin content, search or metrics into that row.
|
||||
|
||||
Phone sequence: workspace picker; Home and quick actions; existing page-local
|
||||
navigation; Tasks; existing workspace plugin/canvas and fallback metric content;
|
||||
Automations; existing plugin navigation groups; Integrations; Utilities. Optional
|
||||
navigation; Tasks; canvases; Automations; Plugins; Integrations; optional fallback
|
||||
system metrics; Utilities. Optional
|
||||
extension slots remain reachable without breaking the adjacency of Home/quick
|
||||
actions or the relative order of the named sections. Office retains its local
|
||||
navigation and suppression of the Kanban task and automation sections.
|
||||
|
||||
A saved sidebar layout owns its configured section order and visibility instead
|
||||
of the default sequence. Keep phone quick actions immediately after its Home row,
|
||||
or at the top when Home is hidden. Home still covers all listing modes. Retain
|
||||
the embedded Tasks section and Utilities outside the configurable projection;
|
||||
do not duplicate default Automations, plugin groups, or Integrations when the
|
||||
saved layout renders those sections.
|
||||
The September 23 composition refinement groups `main-top-bar`,
|
||||
`sidebar-workspace-actions`, and page-scoped `chat-top-bar` contributions inside
|
||||
`MobilePluginNavSection`. `AppNavSheet` supplies phone workspace context through
|
||||
`AppNavSections`; the section checks registrations before rendering workspace
|
||||
controls. When both workspace and task actions exist, localized context labels
|
||||
separate them. Keep each registration and its original props; identical-looking
|
||||
status controls can have different scopes and must not be deduplicated by the
|
||||
host. Empty sections disappear. `MobileWorkspaceActionsSection` retains canvases
|
||||
and opts out of rendering plugin actions only for phone app navigation.
|
||||
|
||||
`NavigationMetrics` retains the status-bar preference gate and reuses
|
||||
`StatusSurfaceMetrics` after navigation. No new subscription, metric setting,
|
||||
or resource card is introduced. The phone scroller uses a 16px section gap;
|
||||
plugin controls wrap within its width. Main-toolbar host buttons retain a 44px
|
||||
minimum touch target without forcing labeled buttons into square icon geometry.
|
||||
Wider consumers of the existing listing menu and inline plugin slots retain
|
||||
their composition. The delivery record is the
|
||||
[coherent mobile plugin menu plan](../../../plans/mobile-plugin-menu-coherence/plan.md).
|
||||
|
||||
A saved sidebar layout retains visibility and the relative order of optional
|
||||
tools and plugin destinations. On phones, keep Home and quick actions before
|
||||
the embedded Tasks section, followed by those optional nodes; when Home is
|
||||
hidden, quick actions remain first. Home still covers all listing modes. Keep
|
||||
workspace/task plugin controls together after the configurable projection,
|
||||
followed by metrics and Utilities. Do not duplicate saved plugin destinations,
|
||||
Automations, Canvases, or Integrations in default sections, or persist this phone
|
||||
composition over the desktop layout. See the
|
||||
[sidebar customization design](sidebar-customization.md).
|
||||
|
||||
Add an explicit phone disclosure option to `MobileIntegrationsSection`; leave
|
||||
its default/wider behavior unchanged. Use a local initially-false expanded state,
|
||||
|
||||
Reference in New Issue
Block a user