feat(ui): unify mobile navigation and task access (#3830)

This commit is contained in:
José Almeida
2026-09-20 19:24:26 +01:00
committed by GitHub
parent 93a7cba937
commit bae7b8b7de
186 changed files with 5118 additions and 934 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ import { Dialog } from "@kandev/ui/dialog";
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions.
- Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus.
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements; read [confirmation guidance](components/confirmation/AGENTS.md) when adopting phone confirmation surfaces.
- Phone listing chrome uses `KanbanHeaderMobile` and `MobileListingContext` across Kanban, List, and Threads. Threads supplies its saved-view control and inline pagination in the title slot. Phone search, tools, and plugin actions live in `MobileListingMenuActions`; keep activity/connection cues on the persistent menu button and restore the actual opener unless focus is moving into a launched surface. Tablet/desktop composition stays separate.
- 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.
## Data Flow Pattern (Critical)
+14 -14
View File
@@ -39,21 +39,21 @@ function ThreadsPageHeader({
<KanbanHeader
workspaceId={workspaceId ?? undefined}
currentPage="threads"
mobileListingStatus={
<MobileThreadPagination
position={threads.findIndex((thread) => thread.taskId === activeMobileTaskId) + 1}
total={threads.length}
/>
}
taskListingControls={
<>
<ThreadsViewControls
candidates={query.candidates}
repositories={repositories}
admittedCount={query.admittedCandidates.length}
matchingCount={query.matchingCount + query.temporaryAdmissionCount}
hiddenCount={query.hiddenCount}
gridHeightFallback={gridHeightFallback}
/>
<MobileThreadPagination
position={threads.findIndex((thread) => thread.taskId === activeMobileTaskId) + 1}
total={threads.length}
/>
</>
<ThreadsViewControls
candidates={query.candidates}
repositories={repositories}
admittedCount={query.admittedCandidates.length}
matchingCount={query.matchingCount + query.temporaryAdmissionCount}
hiddenCount={query.hiddenCount}
gridHeightFallback={gridHeightFallback}
/>
}
/>
);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import Link from "@/components/routing/app-link";
import { Button } from "@kandev/ui/button";
import {
@@ -11,16 +11,21 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconPlugConnected } from "@tabler/icons-react";
import { IconChevronDown, IconChevronRight, IconPlugConnected } from "@tabler/icons-react";
import { DestinationRows } from "@/components/navigation/destination-rows";
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";
type MobileIntegrationsSectionProps = {
onNavigate: () => void;
showSetup?: boolean;
collapsible?: boolean;
};
const HOVER_CLOSE_DELAY_MS = 180;
const INTEGRATIONS_LABEL = "common:integrations";
/**
* Configured integration destinations, in manifest order. Ids, labels, hrefs and
@@ -73,7 +78,7 @@ export function IntegrationsMenu() {
variant="ghost"
size="icon-lg"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={t("common:integrations")}
aria-label={t(INTEGRATIONS_LABEL)}
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
@@ -86,7 +91,7 @@ export function IntegrationsMenu() {
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<DropdownMenuLabel>{t("common:integrations")}</DropdownMenuLabel>
<DropdownMenuLabel>{t(INTEGRATIONS_LABEL)}</DropdownMenuLabel>
{links.map((link) => {
const Icon = link.icon;
return (
@@ -136,20 +141,68 @@ export function IntegrationsTopbarLinks() {
* `IntegrationsSection`. Both come from the navigation manifest, so the two
* surfaces cannot drift apart.
*/
export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) {
export function MobileIntegrationsSection({
onNavigate,
showSetup = false,
collapsible = false,
}: MobileIntegrationsSectionProps) {
const { t } = useTranslation();
const destinations = useAppDestinations("mobileMenu", "integrations");
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
if (destinations.length === 0) return null;
if (destinations.length === 0 && !showSetup) return null;
return (
<div className="space-y-3">
<div className="text-sm font-medium">{t("common:integrations")}</div>
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="plugin-nav-item-"
/>
<div className={showSetup ? "space-y-3 border-t border-border pt-4" : "space-y-3"}>
{collapsible ? (
<Button
variant="ghost"
className="cursor-pointer h-11 w-full 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(INTEGRATIONS_LABEL)}
{expanded ? (
<IconChevronDown className="size-3.5 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 text-muted-foreground" />
)}
</Button>
) : (
<div className="text-sm font-medium">{t(INTEGRATIONS_LABEL)}</div>
)}
{(!collapsible || expanded) && (
<div id={bodyId} className="space-y-3">
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="plugin-nav-item-"
/>
{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>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { defaultState } from "@/lib/state/default-state";
import { MobileIntegrationsSection } from "./integrations-menu";
let workspaceId = "one";
let github = true;
vi.mock("@/components/state-provider", () => ({
useAppStore: (select: (state: unknown) => unknown) =>
select({
...defaultState,
workspaces: { activeId: workspaceId, items: [] },
}),
}));
vi.mock("@/hooks/use-nav-availability", () => ({
useNavAvailability: () => ({ github }),
}));
vi.mock("@/lib/routing/client-router", () => ({ usePathname: () => "/" }));
afterEach(cleanup);
beforeEach(() => {
workspaceId = "one";
github = true;
});
// @covers AC-UI-MOBILE-MENU-007.3
it("starts collapsed and reveals eligible links and settings on expansion", () => {
const navigate = vi.fn();
render(<MobileIntegrationsSection onNavigate={navigate} showSetup collapsible />);
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
const toggle = screen.getByRole("button", { name: "Integrations" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(screen.getByRole("link", { name: "GitHub" }));
expect(navigate).toHaveBeenCalledOnce();
fireEvent.click(toggle);
expect(screen.queryByTestId("mobile-integration-settings")).toBeNull();
});
// @covers AC-UI-MOBILE-MENU-007.4
it("resolves current workspace availability after changes while collapsed", () => {
const host = render(<MobileIntegrationsSection onNavigate={() => {}} showSetup collapsible />);
workspaceId = "two";
github = false;
host.rerender(<MobileIntegrationsSection onNavigate={() => {}} showSetup collapsible />);
fireEvent.click(screen.getByRole("button", { name: "Integrations" }));
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
expect(screen.getByTestId("mobile-integration-settings").getAttribute("href")).toBe(
"/settings/workspaces/two/integrations",
);
});
it("retains the default always-expanded presentation for other callers", () => {
render(<MobileIntegrationsSection onNavigate={() => {}} />);
expect(screen.getByRole("link", { name: "GitHub" })).not.toBeNull();
expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull();
});
@@ -5,6 +5,22 @@ import { StateProvider } from "@/components/state-provider";
import { KanbanHeaderMobile } from "./kanban-header-mobile";
import { pluginRegistry } from "@/lib/plugins/registry";
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true }),
}));
vi.mock("@/hooks/use-select-workspace", () => ({ useSelectWorkspace: () => vi.fn() }));
vi.mock("@/hooks/use-system-health-indicator", () => ({
useSystemHealthIndicator: () => ({
hasIssues: false,
issues: [],
dialogOpen: false,
openDialog: vi.fn(),
}),
}));
vi.mock("@/hooks/use-nav-availability", () => ({ useNavAvailability: () => ({}) }));
vi.mock("@/components/theme/app-theme", () => ({
useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }),
}));
vi.mock("@/components/page-topbar", () => ({
PageTopbar: ({
title,
@@ -28,8 +44,21 @@ vi.mock("@/components/page-topbar", () => ({
}));
vi.mock("./mobile-menu-sheet", () => ({
MobileMenuSheet: ({ open, pageActions }: { open: boolean; pageActions?: ReactNode }) =>
open ? <div role="dialog">{pageActions}</div> : null,
MobileMenuSheet: ({
open,
pageActions,
listingControls,
}: {
open: boolean;
pageActions?: ReactNode;
listingControls?: ReactNode;
}) =>
open ? (
<div role="dialog">
{pageActions}
{listingControls}
</div>
) : null,
}));
const launchers = vi.hoisted(() => ({
@@ -57,7 +86,7 @@ vi.mock("@/components/quick-chat/use-quick-chat-activity", () => ({
useQuickChatActivity: () => chat,
}));
const MENU = "mobile-topbar-menu";
const MENU = "app-nav-trigger";
const CHAT = "mobile-quick-chat-button";
const TERMINAL = "mobile-quick-terminal-button";
const CONTEXT = "mobile-topbar-page-context";
@@ -79,7 +108,22 @@ afterEach(() => {
function renderHeader(props: Partial<ComponentProps<typeof KanbanHeaderMobile>> = {}) {
return render(
<StateProvider>
<StateProvider
initialState={{
workspaces: {
activeId: props.workspaceId === undefined && "workspaceId" in props ? null : WORKSPACE,
items: [
{
id: WORKSPACE,
name: "Harbor",
owner_id: "user",
created_at: "2026-09-15T00:00:00Z",
updated_at: "2026-09-15T00:00:00Z",
},
],
},
}}
>
<KanbanHeaderMobile
title="Localized page title"
workspaceId={WORKSPACE}
@@ -135,6 +179,15 @@ describe("stateful phone menu plugins", () => {
});
describe("shared phone listing header", () => {
// @covers AC-UI-MOBILE-MENU-001.4
it("exposes view options independently of app navigation", () => {
renderHeader();
expect(screen.queryByRole("button", { name: "View options" })).toBeNull();
const options = screen.getByTestId(CONTEXT);
expect(options === screen.getByTestId("app-nav-trigger")).toBe(false);
fireEvent.click(options);
expect(screen.getByRole("dialog")).toBeTruthy();
});
// @covers AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5 AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7
it.each([
["kanban", "Kanban"],
@@ -151,11 +204,16 @@ describe("shared phone listing header", () => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("keeps the Threads-supplied view control in the title slot", () => {
renderHeader({ currentPage: "threads", taskListingControls: <button>Review view</button> });
it("opens Threads options from the mode title and includes its saved views", () => {
renderHeader({
currentPage: "threads",
taskListingControls: <button>Review view</button>,
mobileListingStatus: <span data-testid="deck-position">2/3</span>,
});
expect(screen.getByTestId("deck-position").closest("header")).not.toBeNull();
fireEvent.click(screen.getByTestId(CONTEXT));
const view = screen.getByRole("button", { name: "Review view" });
expect(view.closest("header")).not.toBeNull();
expect(screen.queryByTestId(CONTEXT)).toBeNull();
expect(view.closest("header")).toBeNull();
expect(screen.queryByTestId("mobile-topbar-action-strip")).toBeNull();
});
@@ -168,7 +226,7 @@ describe("shared phone listing header", () => {
expect(screen.queryByTestId(testId)).toBeNull();
openMenu();
fireEvent.click(screen.getByTestId(testId));
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByRole("dialog").getAttribute("data-state")).toBe("closed");
expect(launchers[launcher]).not.toHaveBeenCalled();
frame();
expect(launchers[launcher]).toHaveBeenCalledTimes(1);
@@ -185,13 +243,13 @@ describe("shared phone listing header", () => {
it("reveals search from the menu and clears its query on collapse", () => {
const onSearchChange = vi.fn();
renderHeader({ currentPage: "tasks", searchQuery: "Alpha", onSearchChange });
openMenu();
fireEvent.click(screen.getByTestId("mobile-topbar-page-context"));
expect(screen.getByTestId(SEARCH).getAttribute("aria-pressed")).toBe("false");
fireEvent.click(screen.getByTestId(SEARCH));
expect(screen.queryByRole("dialog")).toBeNull();
frame();
expect(onSearchChange).not.toHaveBeenCalled();
openMenu();
fireEvent.click(screen.getByTestId("mobile-topbar-page-context"));
expect(screen.getByTestId(SEARCH).getAttribute("aria-pressed")).toBe("true");
fireEvent.click(screen.getByTestId(SEARCH));
frame();
@@ -2,10 +2,11 @@
import { useRef, type MouseEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconAlertTriangle } from "@tabler/icons-react";
import { PageTopbar } from "@/components/page-topbar";
import { MobileMenuSheet } from "./mobile-menu-sheet";
import { MobileListingContext } from "./mobile-listing-context";
import { MobileListingMenuButton } from "./mobile-listing-menu-button";
import { AppNavSheet } from "@/components/navigation/app-nav-sheet";
import { MobileListingMenuActions } from "./mobile-listing-menu-actions";
import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
import { useAppStore } from "@/components/state-provider";
@@ -21,6 +22,7 @@ type KanbanHeaderMobileProps = {
isSearchLoading?: boolean;
tasksListOptions?: TasksListDisplayOptions;
taskListingControls?: ReactNode;
mobileListingStatus?: ReactNode;
};
const MODE_LABELS: Record<TaskListingPage, string> = {
@@ -39,6 +41,7 @@ export function KanbanHeaderMobile({
isSearchLoading = false,
tasksListOptions,
taskListingControls,
mobileListingStatus,
}: KanbanHeaderMobileProps) {
const { t } = useTranslation();
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
@@ -79,28 +82,27 @@ export function KanbanHeaderMobile({
title={title}
testId={currentPage === "threads" ? "threads-mobile-topbar" : undefined}
titleSlot={
currentPage === "threads" ? (
taskListingControls
) : (
<div className="flex min-w-0 items-center">
<MobileListingContext
context={workspaceLabel}
label={t(MODE_LABELS[currentPage])}
status={currentPage === "threads" ? <ThreadViewSyncStatus /> : undefined}
onClick={openMenu}
aria-haspopup="dialog"
aria-expanded={isMenuOpen}
data-testid="mobile-topbar-page-context"
/>
)
{mobileListingStatus}
</div>
}
className="h-14 min-h-14"
showStatusTrigger={false}
homeAffordance="none"
freeWidth="lead"
actions={
<MobileListingMenuButton workspaceId={workspaceId} open={isMenuOpen} onClick={openMenu} />
}
actions={<AppNavSheet />}
/>
<MobileMenuSheet
listingOnly
open={isMenuOpen}
onOpenChange={setMenuOpen}
onCloseAutoFocus={restoreMenuFocus}
@@ -110,8 +112,10 @@ export function KanbanHeaderMobile({
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
tasksListOptions={tasksListOptions}
listingControls={taskListingControls}
pageActions={
<MobileListingMenuActions
showWorkspaceActions={false}
workspaceId={workspaceId}
workspaceLabel={workspaceLabel}
currentPage={currentPage}
@@ -126,3 +130,19 @@ export function KanbanHeaderMobile({
</>
);
}
function ThreadViewSyncStatus() {
const { t } = useTranslation();
const error = useAppStore((state) => state.threadViews.syncError);
if (!error) return null;
return (
<span
role="status"
className="shrink-0 text-destructive"
data-testid="threads-mobile-view-sync-status"
>
<IconAlertTriangle aria-hidden="true" className="size-4" />
<span className="sr-only">{t("threads:failedToSyncViews")}</span>
</span>
);
}
@@ -50,6 +50,7 @@ type KanbanHeaderProps = {
isSearchLoading?: boolean;
tasksListOptions?: TasksListDisplayOptions;
taskListingControls?: ReactNode;
mobileListingStatus?: ReactNode;
};
type ViewToggleItem = {
@@ -411,6 +412,7 @@ export function KanbanHeader({
isSearchLoading = false,
tasksListOptions,
taskListingControls,
mobileListingStatus,
}: KanbanHeaderProps) {
const { t } = useTranslation();
const { isMobile, isTablet } = useResponsiveBreakpoint();
@@ -437,6 +439,7 @@ export function KanbanHeader({
currentPage={currentPage}
title={title}
workspaceLabel={workspaceLabel}
mobileListingStatus={mobileListingStatus}
taskListingControls={taskListingControls}
{...sharedSearch}
tasksListOptions={tasksListOptions}
@@ -1,14 +1,15 @@
"use client";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@kandev/ui/button";
import { IconChevronDown } from "@tabler/icons-react";
export function MobileListingContext({
context,
label,
status,
...props
}: ComponentProps<typeof Button> & { context: string; label: string }) {
}: ComponentProps<typeof Button> & { context: string; label: string; status?: ReactNode }) {
return (
<Button
{...props}
@@ -23,6 +24,7 @@ export function MobileListingContext({
<span className="flex min-w-0 items-center gap-1.5 text-sm leading-5">
<span className="truncate">{label}</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{status}
</span>
</span>
</Button>
@@ -11,6 +11,7 @@ import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher";
import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions";
import { useAppStore } from "@/components/state-provider";
import { StatusSurfaceMetrics } from "@/components/system-metrics/status-surface-metrics";
import { cn } from "@/lib/utils";
import type { TaskListingPage } from "@/lib/task-listing/view-navigation";
export function MobileListingMenuActions({
@@ -22,7 +23,11 @@ export function MobileListingMenuActions({
onToggleSearch,
isSearchOpen,
returnFocusRef,
showWorkspaceActions = true,
showQuickActions = true,
}: {
showWorkspaceActions?: boolean;
showQuickActions?: boolean;
workspaceId?: string;
workspaceLabel: string;
currentPage: TaskListingPage;
@@ -33,9 +38,6 @@ export function MobileListingMenuActions({
returnFocusRef: RefObject<HTMLElement | null>;
}) {
const { t } = useTranslation();
const { activity, label } = useQuickChatActivity(workspaceId);
const openQuickChat = useQuickChatLauncher(workspaceId, "chat", { returnFocusRef });
const openQuickTerminal = useQuickTerminalLauncher(workspaceId, { returnFocusRef });
const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled);
function launch(action: () => void, restoreFocus = false) {
@@ -44,7 +46,12 @@ export function MobileListingMenuActions({
}
return (
<div className="flex flex-col gap-3 [&>div:empty]:hidden [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11">
<div
className={cn(
showQuickActions ? "flex flex-col gap-3" : "contents",
"[&>div:empty]:hidden [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11",
)}
>
{onToggleSearch && (
<Button
variant={isSearchOpen ? "secondary" : "outline"}
@@ -57,41 +64,22 @@ export function MobileListingMenuActions({
{t("kanban:searchTasks")}
</Button>
)}
{workspaceId && (
<>
<Button
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
aria-label={label}
data-testid="mobile-quick-chat-button"
data-legacy-testid="threads-menu-quick-chat"
onClick={() => launch(openQuickChat)}
>
<span className="relative flex">
<IconMessageCircle className="h-4 w-4" />
<QuickChatActivityIndicator activity={activity} />
</span>
{t("sidebar:quickChat")}
</Button>
<Button
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
data-testid="mobile-quick-terminal-button"
data-legacy-testid="threads-menu-quick-terminal"
onClick={() => launch(openQuickTerminal)}
>
<IconTerminal2 className="h-4 w-4" />
{t("sidebar:quickTerminal")}
</Button>
</>
{showWorkspaceActions && showQuickActions && workspaceId && (
<MobileQuickActions
workspaceId={workspaceId}
closeMenu={closeMenu}
returnFocusRef={returnFocusRef}
/>
)}
<MainTopBarPluginActions
workspaceId={workspaceId}
workspaceLabel={workspaceLabel}
currentPage={currentPage}
presentation="mobile"
/>
{!statusBarEnabled && (
{showWorkspaceActions && (
<MainTopBarPluginActions
workspaceId={workspaceId}
workspaceLabel={workspaceLabel}
currentPage={currentPage}
presentation="mobile"
/>
)}
{showWorkspaceActions && !statusBarEnabled && (
<StatusSurfaceMetrics
presentation="mobile-drawer"
density="compact"
@@ -102,3 +90,52 @@ export function MobileListingMenuActions({
</div>
);
}
export function MobileQuickActions({
workspaceId,
closeMenu,
returnFocusRef,
inline = false,
}: {
workspaceId: string;
closeMenu: (restoreFocus?: boolean) => void;
returnFocusRef: RefObject<HTMLElement | null>;
inline?: boolean;
}) {
const { t } = useTranslation();
const { activity, label } = useQuickChatActivity(workspaceId);
const openQuickChat = useQuickChatLauncher(workspaceId, "chat", { returnFocusRef });
const openQuickTerminal = useQuickTerminalLauncher(workspaceId, { returnFocusRef });
function launch(action: () => void) {
closeMenu(false);
requestAnimationFrame(action);
}
return (
<div className={inline ? "grid grid-cols-2 gap-3" : "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"
aria-label={label}
data-testid="mobile-quick-chat-button"
data-legacy-testid="threads-menu-quick-chat"
onClick={() => launch(openQuickChat)}
>
<span className="relative flex">
<IconMessageCircle className="h-4 w-4" />
<QuickChatActivityIndicator activity={activity} />
</span>
{t("sidebar:quickChat")}
</Button>
<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"
data-testid="mobile-quick-terminal-button"
data-legacy-testid="threads-menu-quick-terminal"
onClick={() => launch(openQuickTerminal)}
>
<IconTerminal2 className="h-4 w-4" />
{t("sidebar:quickTerminal")}
</Button>
</div>
);
}
@@ -1,61 +0,0 @@
"use client";
import type { MouseEventHandler } from "react";
import { Button } from "@kandev/ui/button";
import { IconMenu2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
import { cn } from "@/lib/utils";
export function MobileListingMenuButton({
workspaceId,
open,
onClick,
}: {
workspaceId?: string;
open: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
}) {
const { t } = useTranslation();
const { issueSeverity } = useAppStatusDrawer();
const issueDetails = useConnectionIssueCopy(issueSeverity);
const { activity, label } = useQuickChatActivity(workspaceId);
return (
<Button
variant="ghost"
size="icon-lg"
onClick={onClick}
className={cn(
"relative !size-11 shrink-0 cursor-pointer",
issueSeverity === "lost" && "text-destructive",
issueSeverity === "unstable" && "text-amber-500",
)}
aria-label={
issueDetails
? t("kanban:openMenuWithStatus", { description: issueDetails.description })
: t("kanban:openMenu")
}
aria-description={activity ? label : undefined}
aria-haspopup="dialog"
aria-expanded={open}
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
data-testid="mobile-topbar-menu"
>
<IconMenu2 className="h-4 w-4" />
{issueDetails && (
<span
className={cn(
"absolute right-1.5 top-1.5 size-2 rounded-full ring-2 ring-background",
issueDetails.dotClass,
)}
aria-hidden="true"
/>
)}
<QuickChatActivityIndicator activity={activity} className="right-1.5 bottom-1.5 top-auto" />
</Button>
);
}
@@ -0,0 +1,4 @@
import { createContext, useContext } from "react";
export const MobileListingOptionsContext = createContext<{ close: () => void } | null>(null);
export const useMobileListingOptions = () => useContext(MobileListingOptionsContext);
@@ -1,4 +1,9 @@
"use client";
import { MobileListingOptionsContext } from "./mobile-listing-options-context";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
import { type ReactNode, type RefObject } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
@@ -37,6 +42,8 @@ export type MobileMenuSheetProps = {
isSearchLoading?: boolean;
tasksListOptions?: TasksListDisplayOptions;
pageActions?: ReactNode;
listingOnly?: boolean;
listingControls?: ReactNode;
};
function MobileSearchSection({
@@ -140,6 +147,7 @@ function MobileViewSection({
function ResponsiveMenuSurface({
isMobile,
listingOnly,
open,
onOpenChange,
contentRef,
@@ -148,6 +156,7 @@ function ResponsiveMenuSurface({
children,
}: {
isMobile: boolean;
listingOnly?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
contentRef: RefObject<HTMLDivElement | null>;
@@ -158,30 +167,39 @@ function ResponsiveMenuSurface({
const { t } = useTranslation();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
>
<div
data-testid="mobile-home-menu-card"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>{t("kanban:menu")}</DrawerTitle>
</DrawerHeader>
<div
data-testid="mobile-home-menu-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom,0px)]"
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
{...contentProps}
ref={contentRef}
tabIndex={-1}
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
>
{children}
</div>
</div>
</DrawerContent>
</Drawer>
<MobileConfirmationHostBody>
<div
data-testid="mobile-home-menu-card"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>
{t(listingOnly ? "common:viewOptions" : "kanban:menu")}
</DrawerTitle>
</DrawerHeader>
<div
data-testid="mobile-home-menu-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom,0px)]"
>
{children}
</div>
</div>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
@@ -218,6 +236,8 @@ function MobileMenuContent({
displayOptions,
navControls,
pageActions,
listingOnly,
listingControls,
}: Pick<
MobileMenuSheetProps,
| "workspaceId"
@@ -226,6 +246,8 @@ function MobileMenuContent({
| "isSearchLoading"
| "onOpenChange"
| "pageActions"
| "listingOnly"
| "listingControls"
> & {
isMobile: boolean;
open: boolean;
@@ -244,21 +266,28 @@ function MobileMenuContent({
isSearchLoading={isSearchLoading ?? false}
/>
)}
<MobileWorkspaceSection onOpenChange={onOpenChange} />
{!listingOnly && <MobileWorkspaceSection onOpenChange={onOpenChange} />}
<MobileViewSection
viewValue={viewValue}
onViewChange={onViewChange}
showPipeline={showPipeline}
/>
<MobileDisplayOptions open={open} {...displayOptions} />
{listingControls && (
<MobileListingOptionsContext.Provider value={{ close: () => onOpenChange(false) }}>
{listingControls}
</MobileListingOptionsContext.Provider>
)}
{/* Phone Home lives in this menu; the View toggle owns listing modes. */}
<AppNavSections
onNavigate={() => onOpenChange(false)}
omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]}
omitDestinations={["tasks", "threads"]}
workspaceActions={<MobileWorkspaceActionsSection workspaceId={workspaceId} />}
controls={navControls}
/>
{!listingOnly && (
<AppNavSections
onNavigate={() => onOpenChange(false)}
omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]}
omitDestinations={["tasks", "threads"]}
workspaceActions={<MobileWorkspaceActionsSection workspaceId={workspaceId} />}
controls={navControls}
/>
)}
</div>
);
}
@@ -274,6 +303,8 @@ export function MobileMenuSheet({
isSearchLoading = false,
tasksListOptions,
pageActions,
listingOnly = false,
listingControls,
}: MobileMenuSheetProps) {
const navControls = useAppNavDialogs(() => onOpenChange(false));
const { contentRef, isMobile, viewValue, handleViewChange, displayOptions, focusMenu } =
@@ -299,6 +330,8 @@ export function MobileMenuSheet({
displayOptions={displayOptions}
navControls={navControls}
pageActions={pageActions}
listingOnly={listingOnly}
listingControls={listingControls}
/>
);
}
@@ -314,6 +347,8 @@ function MobileMenuRender(
| "onSearchChange"
| "isSearchLoading"
| "pageActions"
| "listingOnly"
| "listingControls"
> & {
isMobile: boolean;
contentRef: RefObject<HTMLDivElement | null>;
@@ -50,8 +50,11 @@ export type AppNavDialogControls = {
dialogs: ReactNode;
};
export function useAppNavDialogs(closeMenu: () => void): AppNavDialogControls {
const taskViews = useTaskViewNavigation(closeMenu);
export function useAppNavDialogs(
closeMenu: () => void,
onOpenTaskViews?: () => void,
): AppNavDialogControls {
const taskViews = useTaskViewNavigation(closeMenu, onOpenTaskViews);
const router = useRouter();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const health = useSystemHealthIndicator();
@@ -121,6 +124,9 @@ type AppNavSectionsProps = {
omitDestinations?: string[];
/** Optional workspace-scoped plugin actions for the current phone surface. */
workspaceActions?: ReactNode;
afterPrimary?: ReactNode;
quickActions?: ReactNode;
phoneNavigation?: boolean;
controls: AppNavDialogControls;
};
@@ -135,6 +141,9 @@ export function AppNavSections({
omitSections = [],
omitDestinations = [],
workspaceActions,
afterPrimary,
quickActions,
phoneNavigation = false,
controls,
}: AppNavSectionsProps) {
const { t } = useTranslation();
@@ -142,16 +151,22 @@ export function AppNavSections({
const hasSavedSidebarLayout = useHasSavedSidebarLayout();
return (
<>
{workspaceActions}
{hasSavedSidebarLayout ? (
<MobileSidebarLayoutNavigation
onNavigate={onNavigate}
omitSections={omit}
omitDestinations={omitDestinations}
quickActions={quickActions}
homeCoversListings={phoneNavigation}
/>
) : (
!omit.has("primary") && (
<PrimaryNavSection onNavigate={onNavigate} omitDestinations={omitDestinations} />
<PrimaryNavSection
onNavigate={onNavigate}
omitDestinations={omitDestinations}
phoneNavigation={phoneNavigation}
quickActions={quickActions}
/>
)
)}
{controls.openTaskViews && (
@@ -164,13 +179,23 @@ export function AppNavSections({
{t("sidebar:taskViews")}
</Button>
)}
{afterPrimary}
{workspaceActions}
{!hasSavedSidebarLayout && !omit.has("plugins") && (
<MobilePluginNavSection onNavigate={onNavigate} />
)}
{!hasSavedSidebarLayout && !omit.has("integrations") && (
<MobileIntegrationsSection onNavigate={onNavigate} />
<MobileIntegrationsSection
onNavigate={onNavigate}
showSetup={phoneNavigation}
collapsible={phoneNavigation}
/>
)}
<UtilityNavSection onNavigate={onNavigate} controls={controls} />
<UtilityNavSection
onNavigate={onNavigate}
controls={controls}
phoneNavigation={phoneNavigation}
/>
</>
);
}
@@ -178,9 +203,13 @@ export function AppNavSections({
function PrimaryNavSection({
onNavigate,
omitDestinations,
phoneNavigation,
quickActions,
}: {
onNavigate: () => void;
omitDestinations: string[];
phoneNavigation: boolean;
quickActions?: ReactNode;
}) {
const all = useStaticDestinations("mobileMenu", "primary");
const destinations = all.filter((destination) => !omitDestinations.includes(destination.id));
@@ -190,8 +219,10 @@ function PrimaryNavSection({
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
homeCoversListings={phoneNavigation}
className="gap-3 px-3 text-sm"
/>
{quickActions}
</div>
);
}
@@ -199,16 +230,29 @@ function PrimaryNavSection({
function UtilityNavSection({
onNavigate,
controls,
phoneNavigation,
}: {
phoneNavigation: boolean;
onNavigate: () => void;
controls: AppNavDialogControls;
}) {
const { t } = useTranslation();
const destinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS);
const allDestinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS);
const destinations = phoneNavigation
? [
...allDestinations.filter((item) => item.id === "settings"),
...allDestinations.filter((item) => item.id !== "settings"),
]
: allDestinations;
const { resolvedTheme, setTheme } = useTheme();
const utilityRowClass = "h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm";
return (
<div className="mt-auto flex flex-col gap-3 border-t border-border pt-4">
<div
className={cn(
"flex flex-col gap-3 border-t border-border pt-4",
!phoneNavigation && "mt-auto",
)}
>
<div className="text-sm font-medium">{t("common:utilities")}</div>
<StatusRow closeMenu={onNavigate} />
<DestinationRows
@@ -12,11 +12,16 @@ const mocks = vi.hoisted(() => ({
setTheme: vi.fn(),
}));
let pathname = "/settings";
let inOffice = false;
let resolvedTheme: "light" | "dark" = "light";
const THEME_TOGGLE_TEST_ID = "mobile-theme-toggle-button";
const NAV_TRIGGER = "app-nav-trigger";
const ARIA_LABEL = "aria-label";
const state = {
...defaultState,
features: { canvases: false },
workspaces: {
activeId: "ws-1" as string | null,
@@ -26,6 +31,8 @@ const state = {
};
beforeEach(() => {
pathname = "/settings";
inOffice = false;
state.userSettings = { ...defaultState.userSettings };
state.workspaces.items[0].office_workflow_id = null;
});
@@ -44,15 +51,22 @@ let workspaceActionsRegistrations: Array<{
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mocks.routerPush }),
usePathname: () => "/settings",
usePathname: () => pathname,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
}));
vi.mock("@/hooks/use-select-workspace", () => ({ useSelectWorkspace: () => vi.fn() }));
vi.mock("@/hooks/use-quick-chat-launcher", () => ({ useQuickChatLauncher: () => vi.fn() }));
vi.mock("@/hooks/use-quick-terminal-launcher", () => ({ useQuickTerminalLauncher: () => vi.fn() }));
vi.mock("@/components/quick-chat/use-quick-chat-activity", () => ({
useQuickChatActivity: () => ({ activity: null, label: "Quick Chat" }),
}));
vi.mock("@/hooks/use-in-office", () => ({
useInOffice: () => false,
useInOffice: () => inOffice,
}));
type NavRegistration = {
@@ -145,7 +159,34 @@ function SectionsHost({
);
}
function verifyMenuOrder() {
render(<AppNavSheet pageNav={<span data-testid="page-nav" />} />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
const precedes = (first: Element, second: Element) =>
Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(
precedes(
screen.getByRole("link", { name: "Home" }),
screen.getByTestId("mobile-quick-chat-button"),
),
).toBe(true);
expect(
precedes(screen.getByTestId("mobile-quick-terminal-button"), screen.getByTestId("page-nav")),
).toBe(true);
expect(
precedes(
screen.getByRole("link", { name: "Settings" }),
screen.getByRole("link", { name: "Stats" }),
),
).toBe(true);
}
describe("AppNavSheet", () => {
// @covers AC-UI-MOBILE-MENU-007.1 AC-UI-MOBILE-MENU-007.2
it("puts quick actions before local navigation and Settings before Stats", () => {
verifyMenuOrder();
});
it("offers task views for Kanban, not Office workspaces", () => {
const host = render(<SectionsHost />);
expect(screen.getByRole("button", { name: "Task views" })).not.toBeNull();
@@ -165,20 +206,56 @@ describe("AppNavSheet", () => {
it("opens from the trigger and offers the manifest destinations plus pageNav", () => {
render(<AppNavSheet pageNav={<span data-testid="page-nav" />} />);
fireEvent.click(screen.getByTestId("app-nav-trigger"));
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
const sheet = screen.getByTestId("app-nav-sheet");
expect(sheet).not.toBeNull();
expect(screen.getByTestId("page-nav")).not.toBeNull();
for (const label of ["Home", "Tasks", "Stats", "Settings"]) {
for (const label of ["Home", "Stats", "Settings"]) {
expect(screen.getByRole("link", { name: label })).not.toBeNull();
}
// pageNav renders above the global sections.
const nav = sheet.querySelector("nav");
const pageNavIndex = [...(nav?.children ?? [])].findIndex((el) =>
el.matches('[data-testid="page-nav"]'),
// Global destinations have a stable position before local navigation.
expect(
screen
.getByTestId("app-nav-primary")
.compareDocumentPosition(screen.getByTestId("page-nav")) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
// @covers AC-UI-MOBILE-MENU-001.1, AC-UI-MOBILE-MENU-001.5
it("opens phone navigation in a bottom drawer", () => {
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByTestId("app-nav-sheet").getAttribute("data-vaul-drawer-direction")).toBe(
"bottom",
);
expect(pageNavIndex).toBe(0);
});
// @covers AC-UI-MOBILE-MENU-001.2
it("identifies the active destination", () => {
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByRole("link", { name: "Settings" }).getAttribute("aria-current")).toBe(
"page",
);
expect(screen.queryByRole("link", { name: "Tasks" })).toBeNull();
});
it.each(["/", "/tasks", "/threads"])("marks Home current for %s", (path) => {
pathname = path;
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByRole("link", { name: "Home" }).getAttribute("aria-current")).toBe("page");
});
it("does not offer Kanban destinations from an Office workspace on a shared page", () => {
inOffice = true;
state.workspaces.items[0].office_workflow_id = "office-workflow";
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.queryByRole("link", { name: "Tasks" })).toBeNull();
expect(screen.queryByRole("link", { name: "Threads" })).toBeNull();
expect(screen.queryByRole("button", { name: "Task views" })).toBeNull();
});
// @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4
@@ -189,7 +266,7 @@ describe("AppNavSheet", () => {
state.userSettings.startupPage = startupPage;
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId("app-nav-trigger"));
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByRole("link", { name: "Home" }).getAttribute("href")).toBe(href);
});
@@ -208,7 +285,7 @@ describe("AppNavSheet", () => {
];
render(<AppNavSheet />);
fireEvent.click(screen.getByTestId("app-nav-trigger"));
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
expect(screen.getByTestId("mobile-workspace-action")).not.toBeNull();
expect(captured).toEqual({ workspaceId: "ws-1", presentation: "mobile" });
+227 -48
View File
@@ -1,73 +1,252 @@
"use client";
import { useState, type MouseEvent, type ReactNode } from "react";
import { useArchivedTaskState } from "@/components/task/task-archived-context";
import { useOptionalPortForwardingVisibility } from "@/components/task/port-forwarding-visibility-provider";
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet";
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 { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useInOffice } from "@/hooks/use-in-office";
import { usePathname } from "@/lib/routing/client-router";
import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation";
import { AppNavSections, useAppNavDialogs } from "./app-nav-sections";
import { MobileAutomationsSection } from "./mobile-automations-section";
import { AppNavTrigger } from "./app-nav-trigger";
import { AppNavSurface } from "./app-nav-surface";
import { useMobileTaskNavigationOutlet } from "./mobile-task-navigation-provider";
import { useWorkbenchTaskSelection } from "@/components/task/mobile/task-sheet-selection-context";
import { useRouter } from "@/lib/routing/client-router";
import { linkToTask, replaceTaskUrl } from "@/lib/links";
type AppNavSheetProps = {
/**
* In-section navigation rendered above the global sections — the settings
* tree on /settings, Office's sections on /office. Phone users get the
* page's own nav plus the app-wide destinations in one place.
*/
pageNav?: ReactNode | ((close: () => void) => ReactNode);
/** Manifest destination ids `pageNav` already covers. See `AppNavSections`. */
omitDestinations?: string[];
/** Reuse a workbench's existing task picker and selection controller. */
onOpenTaskViews?: () => void;
};
/**
* The app navigation sheet behind every `PageShell` hamburger: one trigger,
* one left-side sheet, one meaning at every width below `md`. Replaces the
* per-surface menus that each hand-picked a different subset of destinations.
*/
export function AppNavSheet({ pageNav, omitDestinations }: AppNavSheetProps) {
const { t } = useTranslation();
/** Shared phone app navigation; wider page shells retain their side sheet. */
export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppNavSheetProps) {
const { isMobile } = useResponsiveBreakpoint();
const pathname = usePathname();
const inOffice = useInOffice();
const workspace = useAppStore((s) =>
s.workspaces.items.find((w) => w.id === s.workspaces.activeId),
);
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
const controls = useAppNavDialogs(close);
const opener = useRef<HTMLButtonElement | null>(null);
const restoreFocus = useRef(true);
const close = useCallback(() => setOpen(false), []);
const controls = useAppNavDialogs(close, onOpenTaskViews);
const renderedPageNav = typeof pageNav === "function" ? pageNav(close) : pageNav;
// pageNav trees (SettingsTree, Office sections) render plain links that
// don't know about this sheet; close on any link click so navigation isn't
// hidden behind an open overlay.
const currentPage = listingPageForPath(pathname);
const closeOnLinkClick = (event: MouseEvent<HTMLElement>) => {
if (event.target instanceof Element && event.target.closest("a[href]")) close();
};
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<AppNavTrigger />
</SheetTrigger>
<SheetContent
onCloseAutoFocus={controls.onMenuCloseAutoFocus}
side="left"
className="flex w-80 max-w-[85vw] flex-col overflow-hidden p-0"
data-testid="app-nav-sheet"
data-legacy-testid="settings-mobile-menu"
<AppNavSurface
isMobile={isMobile}
open={open}
onOpenChange={(next) => {
if (next) restoreFocus.current = true;
setOpen(next);
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (restoreFocus.current && opener.current?.isConnected)
opener.current.focus({ preventScroll: true });
controls.onMenuCloseAutoFocus?.(event);
}}
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))]"
onClick={closeOnLinkClick}
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle>{t("common:menu")}</SheetTitle>
</SheetHeader>
<nav
className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain p-4"
onClick={closeOnLinkClick}
>
{renderedPageNav}
<AppNavSections
onNavigate={close}
omitDestinations={omitDestinations}
workspaceActions={<MobileWorkspaceActionsSection />}
controls={controls}
/>
</nav>
</SheetContent>
</Sheet>
{isMobile && <NavigationWorkspacePicker close={close} />}
{!isMobile && renderedPageNav}
<AppNavSections
onNavigate={close}
phoneNavigation={isMobile}
omitDestinations={navigationOmissions(isMobile, omitDestinations)}
quickActions={
<NavigationQuickActions
isMobile={isMobile}
workspaceId={workspace?.id}
returnFocusRef={opener}
closeMenu={(focus = false) => {
restoreFocus.current = focus;
close();
}}
/>
}
afterPrimary={
isMobile ? (
<MobileNavigationExtras
localNav={renderedPageNav}
workspaceId={workspace?.id}
showTasks={open && !inOffice}
close={close}
/>
) : undefined
}
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 />
<NavigationAutomations
isMobile={isMobile}
open={open}
inOffice={inOffice}
workspaceId={workspace?.id}
close={close}
/>
</>
}
controls={isMobile ? { ...controls, openTaskViews: undefined } : controls}
/>
</nav>
</AppNavSurface>
{controls.dialogs}
</>
);
}
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>
<AppSidebarWorkspacePicker
modal={false}
onActionComplete={close}
triggerClassName="min-h-11 w-full flex-none"
triggerTestId="mobile-workspace-trigger"
chevronTestId="mobile-workspace-trigger-chevron"
itemTestIdPrefix="mobile-workspace-item"
contentClassName="w-80 max-w-[calc(100vw-2rem)]"
/>
</section>
);
}
function NavigationQuickActions({
isMobile,
workspaceId,
...props
}: {
isMobile: boolean;
workspaceId?: string;
} & Omit<Parameters<typeof MobileQuickActions>[0], "workspaceId">) {
return isMobile && workspaceId ? (
<MobileQuickActions {...props} workspaceId={workspaceId} inline />
) : null;
}
function MobileNavigationExtras({
localNav,
workspaceId,
showTasks,
close,
}: {
localNav: ReactNode;
workspaceId?: string;
showTasks: boolean;
close: () => void;
}) {
const { t } = useTranslation();
return (
<>
{localNav && (
<section className="flex flex-col gap-3" aria-label={t("common:pageNavigation")}>
<h3 className="text-sm font-medium">{t("common:pageNavigation")}</h3>
{localNav}
</section>
)}
{showTasks && workspaceId && <MobileTaskOutlet key={workspaceId} close={close} />}
</>
);
}
function listingPageForPath(pathname: string) {
if (pathname === "/tasks") return "tasks";
if (pathname === "/threads") return "threads";
return "kanban";
}
function MobileTaskOutlet({ close }: { close: () => void }) {
const register = useMobileTaskNavigationOutlet();
const archivedState = useArchivedTaskState();
const portForwarding = useOptionalPortForwardingVisibility();
const selection = useWorkbenchTaskSelection();
const router = useRouter();
const element = useRef<HTMLDivElement>(null);
const navigate = useCallback(
(id: string) => {
if (selection) replaceTaskUrl(id);
else router.push(linkToTask(id));
},
[router, selection],
);
useEffect(() => {
if (element.current)
register({
element: element.current,
close,
navigate,
selection,
archivedState,
portForwarding,
});
return () => register(null);
}, [register, close, navigate, selection, archivedState, portForwarding]);
return <section ref={element} data-testid="mobile-navigation-tasks" />;
}
function NavigationAutomations({
isMobile,
open,
inOffice,
workspaceId,
close,
}: {
isMobile: boolean;
open: boolean;
inOffice: boolean;
workspaceId?: string;
close: () => void;
}) {
const hasSavedSidebarLayout = useHasSavedSidebarLayout();
if (!isMobile || !open || inOffice || !workspaceId || hasSavedSidebarLayout) return null;
return (
<MobileAutomationsSection key={workspaceId} workspaceId={workspaceId} onNavigate={close} />
);
}
function navigationOmissions(isMobile: boolean, omitted: string[] = []) {
return isMobile ? [...omitted, "tasks", "threads"] : omitted;
}
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from "@kandev/ui/drawer";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet";
export function AppNavSurface({
isMobile,
open,
onOpenChange,
onCloseAutoFocus,
trigger,
children,
}: {
isMobile: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onCloseAutoFocus: (event: Event) => void;
trigger: ReactNode;
children: ReactNode;
}) {
const { t } = useTranslation();
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent
data-testid="app-nav-sheet"
data-legacy-testid="mobile-home-menu-card"
aria-describedby={undefined}
onCloseAutoFocus={onCloseAutoFocus}
onOpenAutoFocus={(event) => {
event.preventDefault();
(event.currentTarget as HTMLElement | null)?.focus();
}}
tabIndex={-1}
className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background shadow-2xl shadow-black/20">
<DrawerHeader className="shrink-0 !flex-row items-center justify-between border-b border-border/70 px-4 py-1 text-left">
<DrawerTitle>{t("common:menu")}</DrawerTitle>
<DrawerClose asChild>
<Button
variant="ghost"
size="icon"
className="size-11 cursor-pointer"
aria-label={t("common:close")}
>
<IconX className="size-4" />
</Button>
</DrawerClose>
</DrawerHeader>
{children}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
side="left"
aria-describedby={undefined}
className="flex w-80 max-w-[85vw] flex-col overflow-hidden p-0"
data-testid="app-nav-sheet"
data-legacy-testid="settings-mobile-menu"
>
<SheetHeader className="border-b px-4 py-3 text-left">
<SheetTitle>{t("common:menu")}</SheetTitle>
</SheetHeader>
{children}
</SheetContent>
</Sheet>
);
}
@@ -6,6 +6,9 @@ import { IconMenu2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item";
import { useAppStore } from "@/components/state-provider";
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator";
import { cn } from "@/lib/utils";
type AppNavTriggerProps = ComponentProps<typeof Button>;
@@ -23,6 +26,8 @@ export const AppNavTrigger = forwardRef<HTMLButtonElement, AppNavTriggerProps>(
const { t } = useTranslation();
const { issueSeverity } = useAppStatusDrawer();
const issue = useConnectionIssueCopy(issueSeverity);
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const { activity, label } = useQuickChatActivity(workspaceId ?? undefined);
return (
<Button
ref={ref}
@@ -41,10 +46,12 @@ export const AppNavTrigger = forwardRef<HTMLButtonElement, AppNavTriggerProps>(
? t("common:openNavigationMenuWithConnectionIssue", { description: issue.description })
: t("common:openNavigationMenu")
}
aria-description={activity ? label : undefined}
data-testid="app-nav-trigger"
data-legacy-testid="settings-mobile-menu-button"
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
>
<QuickChatActivityIndicator activity={activity} className="right-1.5 bottom-1.5 top-auto" />
<IconMenu2 className="h-4 w-4" />
{issue && (
<span
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { DestinationRows } from "./destination-rows";
import type { ResolvedDestination } from "@/lib/navigation/types";
let pathname = "/";
vi.mock("@/lib/routing/client-router", () => ({
usePathname: () => "/",
usePathname: () => pathname,
}));
function Glyph({ className }: { className?: string }) {
@@ -41,7 +43,26 @@ function renderRows(destinations: ResolvedDestination[], pluginTestIdPrefix?: st
}
describe("DestinationRows", () => {
afterEach(() => cleanup());
afterEach(() => {
cleanup();
pathname = "/";
});
it("highlights the canonical route rather than both Home and its configured target", () => {
pathname = "/threads";
renderRows([
{ ...STATS, id: "home", label: "Home", href: "/threads?workspace=ws-1" },
{ ...STATS, id: "threads", label: "Threads", href: "/threads?workspace=ws-1" },
]);
expect(screen.getByRole("link", { name: "Threads" }).getAttribute("aria-current")).toBe("page");
expect(screen.getByRole("link", { name: "Home" }).getAttribute("aria-current")).toBeNull();
});
it("highlights Home on the Office home route", () => {
pathname = "/office";
renderRows([{ ...STATS, id: "home", label: "Home", href: "/office?workspaceId=ws" }]);
expect(screen.getByRole("link", { name: "Home" }).getAttribute("aria-current")).toBe("page");
});
it("renders a labelled link per destination", () => {
renderRows([STATS, PLUGIN_PAGE]);
@@ -3,6 +3,7 @@
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import type { ResolvedDestination } from "@/lib/navigation/types";
import { usePathname } from "@/lib/routing/client-router";
import { cn } from "@/lib/utils";
type DestinationRowsProps = {
@@ -18,6 +19,7 @@ type DestinationRowsProps = {
pluginTestIdPrefix?: string;
/** Extra classes so a surface can keep its own row spacing. */
className?: string;
homeCoversListings?: boolean;
};
/**
@@ -31,6 +33,7 @@ export function DestinationRows({
onNavigate,
pluginTestIdPrefix,
className,
homeCoversListings,
}: DestinationRowsProps) {
return (
<>
@@ -38,6 +41,7 @@ export function DestinationRows({
<DestinationRow
key={destination.id}
destination={destination}
homeCoversListings={homeCoversListings}
onNavigate={onNavigate}
{...(pluginTestIdPrefix ? { pluginTestIdPrefix } : {})}
{...(className ? { className } : {})}
@@ -52,6 +56,7 @@ type DestinationRowProps = {
onNavigate: () => void;
pluginTestIdPrefix?: string;
className?: string;
homeCoversListings?: boolean;
};
export function DestinationRow({
@@ -59,8 +64,21 @@ export function DestinationRow({
onNavigate,
pluginTestIdPrefix,
className,
homeCoversListings,
}: 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));
}
// 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 =
@@ -72,9 +90,18 @@ export function DestinationRow({
<Button
asChild
variant="outline"
className={cn("h-11 w-full cursor-pointer justify-start gap-2", className)}
className={cn(
"h-11 w-full cursor-pointer justify-start gap-2",
current && "border-primary/40 bg-accent",
className,
)}
>
<Link href={destination.href} onClick={onNavigate} data-testid={testId}>
<Link
href={destination.href}
onClick={onNavigate}
data-testid={testId}
aria-current={current ? "page" : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-left">{destination.label}</span>
</Link>
@@ -0,0 +1,59 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MobileAutomationsSection } from "./mobile-automations-section";
const api = vi.hoisted(() => ({ listAutomations: vi.fn(), listAutomationSummaries: vi.fn() }));
vi.mock("@/lib/api/domains/automation-api", () => api);
vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: vi.fn() }) }));
const automation = {
id: "a",
workspace_id: "one",
name: "Daily check",
enabled: true,
max_concurrent_runs: 1,
triggers: [],
};
beforeEach(() => {
api.listAutomations.mockReset().mockResolvedValue([automation]);
api.listAutomationSummaries.mockReset().mockResolvedValue([]);
});
afterEach(cleanup);
function open() {
fireEvent.click(screen.getByRole("button", { name: "Automations" }));
}
it("defers reads until opened and exposes the existing detail route", async () => {
render(<MobileAutomationsSection workspaceId="one" onNavigate={() => {}} />);
expect(api.listAutomations).not.toHaveBeenCalled();
open();
expect((await screen.findByRole("link", { name: /Daily check/ })).getAttribute("href")).toBe(
"/automations/a",
);
});
it("reports a failed list and recovers without calling it empty", async () => {
api.listAutomations.mockRejectedValueOnce(new Error("private detail"));
render(<MobileAutomationsSection workspaceId="one" onNavigate={() => {}} />);
open();
expect((await screen.findByRole("alert")).textContent).not.toContain("private detail");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await screen.findByRole("link", { name: /Daily check/ });
expect(screen.queryByRole("alert")).toBeNull();
});
it("hides old workspace names while the replacement read is pending", async () => {
const view = render(<MobileAutomationsSection workspaceId="one" onNavigate={() => {}} />);
open();
await screen.findByRole("link", { name: /Daily check/ });
api.listAutomations.mockImplementation(() => new Promise(() => {}));
view.rerender(<MobileAutomationsSection workspaceId="two" onNavigate={() => {}} />);
expect(screen.queryByRole("link", { name: /Daily check/ })).toBeNull();
await waitFor(() => expect(api.listAutomations).toHaveBeenLastCalledWith("two"));
expect(screen.getByRole("status").textContent).toContain("Loading");
});
it("does not report a healthy status when activity failed", async () => {
api.listAutomationSummaries.mockRejectedValueOnce(new Error("private activity"));
render(<MobileAutomationsSection workspaceId="one" onNavigate={() => {}} />);
open();
const row = await screen.findByRole("link", { name: /Daily check/ });
await screen.findByRole("alert");
expect(row.textContent).not.toContain("Idle");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
@@ -0,0 +1,127 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronRight, IconListDetails } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { useWorkspaceAutomations } from "@/components/runs/use-workspace-automations";
import { useAutomationSummaries } from "@/components/runs/use-automation-summaries";
import { useLiveRefresh } from "@/components/runs/use-live-refresh";
import { buildAutomationRows, STATE_LABEL_KEY } from "@/components/runs/automation-rows";
import { AUTOMATIONS_HREF } from "@/components/runs/runs-view";
import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs";
export function MobileAutomationsSection({
workspaceId,
onNavigate,
}: {
workspaceId: string;
onNavigate: () => void;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
return (
<section
className="flex flex-col gap-3 border-t border-border pt-4"
data-testid="mobile-automations-section"
>
<div className="flex items-center gap-2">
<Button
variant="ghost"
className="cursor-pointer h-11 min-w-11 flex-1 justify-start gap-2 px-0 text-sm font-medium hover:bg-transparent aria-expanded:bg-transparent"
aria-expanded={expanded}
aria-controls="mobile-automations-body"
onClick={() => setExpanded(!expanded)}
>
{t("automations:automations")}
{expanded ? (
<IconChevronDown className="size-3.5 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 text-muted-foreground" />
)}
</Button>
<Button
asChild
variant="ghost"
className="cursor-pointer size-11 shrink-0 text-muted-foreground"
>
<Link
href={AUTOMATIONS_HREF}
onClick={onNavigate}
aria-label={t("automations:openAutomations")}
>
<IconListDetails className="size-4" />
</Link>
</Button>
</div>
{expanded && (
<MobileAutomationRows key={workspaceId} workspaceId={workspaceId} onNavigate={onNavigate} />
)}
</section>
);
}
function MobileAutomationRows({
workspaceId,
onNavigate,
}: {
workspaceId: string;
onNavigate: () => void;
}) {
const { t } = useTranslation();
const list = useWorkspaceAutomations(workspaceId);
const activity = useAutomationSummaries(workspaceId);
useLiveRefresh(true, activity.refresh);
const rows = buildAutomationRows(list.automations, activity.summaries);
const pending = list.loading || activity.loading;
const error = list.error || activity.error;
return (
<div id="mobile-automations-body" className="flex flex-col gap-2">
{pending && (
<p role="status" className="text-sm text-muted-foreground">
{t("common:loading")}
</p>
)}
{error && (
<div role="alert" className="space-y-2 text-sm">
<p>
{list.error
? t("automations:failedToLoadAutomations")
: t("automations:failedToLoadAutomationActivity")}
</p>
<Button
variant="outline"
className="cursor-pointer h-11"
onClick={() => {
list.refresh();
activity.refresh();
}}
>
{t("automations:tryAgain")}
</Button>
</div>
)}
{!list.loading &&
!list.error &&
rows.map(({ automation, state }) => (
<Button
asChild
variant="outline"
className="cursor-pointer min-h-11 h-auto justify-start gap-3 px-3 py-2"
key={automation.id}
>
<Link href={`${AUTOMATIONS_HREF}/${automation.id}`} onClick={onNavigate}>
<span className="min-w-0 flex-1 truncate text-left">{automation.name}</span>
{!activity.loading && !activity.error && (
<span className="text-xs text-muted-foreground">{t(STATE_LABEL_KEY[state])}</span>
)}
</Link>
</Button>
))}
<Button asChild variant="outline" className="cursor-pointer h-11 justify-start px-3">
<Link href={workspaceSettingsHref(workspaceId, "automations")} onClick={onNavigate}>
{t("automations:setUpAnAutomation")}
</Link>
</Button>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconInbox, IconSquarePlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
@@ -31,12 +31,16 @@ import { DestinationRows } from "./destination-rows";
const INTEGRATION_DESTINATION_IDS = new Set(["azure-devops", "github", "gitlab", "jira", "linear"]);
type MobileSidebarLayoutNavigationProps = {
quickActions?: ReactNode;
homeCoversListings?: boolean;
onNavigate: () => void;
omitSections: Set<string>;
omitDestinations: string[];
};
type MobileLayoutNodeProps = {
quickActions?: ReactNode;
homeCoversListings?: boolean;
node: ProjectedSidebarNode;
homeDestination?: ResolvedDestination;
destinationHrefs: Map<string, string | undefined>;
@@ -204,6 +208,8 @@ function MobileRequiredRows({
function MobileBuiltinNode({
node,
quickActions,
homeCoversListings,
homeDestination,
integrationEntries,
canvasEntries,
@@ -218,11 +224,15 @@ function MobileBuiltinNode({
case "home":
if (omitSections.has("primary") || omitDestinations.includes("home")) return null;
return homeDestination ? (
<DestinationRows
destinations={[homeDestination]}
onNavigate={onNavigate}
className="h-11 gap-3 px-3 text-sm"
/>
<>
<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 : (
@@ -291,6 +301,8 @@ function MobileLayoutNode(props: MobileLayoutNodeProps) {
}
export function MobileSidebarLayoutNavigation({
quickActions,
homeCoversListings,
onNavigate,
omitSections,
omitDestinations,
@@ -324,6 +336,10 @@ export function MobileSidebarLayoutNavigation({
// 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);
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) =>
@@ -336,11 +352,14 @@ export function MobileSidebarLayoutNavigation({
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}
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useArchivedTaskState } from "@/components/task/task-archived-context";
import { useOptionalPortForwardingVisibility } from "@/components/task/port-forwarding-visibility-provider";
import {
MobileTaskNavigationProvider,
useMobileTaskNavigationOutlet,
} from "./mobile-task-navigation-provider";
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ workspaces: { activeId: "workspace" }, workflows: { activeId: "workflow" } }),
}));
vi.mock("@/components/task/mobile/session-task-switcher-sheet", () => ({
SessionTaskSwitcherSheet: () => {
const archived = useArchivedTaskState();
const ports = useOptionalPortForwardingVisibility();
return <output>{`${archived.archivedTaskId ?? "none"}:${ports?.enabled ?? false}`}</output>;
},
}));
afterEach(cleanup);
const portForwarding = {
enabled: true,
canToggle: true,
isUpdating: false,
dialogOpen: false,
setDialogOpen: vi.fn(),
togglePortForwarding: vi.fn(),
};
function Outlet({ taskId }: { taskId: string }) {
const register = useMobileTaskNavigationOutlet();
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
const outlet = {
element: element.current!,
close: vi.fn(),
navigate: vi.fn(),
archivedState: { isArchived: true, archivedTaskId: taskId },
portForwarding,
};
register(outlet);
return () => register(null);
}, [register, taskId]);
return <div ref={element} />;
}
it("bridges current task context to the retained sidebar and updates it with the outlet", async () => {
const host = render(
<MobileTaskNavigationProvider>
<Outlet taskId="archived-first" />
</MobileTaskNavigationProvider>,
);
await expect.poll(() => screen.getByRole("status").textContent).toBe("archived-first:true");
host.rerender(
<MobileTaskNavigationProvider>
<Outlet taskId="archived-second" />
</MobileTaskNavigationProvider>,
);
await expect.poll(() => screen.getByRole("status").textContent).toBe("archived-second:true");
});
@@ -0,0 +1,94 @@
import {
createContext,
lazy,
Suspense,
useCallback,
useContext,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { Drawer } from "@kandev/ui/drawer";
import {
TaskArchivedProvider,
useArchivedTaskState,
} from "@/components/task/task-archived-context";
import {
PortForwardingVisibilityContextProvider,
type PortForwardingVisibility,
} from "@/components/task/port-forwarding-visibility-provider";
import { useAppStore } from "@/components/state-provider";
import type { TaskSheetSelectionController } from "@/components/task/mobile/session-task-switcher-sheet-selection";
const TaskSidebar = lazy(() =>
import("@/components/task/mobile/session-task-switcher-sheet").then((m) => ({
default: m.SessionTaskSwitcherSheet,
})),
);
type Outlet = {
element: HTMLDivElement;
close: () => void;
navigate: (taskId: string) => void;
selection?: TaskSheetSelectionController | null;
archivedState?: ReturnType<typeof useArchivedTaskState>;
portForwarding?: PortForwardingVisibility;
};
const Context = createContext<(outlet: Outlet | null) => void>(() => {});
export const useMobileTaskNavigationOutlet = () => useContext(Context);
/** Task action dialogs outlive the menu portal and responsive page headers. */
export function MobileTaskNavigationProvider({ children }: { children: ReactNode }) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const workflowId = useAppStore((s) => s.workflows.activeId);
const [outlet, setOutlet] = useState<Outlet | null>(null);
const configuration = useRef<Outlet | null>(null);
const [requested, setRequested] = useState(false);
const register = useCallback((next: Outlet | null) => {
setOutlet(next);
if (next) {
configuration.current = next;
setRequested(true);
}
}, []);
return (
<Context.Provider value={register}>
{children}
{requested && workspaceId && (
<Suspense fallback={null}>
<TaskArchivedProvider
value={configuration.current?.archivedState ?? { isArchived: false }}
>
<PortForwardingVisibilityContextProvider
value={configuration.current?.portForwarding ?? null}
>
<TaskSidebar
key={workspaceId}
workspaceId={workspaceId}
workflowId={workflowId}
open={!!outlet}
onOpenChange={(open) => {
if (!open) configuration.current?.close();
}}
navigate={configuration.current?.navigate}
selection={configuration.current?.selection ?? undefined}
presentation="drawer"
renderInline={(body) =>
outlet
? createPortal(
<Drawer open modal={false}>
{body}
</Drawer>,
outlet.element,
)
: null
}
/>
</PortForwardingVisibilityContextProvider>
</TaskArchivedProvider>
</Suspense>
)}
</Context.Provider>
);
}
@@ -40,7 +40,7 @@ function TaskViewSurface({
);
}
export function useTaskViewNavigation(closeMenu: () => void) {
export function useTaskViewNavigation(closeMenu: () => void, onOpenTaskViews?: () => void) {
const workspace = useAppStore(selectActiveWorkspace);
const { isMobile } = useResponsiveBreakpoint();
const kanbanWorkspace = workspace && !isOfficeWorkspace(workspace);
@@ -65,8 +65,12 @@ export function useTaskViewNavigation(closeMenu: () => void) {
frame.current = requestAnimationFrame(() => {
opener.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setMounted(true);
setOpen(true);
if (onOpenTaskViews) {
onOpenTaskViews();
} else {
setMounted(true);
setOpen(true);
}
});
};
const restoreFocus = (event: Event) => {
+2 -2
View File
@@ -44,7 +44,7 @@ describe("PageShell", () => {
expect(screen.getByTestId("tree")).not.toBeNull();
});
it("keeps the nav sheet ahead of page-supplied leading content", () => {
it("keeps app navigation after page-supplied content", () => {
render(
<PageShell title="Office" leading={<span data-testid="slot" />}>
content
@@ -55,7 +55,7 @@ describe("PageShell", () => {
const order = [...header.querySelectorAll("[data-testid]")].map((el) =>
el.getAttribute("data-testid"),
);
expect(order.indexOf("nav-sheet-mock")).toBeLessThan(order.indexOf("slot"));
expect(order.indexOf("nav-sheet-mock")).toBeGreaterThan(order.indexOf("slot"));
});
it("applies the resolved home affordance to the topbar crumb", () => {
+4 -3
View File
@@ -48,7 +48,7 @@ type PageShellProps = {
/**
* The one page chrome for top-level routes: `PageTopbar` with the app nav
* trigger injected ahead of any page `leading`, the home affordance resolved by
* trigger following the page actions, the home affordance resolved by
* `useHomeAffordance` (instead of each shell hand-rolling an escape hatch), and
* a scroll container below.
*
@@ -75,12 +75,13 @@ export function PageShell({
testId={topbarTestId}
homeAffordance={home.mode}
homeHref={home.href}
leading={
leading={leading}
actions={
<>
{forwarded.actions}
{showNavTrigger && (
<AppNavSheet pageNav={pageNav} omitDestinations={navOmitDestinations} />
)}
{leading}
</>
}
/>
@@ -206,6 +206,25 @@ describe("AddWorkspaceSourcesDialog repository discovery", () => {
});
});
describe("AddWorkspaceSourcesDialog touch activation", () => {
it("opens the repository menu after touch release without selecting a source", async () => {
isMobile = true;
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL }));
const trigger = screen.getByRole("button", { name: "Add repository" });
const workspaceRepository = { name: "Workspace repository" };
fireEvent.pointerDown(trigger, { button: 0, pointerType: "touch" });
expect(screen.queryByRole("menuitem", workspaceRepository)).toBeNull();
fireEvent.pointerUp(trigger, { button: 0, pointerType: "touch" });
expect(screen.queryByRole("menuitem", workspaceRepository)).toBeNull();
fireEvent.click(trigger);
expect(await screen.findByRole("menuitem", workspaceRepository)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Remove source" })).toBeNull();
fireEvent.click(await screen.findByRole("menuitem", { name: "Local Git repository" }));
expect(screen.getByRole("button", { name: "Remove source" })).toBeTruthy();
});
});
describe("AddWorkspaceSourcesDialog", () => {
it("uses a touch-sized repository menu without tabs or discarded mixed source rows", async () => {
isMobile = true;
@@ -1,14 +1,7 @@
"use client";
import { useCallback, useEffect, useState, type RefObject, type ReactNode } from "react";
import {
IconChevronDown,
IconCloudDownload,
IconGitBranch,
IconPlus,
IconStack2,
IconX,
} from "@tabler/icons-react";
import { IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -25,12 +18,6 @@ import {
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Input } from "@kandev/ui/input";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStore } from "@/components/state-provider";
@@ -46,6 +33,7 @@ import {
getWorkspaceSourceCapabilities,
hasCloneableSavedRepository,
} from "@/components/workspace-source-picker/executor-capabilities";
import { RepositorySourceMenu } from "./repository-source-menu";
import { AddFolderButton } from "./add-folder-button";
import { SavedRepositorySourceRow } from "./saved-repository-source-row";
import { useDialogOpenerFocus } from "./use-dialog-opener-focus";
@@ -53,7 +41,6 @@ import { useSubmitWorkspaceSources } from "./use-submit-workspace-sources";
import { useWorkspaceRepositoryOptions } from "./use-workspace-repository-options";
import { useWorkspaceSourceRows } from "./use-workspace-source-rows";
import { WorkspaceChangeConsequences } from "./workspace-change-consequences";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
@@ -344,79 +331,6 @@ function SourceForm({
);
}
function RepositorySourceMenu({
isMobile,
onAdd,
}: {
isMobile: boolean;
onAdd: (kind: "saved_repository" | "local_repository" | "remote_repository") => void;
}) {
const { t } = useTranslation();
const itemClass = cn("cursor-pointer items-start gap-3", isMobile ? "min-h-11" : "py-2");
return (
<DropdownMenu modal={!isMobile}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn("cursor-pointer", isMobile ? "min-h-11" : "h-9 px-3")}
>
<IconPlus className="h-4 w-4" />
{t("task:addRepository")}
<IconChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80 max-w-[calc(100vw-2rem)]">
<RepositorySourceMenuItem
label={t("task:workspaceRepository")}
description={t("task:chooseFromSavedOrDiscoveredRepositories")}
icon={<IconStack2 className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("saved_repository")}
/>
<RepositorySourceMenuItem
label={t("task:localGitRepository")}
description={t("task:useAnExistingCheckoutOnThis")}
icon={<IconGitBranch className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("local_repository")}
/>
<RepositorySourceMenuItem
label={t("task:remoteRepository")}
description={t("task:cloneFromAProviderOrGit")}
icon={<IconCloudDownload className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("remote_repository")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function RepositorySourceMenuItem({
label,
description,
icon,
className,
onSelect,
}: {
label: string;
description: string;
icon: ReactNode;
className: string;
onSelect: () => void;
}) {
return (
<DropdownMenuItem aria-label={label} className={className} onSelect={onSelect}>
{icon}
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</DropdownMenuItem>
);
}
function SourceRow({
row,
repositories,
@@ -0,0 +1,109 @@
"use client";
import { useRef, useState, type ReactNode } from "react";
import {
IconChevronDown,
IconCloudDownload,
IconGitBranch,
IconPlus,
IconStack2,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export function RepositorySourceMenu({
isMobile,
onAdd,
}: {
isMobile: boolean;
onAdd: (kind: "saved_repository" | "local_repository" | "remote_repository") => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const touchPending = useRef(false);
const itemClass = cn("cursor-pointer items-start gap-3", isMobile ? "min-h-11" : "py-2");
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={!isMobile}>
<DropdownMenuTrigger
asChild
onPointerDown={(event) => {
// Defer opening until the touch's synthetic click has a stable target.
touchPending.current = event.pointerType === "touch";
if (touchPending.current) event.preventDefault();
}}
onPointerCancel={() => {
touchPending.current = false;
}}
onClick={() => {
if (!touchPending.current) return;
touchPending.current = false;
setOpen((value) => !value);
}}
>
<Button
type="button"
variant="outline"
className={cn("cursor-pointer", isMobile ? "min-h-11" : "h-9 px-3")}
>
<IconPlus className="h-4 w-4" />
{t("task:addRepository")}
<IconChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-80 max-w-[calc(100vw-2rem)]">
<RepositorySourceMenuItem
label={t("task:workspaceRepository")}
description={t("task:chooseFromSavedOrDiscoveredRepositories")}
icon={<IconStack2 className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("saved_repository")}
/>
<RepositorySourceMenuItem
label={t("task:localGitRepository")}
description={t("task:useAnExistingCheckoutOnThis")}
icon={<IconGitBranch className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("local_repository")}
/>
<RepositorySourceMenuItem
label={t("task:remoteRepository")}
description={t("task:cloneFromAProviderOrGit")}
icon={<IconCloudDownload className="mt-0.5 h-4 w-4 text-muted-foreground" />}
className={itemClass}
onSelect={() => onAdd("remote_repository")}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function RepositorySourceMenuItem({
label,
description,
icon,
className,
onSelect,
}: {
label: string;
description: string;
icon: ReactNode;
className: string;
onSelect: () => void;
}) {
return (
<DropdownMenuItem aria-label={label} className={className} onSelect={onSelect}>
{icon}
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted-foreground">{description}</span>
</span>
</DropdownMenuItem>
);
}
@@ -0,0 +1,50 @@
import { lazy, Suspense, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const TaskPicker = lazy(() =>
import("./session-task-switcher-sheet").then((module) => ({
default: module.SessionTaskSwitcherSheet,
})),
);
/** Owns task dialogs above layout changes so rotating a phone preserves drafts. */
export function ResponsiveTaskPicker({
workspaceId,
workflowId,
}: {
workspaceId: string | null;
workflowId: string | null;
}) {
const { isMobile } = useResponsiveBreakpoint();
const open = useAppStore((state) => state.mobileSession.isTaskSwitcherOpen);
const setOpen = useAppStore((state) => state.setMobileSessionTaskSwitcherOpen);
const [requested, setRequested] = useState(false);
const [entry, setEntry] = useState({ open: false, opener: null as HTMLElement | null });
if (open && !requested) setRequested(true);
if (entry.open !== open) {
setEntry({
open,
opener:
open && document.activeElement instanceof HTMLElement
? document.activeElement
: entry.opener,
});
}
if (!requested && !open) return null;
return (
<Suspense fallback={null}>
<TaskPicker
workspaceId={workspaceId}
workflowId={workflowId}
open={open}
onOpenChange={setOpen}
presentation={isMobile ? "drawer" : "sheet"}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (entry.opener?.isConnected) entry.opener.focus({ preventScroll: true });
}}
/>
</Suspense>
);
}
@@ -5,7 +5,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
import { MobileFileViewerPanel } from "./mobile-file-viewer-panel";
import { TaskChatPanel, type PendingMessageScrollTarget } from "../task-chat-panel";
import { TaskPlanPanel } from "../task-plan-panel";
@@ -420,7 +419,8 @@ type MobileTopBarStickyProps = {
effectiveSessionId: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
onMenuClick: () => void;
onTaskPickerClick: () => void;
taskPickerOpen: boolean;
showApproveButton: boolean;
onApprove: () => void;
isRemoteExecutor?: boolean;
@@ -448,7 +448,8 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) {
sessionId={props.effectiveSessionId}
baseBranch={props.baseBranch}
worktreeBranch={props.worktreeBranch}
onMenuClick={props.onMenuClick}
onTaskPickerClick={props.onTaskPickerClick}
taskPickerOpen={props.taskPickerOpen}
showApproveButton={props.showApproveButton}
onApprove={props.onApprove}
isRemoteExecutor={props.isRemoteExecutor}
@@ -655,7 +656,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
handlePanelChange,
isTaskSwitcherOpen,
handleMenuClick,
setMobileSessionTaskSwitcherOpen,
} = useSessionLayoutState({ sessionId: props.sessionId });
const {
selectedFile,
@@ -737,7 +737,8 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
{...props}
activeTaskId={activeTaskId}
effectiveSessionId={effectiveSessionId}
onMenuClick={handleMenuClick}
onTaskPickerClick={handleMenuClick}
taskPickerOpen={isTaskSwitcherOpen}
showApproveButton={showApproveButton}
onApprove={handleApprove}
/>
@@ -775,13 +776,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
taskCanvases={props.taskCanvases}
onOpenCanvas={props.onOpenCanvas}
/>
<SessionTaskSwitcherSheet
open={isTaskSwitcherOpen}
onOpenChange={setMobileSessionTaskSwitcherOpen}
workspaceId={props.workspaceId}
workflowId={props.workflowId}
presentation="drawer"
/>
<SessionMobileReviewDialog
sessionId={effectiveSessionId}
taskId={activeTaskId}
@@ -48,7 +48,8 @@ function renderTopBar(props: Record<string, unknown> = {}) {
workspaceId="ws-1"
taskTitle="Pin the RDS engine version"
sessionId="session-1"
onMenuClick={vi.fn()}
onTaskPickerClick={vi.fn()}
taskPickerOpen={false}
{...props}
/>
</TooltipProvider>
@@ -1,53 +1,58 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
vi.mock("@/components/task/executor-settings-button", () => ({
ExecutorSettingsButton: ({ taskId, sessionId }: { taskId: string; sessionId: string }) => (
<button data-testid="mobile-kubernetes-executor">
{taskId}:{sessionId}
</button>
),
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => null,
}));
vi.mock("@/components/task/remote-cloud-tooltip", () => ({
RemoteCloudTooltip: () => <span data-testid="mobile-remote-tooltip" />,
vi.mock("@/hooks/domains/session/use-session-commits", () => ({
useSessionCommits: () => ({ commits: [] }),
}));
vi.mock("@/components/gitlab/mr-topbar-button", () => ({ MRTopbarButton: () => null }));
vi.mock("@/components/task/port-forward-dialog", () => ({ PortForwardButton: () => null }));
vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({
TaskTopBarPluginActions: () => null,
}));
vi.mock("@/components/navigation/app-nav-sheet", () => ({
AppNavSheet: () => <button aria-label="Open navigation menu" />,
}));
import * as mobileTopBar from "./session-mobile-top-bar";
afterEach(cleanup);
describe("SessionMobileTopBar remote executor disclosure", () => {
it("uses the touch-capable Kubernetes disclosure instead of a tooltip-only glyph", () => {
expect("MobileRemoteExecutorIndicator" in mobileTopBar).toBe(true);
const MobileRemoteExecutorIndicator = (
mobileTopBar as typeof mobileTopBar & {
MobileRemoteExecutorIndicator: React.ComponentType<{
taskId: string;
sessionId: string;
remoteExecutorType: string;
}>;
}
).MobileRemoteExecutorIndicator;
const { rerender } = render(
<MobileRemoteExecutorIndicator
taskId="task-1"
sessionId="session-1"
remoteExecutorType="k8s"
describe("phone task navigation controls", () => {
// @covers AC-UI-MOBILE-MENU-002.1, AC-UI-MOBILE-MENU-002.3
it("opens task switching from the title and exposes its expanded state", () => {
const onTaskPickerClick = vi.fn();
const host = render(
<SessionMobileTopBar
taskTitle="Fix checkout"
onTaskPickerClick={onTaskPickerClick}
taskPickerOpen={false}
/>,
);
expect(screen.getByTestId("mobile-kubernetes-executor").textContent).toBe("task-1:session-1");
expect(screen.queryByTestId("mobile-remote-tooltip")).toBeNull();
rerender(
<MobileRemoteExecutorIndicator
taskId="task-1"
sessionId="session-1"
remoteExecutorType="ssh"
const title = screen.getByRole("button", { name: /Fix checkout/ });
fireEvent.click(title);
expect(onTaskPickerClick).toHaveBeenCalledOnce();
expect(title.getAttribute("aria-expanded")).toBe("false");
host.rerender(
<SessionMobileTopBar
taskTitle="Fix checkout"
onTaskPickerClick={onTaskPickerClick}
taskPickerOpen
/>,
);
expect(screen.getByTestId("mobile-remote-tooltip")).toBeTruthy();
expect(screen.queryByTestId("mobile-kubernetes-executor")).toBeNull();
expect(title.getAttribute("aria-expanded")).toBe("true");
});
it("keeps app navigation separate from task selection", () => {
const onTaskPickerClick = vi.fn();
render(
<SessionMobileTopBar
taskTitle="Fix checkout"
onTaskPickerClick={onTaskPickerClick}
taskPickerOpen={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Open navigation menu" }));
expect(onTaskPickerClick).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,7 @@
import { memo } from "react";
import Link from "@/components/routing/app-link";
import { IconArrowLeft, IconMenu2, IconGitBranch, IconCheck } from "@tabler/icons-react";
import { IconArrowLeft, IconChevronDown, IconGitBranch, IconCheck } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
@@ -15,6 +15,7 @@ import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { linkToTaskOverview } from "@/lib/links";
import { AppNavSheet } from "@/components/navigation/app-nav-sheet";
import { useTranslation } from "react-i18next";
type SessionMobileTopBarProps = {
@@ -26,7 +27,8 @@ type SessionMobileTopBarProps = {
sessionId?: string | null;
baseBranch?: string;
worktreeBranch?: string | null;
onMenuClick: () => void;
onTaskPickerClick: () => void;
taskPickerOpen: boolean;
showApproveButton?: boolean;
onApprove?: () => void;
isRemoteExecutor?: boolean;
@@ -46,17 +48,36 @@ function MobileTaskTitle({
displayBranch,
totalAdditions,
totalDeletions,
onTaskPickerClick,
taskPickerOpen,
}: {
taskTitle?: string;
repositoryLabel?: string | null;
displayBranch?: string;
totalAdditions: number;
totalDeletions: number;
onTaskPickerClick: () => void;
taskPickerOpen: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">{taskTitle ?? t("task:taskDetails")}</span>
<button
type="button"
className="flex h-11 min-w-11 flex-1 flex-col justify-center rounded-md text-left cursor-pointer hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
onClick={(event) => {
event.currentTarget.focus();
onTaskPickerClick();
}}
aria-haspopup="dialog"
aria-expanded={taskPickerOpen}
aria-label={t("task:openTaskSwitcherFor", { title: taskTitle ?? t("task:taskDetails") })}
data-testid="mobile-task-picker-trigger"
data-legacy-testid="mobile-session-menu"
>
<span className="flex w-full min-w-0 items-center gap-1">
<span className="text-sm font-medium truncate">{taskTitle ?? t("task:taskDetails")}</span>
<IconChevronDown className="size-4 shrink-0" />
</span>
{/* The phone bar has no breadcrumb, so the repository rides the same
secondary line as the branch. It shrinks first: on a phone the branch
and diff stats are the denser signal, and the full name stays in
@@ -83,7 +104,7 @@ function MobileTaskTitle({
</>
)}
</div>
</div>
</button>
);
}
@@ -183,7 +204,7 @@ type MobileTopBarActionsProps = {
taskTitle?: string;
isArchived?: boolean;
onTaskUnarchived?: (taskId: string) => void;
onMenuClick: () => void;
onTaskPickerClick: () => void;
};
function MobileTopBarActions({
@@ -202,11 +223,10 @@ function MobileTopBarActions({
taskTitle,
isArchived,
onTaskUnarchived,
onMenuClick,
onTaskPickerClick,
}: MobileTopBarActionsProps) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1" data-testid="mobile-topbar-actions">
<div className="flex shrink-0 items-center gap-1" data-testid="mobile-topbar-actions">
<MRTopbarButton compact mobile />
{isArchived && <TaskUnarchiveButton taskId={taskId} onUnarchived={onTaskUnarchived} mobile />}
{!isArchived && <PortForwardButton sessionId={sessionId} />}
@@ -231,16 +251,7 @@ function MobileTopBarActions({
/>
)}
{showApproveButton && onApprove && <ApproveButton onApprove={onApprove} />}
<Button
variant="ghost"
size="icon-sm"
className="h-11 w-11 cursor-pointer"
onClick={onMenuClick}
data-testid="mobile-session-menu"
aria-label={t("task:openTaskSwitcher")}
>
<IconMenu2 className="h-4 w-4" />
</Button>
<AppNavSheet onOpenTaskViews={onTaskPickerClick} />
</div>
);
}
@@ -255,7 +266,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
props.baseBranch,
);
return (
<header className="flex items-center justify-between px-2 py-2 bg-background">
<header className="flex items-center justify-between h-14 px-2 py-1 bg-background">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Button variant="ghost" size="icon-sm" asChild>
<Link
@@ -271,6 +282,8 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
displayBranch={displayBranch}
totalAdditions={totalAdditions}
totalDeletions={totalDeletions}
onTaskPickerClick={props.onTaskPickerClick}
taskPickerOpen={props.taskPickerOpen}
/>
</div>
<MobileTopBarActions
@@ -289,7 +302,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar(
taskTitle={props.taskTitle}
isArchived={props.isArchived}
onTaskUnarchived={props.onTaskUnarchived}
onMenuClick={props.onMenuClick}
onTaskPickerClick={props.onTaskPickerClick}
/>
</header>
);
@@ -2,7 +2,6 @@
import { memo, useMemo } from "react";
import { Group, Panel } from "react-resizable-panels";
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
import { TaskCenterPanel } from "../task-center-panel";
import { TaskRightPanel } from "../task-right-panel";
import { TaskFilesPanel } from "../task-files-panel";
@@ -97,8 +96,6 @@ function TabletLeftPanel({
}
export const SessionTabletLayout = memo(function SessionTabletLayout({
workspaceId,
workflowId,
sessionId = null,
repository = null,
defaultLayouts = {},
@@ -113,8 +110,6 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({
openFileRequest,
handleOpenFile,
handleFileOpenHandled,
isTaskSwitcherOpen,
setMobileSessionTaskSwitcherOpen,
} = useSessionLayoutState({ sessionId });
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
@@ -185,13 +180,6 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({
)}
</Group>
{/* Task Switcher Sheet - same as mobile */}
<SessionTaskSwitcherSheet
open={isTaskSwitcherOpen}
onOpenChange={setMobileSessionTaskSwitcherOpen}
workspaceId={workspaceId}
workflowId={workflowId}
/>
<TaskReviewDialogMount taskId={activeTaskId} sessionId={effectiveSessionId} />
</div>
);
@@ -1,11 +1,14 @@
"use client";
import { useCallback, useEffect, useMemo, useState, memo } from "react";
import { useCallback, useMemo, useState, memo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconCheck, IconNetwork, IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
import { useTaskSheetSelectionController } from "./task-sheet-selection-context";
export { useTaskSheetSelectionController } from "./task-sheet-selection-context";
import type { TaskSheetSelectionController } from "./session-task-switcher-sheet-selection";
import { TaskPickerSurface, InlineTaskHeader } from "./task-picker-surface";
import { Button } from "@kandev/ui/button";
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
import { TaskSwitcher } from "../task-switcher";
@@ -26,10 +29,7 @@ import {
} from "../task-session-sidebar-link-actions";
import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking";
import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks";
import {
createTaskSheetSelectionController,
handleTaskSheetOpenChange,
} from "./session-task-switcher-sheet-selection";
import { handleTaskSheetOpenChange } from "./session-task-switcher-sheet-selection";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { useMobileTaskRename } from "./use-mobile-task-rename";
import { useSidebarTaskEdit } from "../task-session-sidebar-edit";
@@ -44,6 +44,8 @@ type SessionTaskSwitcherSheetProps = {
presentation?: "sheet" | "drawer";
navigate?: (taskId: string) => void;
onCloseAutoFocus?: (event: Event) => void;
renderInline?: (body: ReactNode) => ReactNode;
selection?: TaskSheetSelectionController;
};
function useTaskSheetOpener(open: boolean) {
const [opener, setOpener] = useState({ open: false, current: null as HTMLElement | null });
@@ -59,16 +61,6 @@ function useTaskSheetOpener(open: boolean) {
return opener;
}
export function useTaskSheetSelectionController() {
const [selectionController] = useState(createTaskSheetSelectionController);
useEffect(
() => () => {
selectionController.invalidate();
},
[selectionController],
);
return selectionController;
}
export function useMobileTaskLinking(workspaceId: string | null) {
const store = useAppStoreApi();
const actions = useSidebarLinkActions(store);
@@ -305,6 +297,9 @@ type TaskSwitcherSurfaceContentProps = {
onOpenChange: (open: boolean) => void;
onQuickChat: () => void;
onNewTask: () => void;
inline?: boolean;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
onCreateSubtask: (taskId: string, taskTitle: string) => void;
data: ReturnType<typeof useSheetData>;
actions: ReturnType<typeof useSheetActions>;
@@ -313,6 +308,19 @@ type TaskSwitcherSurfaceContentProps = {
linking: ReturnType<typeof useMobileTaskLinking>;
};
function mobileMoveActions(
presentation: "sheet" | "drawer",
moveOptions: ReturnType<typeof useMobileTaskMoveOptions>,
onOpenChange: (open: boolean) => void,
) {
if (presentation !== "drawer") return {};
return {
onMoveToStep: moveOptions.handleMove,
onRequestMoveOptions: moveOptions.handleRequest,
onBeforeMoveOptionsOpen: () => onOpenChange(false),
};
}
// eslint-disable-next-line max-lines-per-function -- this surface keeps the existing mobile scroll owner intact
function TaskSwitcherSurfaceContent({
open,
@@ -327,19 +335,12 @@ function TaskSwitcherSurfaceContent({
rename,
edit,
linking,
inline = false,
expanded,
onExpandedChange,
}: TaskSwitcherSurfaceContentProps) {
const { t } = useTranslation();
let taskLoadError: string | null = null;
if (data.workspaceContextError) {
taskLoadError = data.workspaceContextAccessDenied
? t("sidebar:workspaceContextAccessDenied")
: t("sidebar:workspaceContextRefreshFailed");
} else if (data.archivedError) {
taskLoadError = t("sidebar:archivedLoadFailed");
}
const retryTaskLoad = data.workspaceContextError
? data.retryWorkspaceContext
: data.retryArchivedTasks;
const { taskLoadError, retryTaskLoad } = useTaskReadStatus(data);
const moveOptions = useMobileTaskMoveOptions({
open,
activeTaskId: data.activeTaskId,
@@ -366,54 +367,67 @@ function TaskSwitcherSurfaceContent({
});
return (
<>
<TaskSwitcherSurfaceHeader
presentation={presentation}
workspaceId={workspaceId}
workspaces={data.workspaces.map((w) => ({ id: w.id, name: w.name }))}
onWorkspaceChange={actions.handleWorkspaceChange}
onQuickChat={onQuickChat}
onNewTask={onNewTask}
/>
{data.activeTaskId && (
<PortForwardingTaskAction
onClose={() => onOpenChange(false)}
activeTaskId={data.activeTaskId}
{inline ? (
<InlineTaskHeader
expanded={expanded}
onExpandedChange={onExpandedChange}
onNewTask={onNewTask}
/>
) : (
<TaskSwitcherSurfaceHeader
presentation={presentation}
workspaceId={workspaceId}
workspaces={data.workspaces.map((w) => ({ id: w.id, name: w.name }))}
onWorkspaceChange={actions.handleWorkspaceChange}
onQuickChat={onQuickChat}
onNewTask={onNewTask}
/>
)}
<div className="shrink-0">
<SidebarFilterBar />
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-2" data-testid="mobile-task-switcher-list">
<PluginTaskLinkActionSurfaceProvider
beforePluginRun={presentation === "drawer" ? () => onOpenChange(false) : undefined}
>
<MobileTaskList
tasks={data.tasksWithRepositories}
workflows={data.workflows}
stepsByWorkflowId={data.stepsByWorkflowId}
<div
id={inline ? "mobile-navigation-task-body" : undefined}
hidden={inline && !expanded}
className={inline ? undefined : "flex min-h-0 flex-1 flex-col"}
>
{data.activeTaskId && (
<PortForwardingTaskAction
onClose={() => onOpenChange(false)}
activeTaskId={data.activeTaskId}
selectedTaskId={data.selectedTaskId}
onSelectTask={actions.handleSelectTask}
onMoveToStep={presentation === "drawer" ? moveOptions.handleMove : undefined}
onRequestMoveOptions={presentation === "drawer" ? moveOptions.handleRequest : undefined}
onBeforeMoveOptionsOpen={
presentation === "drawer" ? () => onOpenChange(false) : undefined
}
{...taskListActions}
onCreateSubtask={onCreateSubtask}
onNestTask={actions.handleNestTask}
deletingTaskId={actions.deletingTaskId}
archivingTaskId={actions.archivingTaskId}
isArchiving={actions.isArchiving}
isLoading={
data.tasksLoading ||
(data.workspaceContextPending && data.tasksWithRepositories.length === 0)
}
loadError={taskLoadError}
onRetryLoad={retryTaskLoad}
retryLabel={t("sidebar:retry")}
/>
</PluginTaskLinkActionSurfaceProvider>
)}
<div className="shrink-0">
<SidebarFilterBar />
</div>
<div
className={inline ? "p-2" : "flex-1 min-h-0 overflow-y-auto p-2"}
data-testid="mobile-task-switcher-list"
>
<PluginTaskLinkActionSurfaceProvider
beforePluginRun={presentation === "drawer" ? () => onOpenChange(false) : undefined}
>
<MobileTaskList
tasks={data.tasksWithRepositories}
workflows={data.workflows}
stepsByWorkflowId={data.stepsByWorkflowId}
activeTaskId={data.activeTaskId}
selectedTaskId={data.selectedTaskId}
onSelectTask={actions.handleSelectTask}
{...mobileMoveActions(presentation, moveOptions, onOpenChange)}
{...taskListActions}
onCreateSubtask={onCreateSubtask}
onNestTask={actions.handleNestTask}
deletingTaskId={actions.deletingTaskId}
archivingTaskId={actions.archivingTaskId}
isArchiving={actions.isArchiving}
isLoading={
data.tasksLoading ||
(data.workspaceContextPending && data.tasksWithRepositories.length === 0)
}
loadError={taskLoadError}
onRetryLoad={retryTaskLoad}
retryLabel={t("sidebar:retry")}
/>
</PluginTaskLinkActionSurfaceProvider>
</div>
</div>
</>
);
@@ -460,13 +474,17 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
presentation = "sheet",
navigate,
onCloseAutoFocus,
renderInline,
selection,
}: SessionTaskSwitcherSheetProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [expanded, setExpanded] = useState(true);
const opener = useTaskSheetOpener(open);
const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks) !== false;
const [subtaskTarget, setSubtaskTarget] = useState<{ id: string; title: string } | null>(null);
const data = useSheetData(workspaceId);
const selectionController = useTaskSheetSelectionController();
const localSelection = useTaskSheetSelectionController();
const selectionController = selection ?? localSelection;
const handleOpenChange = useCallback(
(nextOpen: boolean) => handleTaskSheetOpenChange(selectionController, nextOpen, onOpenChange),
[onOpenChange, selectionController],
@@ -491,6 +509,9 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
const surfaceContent = (
<TaskSwitcherSurfaceContent
open={open}
inline={!!renderInline}
expanded={expanded}
onExpandedChange={setExpanded}
presentation={presentation}
workspaceId={workspaceId}
onOpenChange={handleOpenChange}
@@ -508,27 +529,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
/>
);
const surface =
presentation === "drawer" ? (
<TaskSwitcherDrawer
key={workspaceId}
open={open}
onOpenChange={handleOpenChange}
onCloseAutoFocus={onCloseAutoFocus}
>
{surfaceContent}
</TaskSwitcherDrawer>
) : (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
showCloseButton={false}
side="left"
className="w-[85vw] max-w-sm p-0 flex flex-col"
>
{surfaceContent}
</SheetContent>
</Sheet>
);
const surface = renderInline ? (
renderInline(surfaceContent)
) : (
<TaskPickerSurface
presentation={presentation}
workspaceId={workspaceId}
open={open}
onOpenChange={handleOpenChange}
onCloseAutoFocus={onCloseAutoFocus}
>
{surfaceContent}
</TaskPickerSurface>
);
return (
<>
@@ -550,3 +563,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
</>
);
});
function useTaskReadStatus(data: ReturnType<typeof useSheetData>) {
const { t } = useTranslation();
let taskLoadError: string | null = null;
if (data.workspaceContextError) {
taskLoadError = data.workspaceContextAccessDenied
? t("sidebar:workspaceContextAccessDenied")
: t("sidebar:workspaceContextRefreshFailed");
} else if (data.archivedError) {
taskLoadError = t("sidebar:archivedLoadFailed");
}
const retryTaskLoad = data.workspaceContextError
? data.retryWorkspaceContext
: data.retryArchivedTasks;
return { taskLoadError, retryTaskLoad };
}
@@ -0,0 +1,84 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronDown, IconChevronRight, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Sheet, SheetContent } from "@kandev/ui/sheet";
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
export function InlineTaskHeader({
expanded,
onExpandedChange,
onNewTask,
}: {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
onNewTask: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 border-t border-border pt-4">
<Button
variant="ghost"
className="cursor-pointer h-11 min-w-11 flex-1 justify-start gap-2 px-0 text-sm font-medium hover:bg-transparent aria-expanded:bg-transparent"
aria-expanded={expanded}
aria-controls="mobile-navigation-task-body"
data-testid="mobile-navigation-tasks-toggle"
onClick={() => onExpandedChange(!expanded)}
>
{t("task:tasks")}
{expanded ? (
<IconChevronDown className="size-3.5 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 text-muted-foreground" />
)}
</Button>
<Button
variant="ghost"
className="cursor-pointer size-11 shrink-0 text-muted-foreground hover:text-foreground"
onClick={onNewTask}
aria-label={t("task:newTask")}
>
<IconPlus className="size-4" />
</Button>
</div>
);
}
export function TaskPickerSurface({
presentation,
workspaceId,
open,
onOpenChange,
onCloseAutoFocus,
children,
}: {
presentation?: "sheet" | "drawer";
workspaceId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCloseAutoFocus?: (event: Event) => void;
} & { children: ReactNode }) {
if (presentation === "drawer")
return (
<TaskSwitcherDrawer
key={workspaceId}
open={open}
onOpenChange={onOpenChange}
onCloseAutoFocus={onCloseAutoFocus}
>
{children}
</TaskSwitcherDrawer>
);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
showCloseButton={false}
side="left"
className="w-[85vw] max-w-sm p-0 flex flex-col"
>
{children}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,51 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import {
TaskSheetSelectionProvider,
useTaskSheetSelectionController,
} from "./task-sheet-selection-context";
import type { TaskSheetSelectionController } from "./session-task-switcher-sheet-selection";
afterEach(cleanup);
it("invalidates pending selection when workspace scope changes without replacing the controller", () => {
const controllers: TaskSheetSelectionController[] = [];
function Consumer() {
controllers.push(useTaskSheetSelectionController());
return null;
}
const view = render(
<TaskSheetSelectionProvider workspaceId="first">
<Consumer />
</TaskSheetSelectionProvider>,
);
const token = controllers[0].beginSelection();
view.rerender(
<TaskSheetSelectionProvider workspaceId="second">
<Consumer />
</TaskSheetSelectionProvider>,
);
expect(controllers[1]).toBe(controllers[0]);
expect(controllers[0].isCurrent(token)).toBe(false);
});
// @covers AC-UI-MOBILE-MENU-003.3
it("shares selection cancellation between pins and the task picker until their owner unmounts", () => {
const controllers: TaskSheetSelectionController[] = [];
function Consumer() {
controllers.push(useTaskSheetSelectionController());
return null;
}
const view = render(
<TaskSheetSelectionProvider>
<Consumer />
<Consumer />
</TaskSheetSelectionProvider>,
);
expect(controllers[0]).toBe(controllers[1]);
const first = controllers[0].beginSelection();
const second = controllers[1].beginSelection();
expect(controllers[0].isCurrent(first)).toBe(false);
expect(controllers[0].isCurrent(second)).toBe(true);
view.unmount();
expect(controllers[0].isCurrent(second)).toBe(false);
});
@@ -0,0 +1,42 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
type ReactNode,
} from "react";
import {
createTaskSheetSelectionController,
type TaskSheetSelectionController,
} from "./session-task-switcher-sheet-selection";
const TaskSheetSelectionContext = createContext<TaskSheetSelectionController | null>(null);
export function useTaskSheetSelectionController() {
const shared = useContext(TaskSheetSelectionContext);
const [local] = useState(createTaskSheetSelectionController);
useEffect(() => () => local.invalidate(), [local]);
return shared ?? local;
}
export function useWorkbenchTaskSelection() {
return useContext(TaskSheetSelectionContext);
}
/** The embedded sidebar and title picker must supersede each other's pending selection requests. */
export function TaskSheetSelectionProvider({
children,
workspaceId,
}: {
children: ReactNode;
workspaceId?: string | null;
}) {
const controller = useTaskSheetSelectionController();
useLayoutEffect(() => () => controller.invalidate(), [controller, workspaceId]);
return (
<TaskSheetSelectionContext.Provider value={controller}>
{children}
</TaskSheetSelectionContext.Provider>
);
}
@@ -40,6 +40,8 @@ type PortForwardingVisibilityProviderProps = {
const PortForwardingVisibilityContext = createContext<PortForwardingVisibility | null>(null);
export const PortForwardingVisibilityContextProvider = PortForwardingVisibilityContext.Provider;
export function useOptionalPortForwardingVisibility(): PortForwardingVisibility | undefined {
return useContext(PortForwardingVisibilityContext) ?? undefined;
}
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { StateProvider } from "@/components/state-provider";
import { TaskLayout } from "./task-layout";
afterEach(cleanup);
@@ -9,12 +11,38 @@ afterEach(cleanup);
// repository label reaches the phone layout. `session-mobile-top-bar-repository`
// covers what the header does with it.
vi.mock("./mobile", () => ({
SessionMobileLayout: ({ repositoryLabel }: { repositoryLabel?: string | null }) => (
<div data-testid="mobile-layout" data-repository-label={repositoryLabel ?? ""} />
),
SessionMobileLayout: function MobileLayout({
repositoryLabel,
}: {
repositoryLabel?: string | null;
}) {
const [draft, setDraft] = useState("");
return (
<div data-testid="mobile-layout" data-repository-label={repositoryLabel ?? ""}>
<input
aria-label="Layout draft"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
</div>
);
},
SessionTabletLayout: () => <div data-testid="tablet-layout" />,
}));
vi.mock("./mobile/responsive-task-picker", () => ({
ResponsiveTaskPicker: function Picker() {
const [draft, setDraft] = useState("");
return (
<input
aria-label="Picker draft"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
);
},
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true, usesDesktopWorkbench: false }),
}));
@@ -28,8 +56,38 @@ vi.mock("./task-launch-error-context", () => ({
}));
describe("TaskLayout repository label", () => {
it("keeps layout state while task workspace metadata refreshes", () => {
const view = render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />, {
wrapper: StateProvider,
});
fireEvent.change(screen.getByRole("textbox", { name: "Layout draft" }), {
target: { value: "Keep this draft" },
});
view.rerender(<TaskLayout workspaceId={null} workflowId={null} />);
view.rerender(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />);
expect((screen.getByRole("textbox", { name: "Layout draft" }) as HTMLInputElement).value).toBe(
"Keep this draft",
);
});
it("retains picker drafts through missing workspace metadata and resets for a different workspace", () => {
const view = render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />, {
wrapper: StateProvider,
});
const draft = () => screen.getByRole("textbox", { name: "Picker draft" }) as HTMLInputElement;
fireEvent.change(draft(), { target: { value: "Keep picker draft" } });
view.rerender(<TaskLayout workspaceId={null} workflowId={null} />);
expect(draft().value).toBe("Keep picker draft");
view.rerender(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />);
expect(draft().value).toBe("Keep picker draft");
view.rerender(<TaskLayout workspaceId="ws-2" workflowId="wf-2" />);
expect(draft().value).toBe("");
});
it("hands the repository label to the phone layout", () => {
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" repositoryLabel="kdlbs/kandev" />);
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" repositoryLabel="kdlbs/kandev" />, {
wrapper: StateProvider,
});
expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe(
"kdlbs/kandev",
@@ -37,7 +95,7 @@ describe("TaskLayout repository label", () => {
});
it("passes nothing along for a task with no repository", () => {
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />);
render(<TaskLayout workspaceId="ws-1" workflowId="wf-1" />, { wrapper: StateProvider });
expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe("");
});
+21 -2
View File
@@ -1,10 +1,12 @@
"use client";
import { memo, useCallback } from "react";
import { memo, useCallback, useState } from "react";
import dynamic from "@/lib/routing/client-dynamic";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useRouter } from "@/lib/routing/client-router";
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
import { TaskSheetSelectionProvider } from "./mobile/task-sheet-selection-context";
import { ResponsiveTaskPicker } from "./mobile/responsive-task-picker";
import { SessionMobileLayout, SessionTabletLayout } from "./mobile";
import type { Repository, RepositoryScript } from "@/lib/types/http";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
@@ -49,7 +51,24 @@ type TaskLayoutProps = {
taskCanvases?: Canvas[];
};
export const TaskLayout = memo(function TaskLayout({
export const TaskLayout = memo(function TaskLayout(props: TaskLayoutProps) {
const [pickerWorkspaceId, setPickerWorkspaceId] = useState(props.workspaceId);
if (props.workspaceId && props.workspaceId !== pickerWorkspaceId) {
setPickerWorkspaceId(props.workspaceId);
}
return (
<TaskSheetSelectionProvider workspaceId={props.workspaceId}>
<ResponsiveTaskLayout {...props} />
<ResponsiveTaskPicker
key={pickerWorkspaceId}
workspaceId={props.workspaceId}
workflowId={props.workflowId}
/>
</TaskSheetSelectionProvider>
);
});
const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({
taskId = null,
workspaceId,
workflowId,
@@ -1,5 +1,6 @@
"use client";
import { useMobileListingOptions } from "@/components/kanban/mobile-listing-options-context";
import { useMemo, useRef, useState } from "react";
import {
IconAdjustments,
@@ -319,6 +320,7 @@ function MobileThreadsViewControls({
onDismissSyncError,
}: MobileThreadsViewControlsProps) {
const { t } = useTranslation();
const inlineOptions = useMobileListingOptions();
const { isMobile } = useResponsiveBreakpoint();
const activeViewName = threadViewName(activeView, t);
const [open, setOpen] = useState(false);
@@ -336,6 +338,10 @@ function MobileThreadsViewControls({
}
function closeDrawer() {
if (inlineOptions) {
inlineOptions.close();
return;
}
setOpen(false);
triggerRef.current?.focus();
}
@@ -345,6 +351,81 @@ function MobileThreadsViewControls({
closeDrawer();
}
const viewBody = (
<>
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-2 text-left">
<div className="flex items-center gap-2">
{page === "editor" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 shrink-0 cursor-pointer"
onClick={() => setPage("views")}
aria-label={t("threads:backToViewEditor")}
data-testid="threads-mobile-view-back"
>
<IconArrowLeft className="h-4 w-4" />
</Button>
)}
<DrawerTitle className="text-base">
{page === "editor" ? t("threads:viewSettings") : t("threads:title")}
</DrawerTitle>
</div>
</DrawerHeader>
<div
className={inlineOptions ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto overscroll-contain"}
data-testid="threads-mobile-view-drawer-scroll-region"
>
{syncError && isMobile && (
<ThreadViewSyncError
error={syncError}
mobile
onRetry={onRetrySync}
onDismiss={onDismissSyncError}
/>
)}
{page === "views" ? (
<MobileThreadViewList
activeView={activeView}
views={views}
hasDraft={!!draft}
admittedCount={admittedCount}
matchingCount={matchingCount}
hiddenCount={hiddenCount}
disabledReason={disabledReason}
onSelect={selectView}
onNewView={startNewView}
onOpenSettings={() => setPage("editor")}
/>
) : (
<ThreadsViewEditor
activeView={activeView}
draft={draft}
candidates={candidates}
repositories={repositories}
viewCount={viewCount}
canDelete={canDelete}
mobile
gridHeightFallback={gridHeightFallback}
onUpdate={onUpdate}
onSave={onSave}
onSaveAs={onSaveAs}
onDiscard={onDiscard}
onRename={onRename}
onDelete={(viewId) => {
onDelete(viewId);
closeDrawer();
}}
onDuplicate={onDuplicate}
onReapplySort={onReapplySort}
/>
)}
</div>
</>
);
if (inlineOptions) return <section data-testid="threads-mobile-view-drawer">{viewBody}</section>;
return (
<>
{syncError && !isMobile && (
@@ -415,77 +496,7 @@ function MobileThreadsViewControls({
}
}}
>
<MobileConfirmationHostBody>
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-2 text-left">
<div className="flex items-center gap-2">
{page === "editor" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 shrink-0 cursor-pointer"
onClick={() => setPage("views")}
aria-label={t("threads:backToViewEditor")}
data-testid="threads-mobile-view-back"
>
<IconArrowLeft className="h-4 w-4" />
</Button>
)}
<DrawerTitle className="text-base">
{page === "editor" ? t("threads:viewSettings") : t("threads:title")}
</DrawerTitle>
</div>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-testid="threads-mobile-view-drawer-scroll-region"
>
{syncError && isMobile && (
<ThreadViewSyncError
error={syncError}
mobile
onRetry={onRetrySync}
onDismiss={onDismissSyncError}
/>
)}
{page === "views" ? (
<MobileThreadViewList
activeView={activeView}
views={views}
hasDraft={!!draft}
admittedCount={admittedCount}
matchingCount={matchingCount}
hiddenCount={hiddenCount}
disabledReason={disabledReason}
onSelect={selectView}
onNewView={startNewView}
onOpenSettings={() => setPage("editor")}
/>
) : (
<ThreadsViewEditor
activeView={activeView}
draft={draft}
candidates={candidates}
repositories={repositories}
viewCount={viewCount}
canDelete={canDelete}
mobile
gridHeightFallback={gridHeightFallback}
onUpdate={onUpdate}
onSave={onSave}
onSaveAs={onSaveAs}
onDiscard={onDiscard}
onRename={onRename}
onDelete={(viewId) => {
onDelete(viewId);
closeDrawer();
}}
onDuplicate={onDuplicate}
onReapplySort={onReapplySort}
/>
)}
</div>
</MobileConfirmationHostBody>
<MobileConfirmationHostBody>{viewBody}</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
+5
View File
@@ -1269,6 +1269,11 @@ export class ApiClient {
default_utility_agent_id?: string;
default_utility_model?: string;
default_utility_agent_profile_id?: string;
sidebar_task_prefs?: {
pinned_task_ids?: string[];
ordered_task_ids?: string[];
subtask_order_by_parent_id?: Record<string, string[]>;
};
sidebar_view_state?: {
workspace_id: string;
views?: unknown[];
+7 -3
View File
@@ -7,6 +7,8 @@ export class MobileKanbanPage {
readonly mobileSearchToggle: Locator;
readonly mobileMenuButton: Locator;
readonly menuCard: Locator;
readonly viewOptionsButton: Locator;
readonly optionsCard: Locator;
readonly swimlaneContainer: Locator;
readonly boardNavigator: Locator;
@@ -15,8 +17,10 @@ export class MobileKanbanPage {
this.mobileFab = page.getByTestId("mobile-fab");
this.mobileSearchBar = page.getByTestId("mobile-search-bar");
this.mobileSearchToggle = page.getByTestId("mobile-search-toggle");
this.mobileMenuButton = page.getByRole("button", { name: "Open menu" });
this.menuCard = page.getByTestId("mobile-home-menu-card");
this.mobileMenuButton = page.getByTestId("app-nav-trigger");
this.menuCard = page.getByTestId("app-nav-sheet");
this.viewOptionsButton = page.getByTestId("mobile-topbar-page-context");
this.optionsCard = page.getByTestId("mobile-home-menu-card");
this.swimlaneContainer = page.getByTestId("swimlane-container");
this.boardNavigator = page.getByTestId("mobile-board-navigator");
}
@@ -55,7 +59,7 @@ export class MobileKanbanPage {
}
async openSearch() {
await this.mobileMenuButton.click();
await this.viewOptionsButton.click();
await this.mobileSearchToggle.click();
await this.mobileSearchBar.waitFor({ state: "visible" });
}
+1 -1
View File
@@ -49,7 +49,7 @@ export class SessionPage {
return this.page.getByTestId("port-forwarding-menu-item");
}
get mobileSessionMenu() {
return this.page.getByTestId("mobile-session-menu");
return this.page.getByTestId("mobile-task-picker-trigger");
}
get mobilePortForwardingToggle() {
return this.page.getByTestId("mobile-port-forwarding-toggle");
@@ -422,7 +422,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
await testPage.goto("/");
await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 20_000 });
const menuButton = testPage.getByRole("button", { name: "Open menu" });
const menuButton = testPage.getByTestId("app-nav-trigger");
await expect(menuButton).toBeVisible();
await menuButton.tap();
@@ -82,7 +82,7 @@ export async function expectSendNowWorkflowRunning(
expect(runningTask.workflow_step_id).toBe(working.id);
await expect(session.cancelAgentButton()).toBeVisible();
if (mobile) await page.getByTestId("mobile-session-menu").tap();
if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap();
const surface = mobile
? page.getByRole("dialog", { name: "Tasks", exact: true })
: session.sidebar;
@@ -10,7 +10,7 @@ import {
async function openMobileQuickChat(page: Page): Promise<Locator> {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByTestId("mobile-topbar-menu").tap();
await page.getByTestId("app-nav-trigger").tap();
await page.getByTestId("mobile-quick-chat-button").tap();
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
@@ -20,7 +20,7 @@ function mobileOverflowScript(prefix: string): string {
}
async function switchMobileTask(testPage: Page, title: string): Promise<void> {
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title });
await expect(taskRow).toBeVisible({ timeout: 15_000 });
@@ -75,7 +75,7 @@ async function createTask(apiClient: ApiClient, seedData: SeedData, title: strin
}
async function switchMobileTask(testPage: Page, title: string) {
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title });
await expect(taskRow).toBeVisible({ timeout: 15_000 });
@@ -161,6 +161,8 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as
await waitForComposerQueueMode(testPage);
const queued = oversizedMessage("MOBILE-QUEUED");
const identity = await apiClient.getQueueSessionIdentity(task.id, task.session_id);
// Keep the preview available even if the agent finishes during the touch checks.
await apiClient.setQueueAutoRun(identity, false);
await apiClient.queueMessage(identity, queued.source);
await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 });
await chat.getByTestId("queue-chip").tap();
@@ -37,7 +37,7 @@ test.describe.serial("Mobile Quick Chat cancellation", () => {
}) => {
test.setTimeout(120_000);
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" });
@@ -66,7 +66,7 @@ test.describe.serial("Mobile Quick Chat cancellation", () => {
}) => {
test.setTimeout(120_000);
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" });
@@ -25,7 +25,7 @@ test.describe("Quick Chat entry points on mobile", () => {
// @covers AC-UI-QUICK-CHAT-VIEWPORT-LAYOUT-001.5 AC-UI-QUICK-CHAT-VIEWPORT-LAYOUT-001.6
test("preserves composer containment at the bottom of the phone dialog", async ({ testPage }) => {
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
@@ -64,7 +64,7 @@ test.describe("Quick Chat entry points on mobile", () => {
await testPage.waitForLoadState("networkidle");
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
@@ -84,12 +84,12 @@ test.describe("Quick Chat entry points on mobile", () => {
await dialog.getByTestId("quick-chat-close").tap();
await expect(dialog).not.toBeVisible();
await expect(testPage.getByTestId("mobile-topbar-menu")).toBeFocused();
await expect(testPage.getByTestId("app-nav-trigger")).toBeFocused();
});
test("chooses configuration mode from the setup panel", async ({ testPage }) => {
await testPage.goto("/");
const context = testPage.getByTestId("mobile-topbar-page-context");
const context = testPage.getByTestId("app-nav-trigger");
await context.tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
@@ -119,7 +119,7 @@ test.describe("Quick Chat entry points on mobile", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
await expect(sheet).toBeVisible();
await testPage.getByTestId("mobile-sheet-quick-chat").tap();
@@ -142,7 +142,7 @@ test.describe("Quick Chat entry points on mobile", () => {
await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`);
await testPage.waitForLoadState("networkidle");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
const homeLink = testPage.getByRole("link", { name: "Home", exact: true });
await expect(homeLink).toHaveAttribute(
"href",
@@ -16,7 +16,7 @@ test.describe("quick chat activity indicators", () => {
}) => {
const ws = watchWs(testPage);
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Quick Chat" });
const created = testPage.waitForResponse(
@@ -32,7 +32,7 @@ test.describe("quick chat activity indicators", () => {
task_id: string;
};
const tab = dialog.getByTestId("quick-chat-tab");
const button = testPage.getByTestId("mobile-topbar-menu");
const button = testPage.getByTestId("app-nav-trigger");
const indicator = button.getByTestId("quick-chat-activity-indicator");
await expect(tab).toHaveCount(1);
@@ -74,7 +74,7 @@ test.describe("quick chat activity indicators", () => {
await testPage.goto(`/t/${seeded.task_id}`);
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const entry = sheet.getByTestId("mobile-sheet-quick-chat");
await expect(entry.getByTestId("quick-chat-activity-indicator")).toHaveCount(0);
@@ -99,7 +99,7 @@ test.describe("quick chat activity indicators", () => {
await expect(dialog.getByTestId("quick-chat-tab").getByRole("status")).toBeVisible();
await dialog.getByTestId("quick-chat-close").tap();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const openEntry = testPage
.getByRole("dialog", { name: "Tasks" })
.getByTestId("mobile-sheet-quick-chat");
@@ -110,7 +110,7 @@ test.describe("quick chat activity indicators", () => {
await testPage.keyboard.press("Escape");
await Promise.all([completed, settled]);
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const finishedEntry = testPage
.getByRole("dialog", { name: "Tasks" })
.getByTestId("mobile-sheet-quick-chat");
@@ -6,7 +6,7 @@ import { quickChatTabReferences, startQuickChatFromSetup } from "./quick-chat-he
async function openMobileQuickChat(page: Page): Promise<Locator> {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByTestId("mobile-topbar-menu").tap();
await page.getByTestId("app-nav-trigger").tap();
await page.getByTestId("mobile-quick-chat-button").tap();
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 10_000 });
@@ -8,7 +8,7 @@ import { waitForStableActiveSession } from "../../helpers/session-store";
const MOBILE_END_TOLERANCE_PX = 10;
async function switchMobileTask(testPage: Page, title: string): Promise<void> {
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title });
await expect(taskRow).toBeVisible({ timeout: 15_000 });
@@ -21,7 +21,7 @@ type SavedPromptDeliveryScenarioOptions = {
export async function openMobileQuickChatSetup(page: Page): Promise<Locator> {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByTestId("mobile-topbar-menu").tap();
await page.getByTestId("app-nav-trigger").tap();
await page.getByTestId("mobile-quick-chat-button").tap();
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
@@ -78,7 +78,10 @@ test("nests multi-repository changes below each repository on desktop and mobile
await expectTreeNestedUnderRepository(session.changes);
await testPage.setViewportSize({ width: 393, height: 851 });
await testPage.getByRole("button", { name: "Changes" }).click();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.click();
const mobilePanel = testPage.getByTestId("mobile-changes-panel");
await expect(mobilePanel).toBeVisible();
await expectTreeNestedUnderRepository(mobilePanel);
@@ -52,7 +52,10 @@ test.describe("Mobile empty remote repository", () => {
const taskCommit = git.commit("Add empty remote mobile fixture");
const taskBranch = git.exec("git branch --show-current").trim();
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible({ timeout: 15_000 });
const commitsToggle = changesPanel.getByTestId("commits-section-collapse-toggle");
@@ -38,10 +38,15 @@ test.describe("Mobile fork pull-request comparison target", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await expect(testPage.getByRole("button", { name: "Changes" })).toBeVisible({
await expect(
testPage.getByRole("navigation").getByRole("button", { name: /Changes$/ }),
).toBeVisible({
timeout: 15_000,
});
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
await expect(changes).toBeVisible({ timeout: 15_000 });
@@ -87,10 +92,15 @@ test.describe("Mobile fork pull-request comparison target", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await expect(testPage.getByRole("button", { name: "Changes" })).toBeVisible({
await expect(
testPage.getByRole("navigation").getByRole("button", { name: /Changes$/ }),
).toBeVisible({
timeout: 15_000,
});
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
await expect(changes).toBeVisible({ timeout: 15_000 });
@@ -108,8 +118,11 @@ test.describe("Mobile fork pull-request comparison target", () => {
).toBeVisible();
comparisonTargetFixture?.setAvailable(true);
await testPage.getByRole("button", { name: "Chat" }).tap();
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage.getByRole("button", { name: "Chat", exact: true }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
await expect(changes.getByTestId("comparison-target-notice")).toHaveCount(0, {
timeout: 30_000,
});
@@ -43,7 +43,10 @@ test.describe("Mobile local-only Git operations", () => {
const session = await openTaskSession(testPage, task.id);
await session.waitForChatIdle({ timeout: 45_000 });
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
const pullMenuTrigger = changes.getByRole("button", { name: /^Pull/ });
await expect(pullMenuTrigger).toBeVisible();
@@ -320,7 +320,10 @@ test.describe("Mobile rewritten contribution history", () => {
await session.waitForChatIdle({ timeout: 45_000 });
git.exec(`git checkout -B ${providerBranch} ${localHead}`);
git.exec(`git branch --set-upstream-to=origin/${providerBranch} ${providerBranch}`);
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0);
@@ -567,7 +570,10 @@ test.describe("Mobile rewritten contribution history", () => {
await testPage.reload();
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
const providerSection = changes.getByTestId("current-pr-commits-section");
@@ -23,7 +23,10 @@ test.describe("Mobile PR file row containment", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
await expect(changes).toBeVisible();
@@ -39,13 +39,8 @@ test("GitHub app navigation opens shared task views and preserves browser Back",
await github.goto();
const opener = testPage.getByTestId("app-nav-trigger");
await opener.tap();
await testPage
.getByTestId("app-nav-sheet")
.getByRole("button", { name: "Task views", exact: true })
.tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
const drawer = testPage.getByTestId("app-nav-sheet");
await expect(drawer).toBeVisible();
await expect(testPage.getByTestId("app-nav-sheet")).toBeHidden();
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
await expect(
drawer.getByTestId("sidebar-view-chip").filter({ hasText: view.name }),
@@ -55,10 +50,6 @@ test("GitHub app navigation opens shared task views and preserves browser Back",
await expect(drawer).toBeHidden();
await expect(opener).toBeFocused();
await opener.tap();
await testPage
.getByTestId("app-nav-sheet")
.getByRole("button", { name: "Task views", exact: true })
.tap();
await drawer.locator(`[data-task-row-id="${task.task_id}"]`).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`));
await testPage.goBack();
@@ -66,12 +57,8 @@ test("GitHub app navigation opens shared task views and preserves browser Back",
await expect(github.mobileMenuButton).toBeVisible();
// The task remains selected in shared state, but GitHub has no task-only providers.
await opener.tap();
await testPage
.getByTestId("app-nav-sheet")
.getByRole("button", { name: "Task views", exact: true })
.tap();
await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible();
await drawer.getByRole("button", { name: "New", exact: true }).tap();
await drawer.getByRole("button", { name: "New task", exact: true }).tap();
await expect(drawer).toBeHidden();
await expect(testPage.getByRole("dialog")).toBeVisible();
await expect(testPage.getByRole("dialog")).not.toHaveAccessibleName("Tasks");
@@ -97,11 +84,10 @@ test("Kanban navigation can open empty task views and return focus", async ({ te
const kanban = new MobileKanbanPage(testPage);
await kanban.goto();
await kanban.mobileMenuButton.tap();
await kanban.menuCard.getByRole("button", { name: "Task views", exact: true }).tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
const drawer = testPage.getByTestId("app-nav-sheet");
await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible();
await expect(drawer.locator('[data-slot="task-switcher-empty-state"]')).toBeVisible();
await expect(kanban.menuCard).toBeHidden();
await expect(kanban.menuCard).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(kanban.mobileMenuButton).toBeFocused();
@@ -302,7 +302,7 @@ test.describe("Mobile GitLab parity", () => {
await session.waitForLoad();
await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0);
await testPage.getByTestId("mobile-session-menu").click();
await testPage.getByTestId("mobile-task-picker-trigger").click();
const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: title });
await expect(taskRow).toBeVisible({ timeout: 10_000 });
@@ -477,7 +477,10 @@ test.describe("Mobile GitLab parity", () => {
).toBeVisible({
timeout: 45_000,
});
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changes = testPage.getByTestId("mobile-changes-panel");
const createMR = changes.getByTestId("commits-repo-create-pr");
await expect(createMR).toBeVisible();
@@ -96,8 +96,8 @@ test.describe("mobile GitLab MR badge on the /tasks list rows", () => {
// Mobile has no `display-button`; the display menu lives behind the
// "Open menu" drawer (see mobile-task-listing-display.spec.ts).
await testPage.getByRole("button", { name: "Open menu" }).tap();
const menu = testPage.getByRole("dialog", { name: "Menu" });
await testPage.getByTestId("mobile-topbar-page-context").tap();
const menu = testPage.getByRole("dialog", { name: "View options" });
await expandDisplaySettingsGroup(testPage, "list-rows", "mobile");
await menu.getByText("Show task details", { exact: true }).click();
await testPage.keyboard.press("Escape");
@@ -31,6 +31,8 @@ test.describe("Integrations section on mobile", () => {
const sheet = testPage.getByRole("dialog");
await expect(sheet.getByText("Integrations", { exact: true })).toBeVisible();
await sheet.getByRole("button", { name: "Integrations", exact: true }).click();
const githubLink = sheet.getByRole("link", { name: "GitHub" });
await expect(githubLink).toBeVisible({ timeout: 10_000 });
expect(await githubLink.getAttribute("href")).toBe("/github");
@@ -5,16 +5,16 @@ import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
const TASK_TITLE = "Mobile auto-hide move source";
async function openMobileMenu(mobile: MobileKanbanPage) {
await mobile.mobileMenuButton.click();
await mobile.menuCard.waitFor({ state: "visible" });
await mobile.viewOptionsButton.click();
await mobile.optionsCard.waitFor({ state: "visible" });
}
async function closeMobileMenu(page: Page, mobile: MobileKanbanPage) {
await expect(async () => {
if ((await mobile.menuCard.count()) > 0) {
if ((await mobile.optionsCard.count()) > 0) {
await page.keyboard.press("Escape");
}
await expect(mobile.menuCard).toHaveCount(0, { timeout: 1_000 });
await expect(mobile.optionsCard).toHaveCount(0, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
@@ -23,7 +23,7 @@ async function openColumnsMenu(
mobile: MobileKanbanPage,
workflowId: string,
): Promise<Locator> {
const trigger = mobile.menuCard.getByTestId(`columns-menu-${workflowId}`);
const trigger = mobile.optionsCard.getByTestId(`columns-menu-${workflowId}`);
await trigger.click();
await expect(trigger).toHaveAttribute("data-state", "open");
const menu = page.locator('[role="menu"]:visible');
@@ -72,7 +72,7 @@ test("keeps auto-hide scoped and moves through the mobile card menu", async ({
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await openMobileMenu(mobile);
await expect(mobile.menuCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0);
await expect(mobile.optionsCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0);
const columnsMenu = await openColumnsMenu(testPage, mobile, workflow.id);
const autoHideToggle = columnsMenu.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`);
@@ -11,7 +11,7 @@ const TASK_CRITICAL = "Mobile sort task critical";
const TASK_MEDIUM = "Mobile sort task medium";
async function openMobileMenu(testPage: import("@playwright/test").Page) {
await testPage.getByRole("button", { name: "Open menu" }).tap();
await testPage.getByTestId("mobile-topbar-page-context").tap();
await testPage.getByTestId("mobile-home-menu-card").waitFor({ state: "visible" });
}
@@ -10,7 +10,7 @@ import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
const PRIORITY_TOKENS = ["critical", "high", "medium", "low"] as const;
async function openMobileMenu(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Open menu" }).tap();
await page.getByTestId("mobile-topbar-page-context").tap();
const card = page.getByTestId("mobile-home-menu-card");
await card.waitFor({ state: "visible" });
return card;
@@ -7,8 +7,8 @@ import { waitForLatestSessionDone } from "../../helpers/session";
test.describe("Shared phone listing topbar", () => {
test("returns focus to the menu button when search is hidden", async ({ testPage }) => {
await testPage.goto("/tasks");
const opener = testPage.getByTestId("mobile-topbar-menu");
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const opener = testPage.getByTestId("mobile-topbar-page-context");
const menu = testPage.getByRole("dialog", { name: "View options", exact: true });
const search = testPage.getByPlaceholder("Search tasks...");
await opener.tap();
await menu.getByTestId("mobile-search-toggle").tap();
@@ -72,7 +72,7 @@ test.describe("Shared phone listing topbar", () => {
});
const header = testPage.getByTestId("threads-mobile-topbar");
const trigger = header.getByTestId("threads-mobile-view-trigger");
const trigger = header.getByTestId("mobile-topbar-page-context");
const drawer = testPage.getByTestId("threads-mobile-view-drawer");
await trigger.tap();
const failedWrite = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, {
@@ -89,7 +89,7 @@ test.describe("Shared phone listing topbar", () => {
expect((await requireBox(header, "header during sync failure")).height).toBe(56);
const cue = header.getByTestId("thread-swipe-cue");
await expect(cue).toHaveText("1/2");
for (const target of [trigger, header.getByTestId("mobile-topbar-menu")]) {
for (const target of [trigger, header.getByTestId("app-nav-trigger")]) {
expect(
await target.evaluate((element) => {
const box = element.getBoundingClientRect();
@@ -100,7 +100,7 @@ test.describe("Shared phone listing topbar", () => {
).toBe(true);
}
const cueBox = await requireBox(cue, "pagination during sync failure");
const menuBox = await requireBox(header.getByTestId("mobile-topbar-menu"), "menu");
const menuBox = await requireBox(header.getByTestId("app-nav-trigger"), "menu");
expect(cueBox.x + cueBox.width).toBeLessThanOrEqual(menuBox.x);
await assertNoDocumentHorizontalOverflow(testPage, "saved-view sync failure");
@@ -127,7 +127,9 @@ test.describe("Shared phone listing topbar", () => {
await retried;
await expect(recovery).toHaveCount(0);
await testPage.reload();
await expect(trigger).toContainText("New view");
await trigger.tap();
await expect(drawer).toContainText("New view");
await testPage.keyboard.press("Escape");
await expect(header.getByTestId("threads-mobile-view-sync-status")).toHaveCount(0);
});
@@ -140,21 +142,19 @@ test.describe("Shared phone listing topbar", () => {
for (const [route, label] of [
["/?home=overview", "Kanban"],
["/tasks", "List"],
["/threads", "All threads"],
["/threads", "Threads"],
]) {
if (label === "Kanban") {
await testPage.goto("/tasks");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("mobile-topbar-page-context").tap();
await testPage.getByRole("radio", { name: "Kanban", exact: true }).tap();
await expect(testPage.getByRole("dialog")).toHaveCount(0);
} else {
await testPage.goto(route);
}
const header = testPage.locator("header").first();
const context = header.getByTestId(
label === "All threads" ? "threads-mobile-view-trigger" : "mobile-topbar-page-context",
);
const menu = header.getByTestId("mobile-topbar-menu");
const context = header.getByTestId("mobile-topbar-page-context");
const menu = header.getByTestId("app-nav-trigger");
await expect(context).toContainText(label);
await expect(header.getByTestId("mobile-topbar-brand")).toHaveCount(0);
await expect(header.getByTestId("mobile-topbar-action-strip")).toHaveCount(0);
@@ -193,7 +193,7 @@ test.describe("Shared phone listing topbar", () => {
testPage,
}) => {
await testPage.goto("/tasks");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
const dialog = testPage.getByRole("dialog", { name: "Menu", exact: true });
await dialog.getByRole("link", { name: "Home", exact: true }).tap();
await expect(testPage).toHaveURL(
@@ -208,7 +208,7 @@ test.describe("Shared phone listing topbar", () => {
await testPage.goto("/?home=overview");
const header = testPage.locator("header").first();
await expect(header.getByTestId("mobile-topbar-page-context")).toHaveCount(0);
await expect(header.getByTestId("mobile-topbar-menu")).toHaveCount(0);
await expect(header.getByTestId("app-nav-trigger")).toHaveCount(0);
await expect(header).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "tablet listing header");
const menu = header.getByRole("button", { name: "Open menu", exact: true });
@@ -239,13 +239,15 @@ test.describe("Shared phone listing topbar", () => {
expect(await label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(
true,
);
const menu = testPage.getByTestId("mobile-topbar-menu");
const menu = testPage.getByTestId("app-nav-trigger");
const box = await requireBox(menu, "menu beside long workspace");
expect(box.width).toBe(44);
expect(box.x + box.width).toBeLessThanOrEqual(360);
await assertNoDocumentHorizontalOverflow(testPage, "long workspace context");
await context.tap();
await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeVisible();
await expect(
testPage.getByRole("dialog", { name: "View options", exact: true }),
).toBeVisible();
} finally {
await apiClient.deleteWorkspace(workspace.id, name);
}
+12 -10
View File
@@ -24,7 +24,7 @@ test.describe("Mobile kanban view", () => {
await mobile.goto();
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("app-nav-trigger").click();
await testPage.getByTestId("mobile-home-status-button").click();
await expect(testPage.getByTestId("app-status-drawer")).toBeVisible();
await expect(testPage.getByTestId("app-status-metrics")).toBeVisible();
@@ -373,7 +373,7 @@ test.describe("Mobile kanban view", () => {
await mobile.goto();
await expect(mobile.mobileKanbanLayout()).toBeVisible();
await mobile.mobileMenuButton.click();
await mobile.viewOptionsButton.click();
const menuCard = testPage.getByTestId("mobile-home-menu-card");
await expect(menuCard).toBeVisible();
await expect(menuCard.getByText("Pipeline", { exact: true })).toHaveCount(0);
@@ -409,7 +409,7 @@ test.describe("Mobile kanban view", () => {
kanban_view_mode: "graph2",
});
await testPage.goto("/tasks");
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("mobile-topbar-page-context").click();
const menuCard = testPage.getByTestId("mobile-home-menu-card");
await menuCard.getByText("Kanban", { exact: true }).click();
@@ -447,7 +447,7 @@ test.describe("Mobile kanban view", () => {
await expect(mobile.searchInput()).toBeFocused();
// Toggling search in the menu again collapses the search bar.
await mobile.mobileMenuButton.click();
await mobile.viewOptionsButton.click();
await mobile.mobileSearchToggle.click();
await expect(mobile.mobileSearchBar).not.toBeVisible();
});
@@ -470,7 +470,7 @@ test.describe("Mobile kanban view", () => {
await expect(mobile.taskCardByTitle("Other Beta")).not.toBeVisible({ timeout: 5000 });
// Collapsing clears the query so the full list is shown again
await mobile.mobileMenuButton.click();
await mobile.viewOptionsButton.click();
await mobile.mobileSearchToggle.click();
await expect(mobile.mobileSearchBar).not.toBeVisible();
await expect(mobile.taskCardByTitle("Clearable Alpha")).toBeVisible({ timeout: 5000 });
@@ -484,9 +484,12 @@ test.describe("Mobile kanban view", () => {
await expect(mobile.mobileMenuButton).toBeVisible();
await mobile.mobileMenuButton.click();
// Menu sheet should open with display options
// App navigation has one meaning; listing options have a separate entry.
await expect(testPage.getByRole("heading", { name: "Menu" })).toBeVisible();
await expect(testPage.getByText("Display Options")).toBeVisible();
await expect(
testPage.getByTestId("app-nav-sheet").getByRole("link", { name: "Home", exact: true }),
).toBeVisible();
await expect(testPage.getByText("Display Options")).toHaveCount(0);
});
test("switches workspaces from the mobile menu", async ({ testPage, apiClient }) => {
@@ -517,7 +520,7 @@ test.describe("Mobile kanban view", () => {
await mobile.goto();
await mobile.mobileMenuButton.click();
await testPage.getByRole("link", { name: "Settings" }).click();
await testPage.getByRole("link", { name: "Settings", exact: true }).click();
await expect(testPage).toHaveURL(/\/settings(?:\/general)?$/);
await expect(testPage.getByRole("link", { name: /Appearance/ })).toBeVisible();
@@ -532,8 +535,7 @@ test.describe("Mobile kanban view", () => {
const searchInput = dialog.getByPlaceholder("Search tasks...");
await expect(searchInput).toHaveCount(0);
await expect(mobile.mobileSearchToggle).toBeVisible();
await expect(mobile.mobileSearchToggle).not.toBeFocused();
await expect(mobile.mobileSearchToggle).toHaveCount(0);
await expect(mobile.mobileSearchBar).not.toBeVisible();
});
@@ -12,7 +12,7 @@ const ORPHAN_STEP_ID = "__kandev_orphan__";
const MIN_TOUCH_TARGET_PX = 44;
async function openMobileMenu(testPage: Page) {
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("mobile-topbar-page-context").click();
await testPage.getByTestId("mobile-home-menu-card").waitFor({ state: "visible" });
}
@@ -45,7 +45,7 @@ test.describe("mobile task title hover on the Kanban card", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const taskSwitcher = testPage.getByRole("dialog", { name: "Tasks" });
await expect(taskSwitcher.getByText(childTitle)).toBeVisible({ timeout: 15_000 });
@@ -83,7 +83,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn
await testPage.goto(`/t/${anchor.task_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const sourceRow = sheet.getByTestId("sidebar-task-item").filter({
hasText: "Mobile Moved Queue",
@@ -98,7 +98,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn
.toBe(reviewStep.id);
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0);
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const queuedSourceRow = testPage
.getByRole("dialog", { name: "Tasks" })
.getByTestId("sidebar-task-item")
@@ -122,7 +122,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn
await testPage.goto(`/t/${anchor.task_id}`);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const admittedRow = testPage
.getByRole("dialog", { name: "Tasks" })
.getByTestId("sidebar-task-item")
@@ -136,7 +136,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn
.toBe(reviewStep.id);
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0);
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
await expect(
testPage
.getByRole("dialog", { name: "Tasks" })
@@ -61,7 +61,7 @@ test.describe("compact desktop responsive layout", () => {
await expect(testPage.getByTestId("dockview-task-layout")).toHaveCount(0);
await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0);
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible();
await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible();
await expect(session.activeChat()).toBeVisible();
await testPage.getByRole("button", { name: "Status", exact: true }).click();
@@ -53,7 +53,7 @@ test.describe("Mobile App status bar preference", () => {
.toBe(false);
await testPage.goto("/");
await testPage.getByRole("button", { name: "Open menu" }).tap();
await testPage.getByTestId("app-nav-trigger").tap();
await expect(testPage.getByTestId("mobile-home-status-button")).toHaveCount(0);
await testPage.keyboard.press("Escape");
await testPage.goto("/stats");
@@ -123,7 +123,7 @@ test.describe("Mobile menu backdrops", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title });
const actions = row.getByRole("button", { name: "Task actions" });
@@ -0,0 +1,216 @@
import { test, expect } from "../../fixtures/test-base";
// @covers AC-UI-MOBILE-MENU-006.1 AC-UI-MOBILE-MENU-006.2
for (const route of ["/", "/tasks", "/threads"]) {
test(`Home owns ${route} and Tasks keeps an independent create action`, async ({ testPage }) => {
await testPage.goto(route);
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu.getByRole("link", { name: "Tasks", exact: true })).toHaveCount(0);
await expect(menu.getByRole("link", { name: "Threads", exact: true })).toHaveCount(0);
await expect(menu.getByRole("link", { name: "Home", exact: true })).toHaveAttribute(
"aria-current",
"page",
);
const toggle = menu.getByTestId("mobile-navigation-tasks-toggle");
await toggle.tap();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await menu.getByRole("button", { name: "New task", exact: true }).tap();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
});
}
// @covers AC-UI-MOBILE-MENU-006.3 AC-UI-MOBILE-MENU-006.4
test("empty sections retain automation and integration setup", async ({ testPage, seedData }) => {
await testPage.goto("/stats");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await menu.getByRole("button", { name: "Automations", exact: true }).tap();
await expect(menu.getByRole("link", { name: "Set up an automation", exact: true })).toBeVisible();
const toggle = menu.getByTestId("mobile-navigation-tasks-toggle");
const utilities = menu.getByText("Utilities", { exact: true });
const headingStyle = async (element: typeof toggle) =>
element.evaluate((el) => {
const style = getComputedStyle(el);
return {
x: el.getBoundingClientRect().x,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
};
});
expect(await headingStyle(toggle)).toEqual(await headingStyle(utilities));
for (const width of [393, 767]) {
await testPage.setViewportSize({ width, height: 851 });
for (const theme of ["first", "second"]) {
await testPage.getByTestId("mobile-theme-toggle-button").tap();
await toggle.scrollIntoViewIfNeeded();
await testPage.screenshot({ path: test.info().outputPath(`menu-${width}-${theme}.png`) });
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
).toBe(true);
}
}
const integrations = menu.getByRole("button", { name: "Integrations", exact: true });
await expect(integrations).toHaveAttribute("aria-expanded", "false");
await integrations.focus();
await testPage.keyboard.press("Enter");
await expect(integrations).toHaveAttribute("aria-expanded", "true");
const settings = menu.getByTestId("mobile-integration-settings");
await expect(settings).toBeVisible();
await settings.tap();
await expect(testPage).toHaveURL(
new RegExp(`/settings/workspaces/${seedData.workspaceId}/integrations`),
);
});
// @covers AC-UI-MOBILE-MENU-006.3
test("opens an automation from the shared menu", async ({ testPage, apiClient, seedData }) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Daily navigation check",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
prompt: "Review changes",
});
await testPage.goto("/stats");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await menu.getByRole("button", { name: "Automations", exact: true }).tap();
await menu.getByRole("link", { name: /Daily navigation check/ }).tap();
await expect(testPage).toHaveURL(new RegExp(`/automations/${automation.id}`));
await expect(menu).toBeHidden();
});
// @covers AC-UI-MOBILE-MENU-007.1 AC-UI-MOBILE-MENU-007.2 AC-UI-MOBILE-MENU-007.4
for (const surface of ["home", "workbench"]) {
test(`quick actions precede expandable content on ${surface}`, async ({
testPage,
apiClient,
seedData,
}) => {
const task =
surface === "workbench"
? await apiClient.seedTask(seedData.workspaceId, "Menu ordering", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
})
: null;
await testPage.goto(task ? `/t/${task.task_id}` : "/");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
const chat = menu.getByTestId("mobile-quick-chat-button");
const terminal = menu.getByTestId("mobile-quick-terminal-button");
const tasks = menu.getByTestId("mobile-navigation-tasks-toggle");
for (const width of [393, 767]) {
await testPage.setViewportSize({ width, height: 851 });
await menu.evaluate(async (element) => {
await Promise.all(
element
.getAnimations({ subtree: true })
.filter((animation) =>
Number.isFinite(animation.effect?.getComputedTiming().iterations),
)
.map((animation) => animation.finished.catch(() => undefined)),
);
});
const boxes = await Promise.all(
[menu.getByRole("link", { name: "Home", exact: true }), chat, terminal, tasks].map((item) =>
item.boundingBox(),
),
);
const [home, quickChat, quickTerminal, heading] = boxes.map((box) => {
expect(box).not.toBeNull();
return box!;
});
expect(home.y + home.height).toBeLessThanOrEqual(quickChat.y);
expect(quickChat.y).toBeCloseTo(quickTerminal.y);
expect(quickChat.x + quickChat.width).toBeLessThanOrEqual(quickTerminal.x);
expect(quickChat.y + quickChat.height).toBeLessThanOrEqual(heading.y);
for (const box of [quickChat, quickTerminal]) {
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.width).toBeGreaterThanOrEqual(44);
}
for (const button of [chat, terminal]) {
expect(await button.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
}
}
await tasks.tap();
const settings = menu.getByRole("link", { name: "Settings", exact: true });
const stats = menu.getByRole("link", { name: "Stats", exact: true });
expect((await settings.boundingBox())!.y).toBeLessThan((await stats.boundingBox())!.y);
await chat.tap();
await expect(menu).toBeHidden();
await expect(testPage.getByRole("dialog", { name: "Quick Chat", exact: true })).toBeVisible();
});
}
// @covers AC-UI-MOBILE-MENU-007.3
test("configured integrations collapse and retain navigation", async ({ testPage, apiClient }) => {
await apiClient.mockGitHubSetUser("menu-demo");
await testPage.goto("/");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
const toggle = menu.getByRole("button", { name: "Integrations", exact: true });
const github = menu.getByRole("link", { name: "GitHub", exact: true });
await expect(github).toBeHidden();
await toggle.tap();
await expect(github).toBeVisible();
await toggle.tap();
await expect(github).toBeHidden();
await toggle.tap();
await github.tap();
await expect(testPage).toHaveURL(/\/github$/);
await expect(menu).toBeHidden();
});
// @covers AC-UI-MOBILE-MENU-007.4
test("translated quick actions fit their phone targets", async ({ testPage }) => {
await testPage.goto("/");
await testPage.evaluate(() => {
document.cookie = "kandev_locale=pt-pt; path=/; SameSite=Lax";
});
await testPage.reload();
await expect(testPage.locator("html")).toHaveAttribute("lang", "pt-pt");
await testPage.getByTestId("app-nav-trigger").tap();
for (const width of [393, 767]) {
await testPage.setViewportSize({ width, height: 851 });
for (const id of ["mobile-quick-chat-button", "mobile-quick-terminal-button"]) {
const button = testPage.getByTestId(id);
await expect(button).toBeVisible();
expect(
await button.evaluate(
(el) => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight,
),
).toBe(true);
expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44);
}
await testPage.screenshot({ path: test.info().outputPath(`quick-actions-pt-${width}.png`) });
}
});
// @covers AC-UI-MOBILE-MENU-007.2 AC-UI-MOBILE-MENU-007.4
test("collapsed sections do not leave flexible space before Utilities", async ({ testPage }) => {
await testPage.setViewportSize({ width: 393, height: 1200 });
await testPage.goto("/");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await menu.getByTestId("mobile-navigation-tasks-toggle").tap();
await expect(menu.getByRole("button", { name: "Integrations", exact: true })).toHaveAttribute(
"aria-expanded",
"false",
);
const utilities = menu.getByText("Utilities", { exact: true }).locator("..");
for (const width of [393, 767]) {
await testPage.setViewportSize({ width, height: 1200 });
await expect
.poll(() =>
utilities.evaluate((el) => {
const preceding = el.previousElementSibling!;
return Math.round(
el.getBoundingClientRect().top - preceding.getBoundingClientRect().bottom,
);
}),
)
.toBe(24);
}
});
@@ -0,0 +1,118 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
// @covers AC-UI-MOBILE-MENU-004.1, 004.2, 004.3
test("shared navigation embeds the complete collapsible task sidebar", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.seedTask(seedData.workspaceId, "Embedded navigation task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
for (let index = 0; index < 5; index++) {
await apiClient.seedTask(seedData.workspaceId, `Another sidebar task ${index}`, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
}
await testPage.goto("/stats");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
const toggle = menu.getByTestId("mobile-navigation-tasks-toggle");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(menu.getByRole("button", { name: "Task views", exact: true })).toHaveCount(0);
await expect(menu.getByTestId("mobile-pinned-tasks")).toHaveCount(0);
await expect(menu.getByText("Embedded navigation task", { exact: true })).toBeVisible();
await toggle.tap();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(menu.getByText("Embedded navigation task", { exact: true })).toBeHidden();
await expect(menu.getByRole("link", { name: "Settings", exact: true })).toBeVisible();
await testPage.keyboard.press("Escape");
await testPage.getByTestId("app-nav-trigger").tap();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await toggle.tap();
await expect(menu.locator("[data-task-row-id]")).toHaveCount(6);
expect(
await menu
.getByTestId("mobile-task-switcher-list")
.evaluate((el) => getComputedStyle(el).overflowY),
).toBe("visible");
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.screenshot({ path: test.info().outputPath("embedded-tasks-393.png") });
await testPage.setViewportSize({ width: 767, height: 851 });
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.screenshot({ path: test.info().outputPath("embedded-tasks-767.png") });
await menu.getByText("Embedded navigation task", { exact: true }).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`));
await testPage.goBack();
await expect(testPage).toHaveURL(/\/stats$/);
});
// @covers AC-UI-MOBILE-MENU-005.1, 005.2, 005.3
for (const route of ["/", "/tasks", "/threads"]) {
test(`listing title opens all view options on ${route}`, async ({ testPage }) => {
await testPage.goto(route);
const trigger = testPage.getByTestId("mobile-topbar-page-context");
await expect(trigger).toBeVisible();
await expect(testPage.getByTestId("mobile-view-options-trigger")).toHaveCount(0);
await trigger.tap();
const options = testPage.getByRole("dialog", { name: "View options", exact: true });
await expect(options).toBeVisible();
await expect(options.getByRole("radio", { name: "Kanban", exact: true })).toBeVisible();
await expect(options.getByRole("radio", { name: "Threads", exact: true })).toBeVisible();
await expect(options.getByRole("radio", { name: "List", exact: true })).toBeVisible();
if (route === "/threads")
await expect(options.getByTestId("threads-mobile-view-list")).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(trigger).toBeFocused();
});
}
// @covers AC-UI-MOBILE-MENU-004.3
for (const status of [503, 403]) {
test(`embedded Tasks recovers from a ${status} workspace read`, async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.seedTask(seedData.workspaceId, "Workspace-scoped task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
let unavailable = true;
await testPage.route("**/api/v1/workflows?*", async (route) => {
if (unavailable) await route.fulfill({ status, json: { error: "unavailable" } });
else await route.continue();
});
await testPage.goto("/stats");
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu.getByTestId("sidebar-task-load-error")).toBeVisible();
await expect(menu.getByRole("link", { name: "Settings", exact: true })).toBeVisible();
if (status === 403)
await expect(menu.getByText("Workspace-scoped task", { exact: true })).toHaveCount(0);
unavailable = false;
await menu.getByRole("button", { name: "Retry", exact: true }).tap();
await expect(menu.getByTestId("sidebar-task-load-error")).toHaveCount(0);
await expect(menu.getByText("Workspace-scoped task", { exact: true })).toBeVisible();
});
}
// @covers AC-UI-MOBILE-MENU-004.1
test("embedded Tasks retains the current archived task outside archived views", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.seedTask(seedData.workspaceId, "Current archived navigation task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.archiveTask(task.task_id);
await testPage.goto(`/t/${task.task_id}`);
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu.getByText("Current archived navigation task", { exact: true })).toBeVisible();
});
@@ -18,7 +18,7 @@ test("phone navigation stays available by tap with no hover rail", async ({
await testPage.goto(`/t/${first.id}`);
await new SessionPage(testPage).waitForLoad();
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
await expect(drawer).toBeVisible();
await drawer.evaluate(async (element) => {
@@ -49,7 +49,7 @@ test("mobile task drawer retains rows while workspace context refresh recovers",
await failedWorkflowRead;
await session.waitForLoad();
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
await expect(drawer).toBeVisible();
await expect(drawer.getByText("Mobile retained task", { exact: true })).toBeVisible();
await expect(drawer.getByTestId("sidebar-task-load-error")).toBeVisible();
@@ -111,7 +111,7 @@ test("mobile task drawer surfaces a failed workflow snapshot and recovers", asyn
await failedSnapshotRead;
const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true });
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
await expect(drawer).toBeVisible();
await expect(drawer.getByTestId("sidebar-task-load-error")).toBeVisible();
await expect(drawer.getByText("No tasks yet.", { exact: true })).toHaveCount(0);
@@ -0,0 +1,143 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
// @covers AC-UI-MOBILE-MENU-001.1, AC-UI-MOBILE-MENU-001.2, AC-UI-MOBILE-MENU-001.5
test("same menu from listings and page shells", async ({ testPage }) => {
for (const path of ["/?home=overview", "/tasks", "/threads", "/stats"]) {
await testPage.goto(path);
const trigger = testPage.getByTestId("app-nav-trigger");
await trigger.tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu).toHaveAttribute("data-vaul-drawer-direction", "bottom");
await expect(menu.getByTestId("mobile-workspace-trigger")).toBeVisible();
for (const name of ["Home", "Settings"]) {
await expect(menu.getByRole("link", { name, exact: true })).toBeVisible();
}
await expect(menu.getByTestId("mobile-search-toggle")).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
}
});
// @covers AC-UI-MOBILE-MENU-001.4, AC-UI-MOBILE-MENU-001.6
test("separate listing controls preserve search behavior", async ({ testPage }) => {
await testPage.goto("/tasks");
const options = testPage.getByTestId("mobile-topbar-page-context");
await options.tap();
const sheet = testPage.getByRole("dialog", { name: "View options", exact: true });
await expect(sheet.getByRole("link", { name: "Settings", exact: true })).toHaveCount(0);
await testPage.screenshot({ path: test.info().outputPath("view-options-393.png") });
await sheet.getByTestId("mobile-search-toggle").tap();
const search = testPage.getByPlaceholder("Search tasks...");
await expect(search).toBeFocused();
await search.fill("checkout");
await options.tap();
await sheet.getByTestId("mobile-search-toggle").tap();
await expect(search).toBeHidden();
await expect(options).toBeFocused();
await options.tap();
await sheet.getByTestId("mobile-search-toggle").tap();
await expect(search).toHaveValue("");
await testPage.setViewportSize({ width: 767, height: 851 });
await options.tap();
await expect(sheet).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.screenshot({ path: test.info().outputPath("view-options-767.png") });
});
// @covers AC-UI-MOBILE-MENU-002.1, AC-UI-MOBILE-MENU-002.2, AC-UI-MOBILE-MENU-002.3
test("title picker switches tasks without changing hamburger meaning", async ({
testPage,
apiClient,
seedData,
}) => {
const first = await apiClient.seedTask(seedData.workspaceId, "First navigation task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const second = await apiClient.seedTask(seedData.workspaceId, "Second navigation task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${first.task_id}`);
const title = testPage.getByTestId("mobile-task-picker-trigger");
await expect(title).toContainText("First navigation task");
await testPage.screenshot({ path: test.info().outputPath("task-title-393.png") });
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu.getByRole("link", { name: "Tasks", exact: true })).toHaveCount(0);
await expect(menu.getByTestId("mobile-navigation-tasks-toggle")).toBeVisible();
await testPage.keyboard.press("Escape");
await title.tap();
const picker = testPage.getByRole("dialog", { name: "Tasks", exact: true });
await expect(picker).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(title).toBeFocused();
await title.tap();
await picker.locator(`[data-task-row-id="${second.task_id}"]`).tap();
await expect(testPage).toHaveURL(new RegExp(`/t/${second.task_id}`));
await expect(title).toContainText("Second navigation task");
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.setViewportSize({ width: 767, height: 851 });
await assertNoDocumentHorizontalOverflow(testPage);
await testPage.screenshot({ path: test.info().outputPath("task-title-767.png") });
});
// @covers AC-UI-MOBILE-MENU-001.5, AC-UI-MOBILE-MENU-001.6
test("drawer geometry and focus survive breakpoint changes", async ({ testPage }) => {
for (const width of [393, 767]) {
await testPage.setViewportSize({ width, height: 851 });
await testPage.goto("/stats");
const trigger = testPage.getByTestId("app-nav-trigger");
await trigger.tap();
const menu = testPage.getByTestId("app-nav-sheet");
await expect(menu).toHaveAttribute("data-vaul-drawer-direction", "bottom");
await menu.evaluate(async (element) => {
await Promise.all(
element.getAnimations({ subtree: true }).map((animation) => animation.finished),
);
});
const box = await menu.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
expect(box!.y + box!.height).toBeLessThanOrEqual(851);
const target = await trigger.boundingBox();
expect(target!.height).toBeGreaterThanOrEqual(44);
expect(target!.width).toBeGreaterThanOrEqual(44);
await testPage.screenshot({ path: test.info().outputPath(`navigation-${width}.png`) });
await testPage.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await assertNoDocumentHorizontalOverflow(testPage);
}
for (const width of [768, 820]) {
await testPage.setViewportSize({ width, height: 851 });
await expect(testPage.getByTestId("app-nav-trigger")).toBeHidden();
await expect(testPage.getByTestId("app-nav-sheet")).toBeHidden();
}
});
// @covers AC-UI-MOBILE-MENU-001.6
test("a task-picker creation draft survives phone rotation", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.seedTask(seedData.workspaceId, "Task with a rotation draft", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.task_id}`);
await testPage.getByTestId("mobile-task-picker-trigger").tap();
await testPage
.getByRole("dialog", { name: "Tasks", exact: true })
.getByRole("button", { name: "New", exact: true })
.tap();
const title = testPage.getByTestId("create-task-dialog").getByTestId("task-title-input");
await title.fill("Draft survives task rotation");
await testPage.setViewportSize({ width: 851, height: 393 });
await expect(title).toHaveValue("Draft survives task rotation");
});
@@ -45,7 +45,7 @@ test.describe("Mobile LSP boundaries", () => {
title: "Mobile Kotlin LSP Boundary",
});
await testPage.getByRole("button", { name: "Files" }).tap();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
const fileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
@@ -38,7 +38,7 @@ test.describe("Improve Kandev on mobile", () => {
);
await testPage.goto("/");
await testPage.getByRole("button", { name: "Open menu" }).tap();
await testPage.getByTestId("app-nav-trigger").tap();
const improveButton = testPage.getByTestId("mobile-improve-kandev-button");
await expect(improveButton).toBeVisible();
await improveButton.tap();
@@ -41,7 +41,7 @@ test.describe("Office mobile navigation", () => {
await expect(sheet.getByRole("link", { name: /Skills/ })).toBeVisible();
// Global destinations come from the navigation manifest.
await expect(sheet.getByRole("link", { name: "Stats" })).toBeVisible();
await expect(sheet.getByRole("link", { name: "Settings" })).toBeVisible();
await expect(sheet.getByRole("link", { name: "Settings", exact: true })).toBeVisible();
// Home from inside Office lands on the office dashboard, not kanban.
await sheet.getByRole("link", { name: "Home", exact: true }).tap();
@@ -97,7 +97,7 @@ test.describe("Office mobile navigation", () => {
try {
await backend.restart({ KANDEV_FEATURES_OFFICE: "false" });
await testPage.goto(`/?home=overview&workspaceId=${officeSeed.workspaceId}`);
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
const home = testPage
.getByRole("dialog", { name: "Menu" })
.getByRole("link", { name: "Home", exact: true });
@@ -75,7 +75,7 @@ test.describe("mobile Bitbucket plugin contract", () => {
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = taskDrawer
.getByTestId("sidebar-task-item")
@@ -112,7 +112,7 @@ test.describe("Mobile plugin navigation", () => {
// The desktop rail carries the same item but is hidden on a phone.
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("app-nav-trigger").click();
const navItem = testPage.getByTestId(`mobile-plugin-nav-item-${NAV_ITEM_ID}`);
await expect(navItem).toBeVisible();
await expect(navItem).toHaveText(/Hello E2E/);
@@ -302,7 +302,7 @@ test.describe("Mobile plugin navigation", () => {
await testPage.goto("/");
await testPage.reload();
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("app-nav-trigger").click();
// Utilities rows carry no data-testid (see spec's Rendered identity
// section) — select by the visible label instead.
@@ -47,7 +47,7 @@ test.describe("Mobile plugin task panel", () => {
await session.waitForLoad();
// Mobile groups all plugin panels behind one bounded bottom-nav action.
const panelsNavButton = testPage.getByRole("button", { name: "Panels" });
const panelsNavButton = testPage.getByRole("button", { name: "Panels", exact: true });
await expect(panelsNavButton).toBeVisible({ timeout: 15_000 });
expect((await panelsNavButton.boundingBox())?.height).toBeGreaterThanOrEqual(44);
await panelsNavButton.tap();
@@ -106,7 +106,9 @@ test.describe("Mobile plugin task panel", () => {
await expect(testPage.getByTestId("mobile-prompt-history-option")).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("dialog", { name: "Panels" })).toHaveCount(0);
await expect(testPage.getByRole("button", { name: "Chat" })).toHaveClass(/text-primary/);
await expect(testPage.getByRole("button", { name: "Chat", exact: true })).toHaveClass(
/text-primary/,
);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
@@ -1,4 +1,5 @@
import path from "node:path";
import { waitForFiniteAnimations } from "../../helpers/animations";
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import {
@@ -68,13 +69,14 @@ test.describe("Mobile listing menu actions", () => {
await testPage.goto(route);
await expect(testPage.getByTestId("mobile-topbar-action-strip")).toHaveCount(0);
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("app-nav-trigger").tap();
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const plugin = menu.locator("#hello-main-top-bar");
const metrics = menu.getByTestId("app-status-metrics");
await expect(plugin).toHaveAccessibleName(`Hello ${pluginPage}`);
await expect(metrics).toBeVisible();
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await waitForFiniteAnimations(menu);
for (const target of [
plugin,
menu.getByTestId("mobile-quick-chat-button"),
@@ -89,15 +91,16 @@ test.describe("Mobile listing menu actions", () => {
expect(icon.width).toBeCloseTo(16, 0);
expect(icon.height).toBeCloseTo(16, 0);
await assertNoDocumentHorizontalOverflow(testPage, `menu tools on ${route}`);
await expect(menu.getByRole("textbox")).toHaveCount(0);
await testPage.keyboard.press("Escape");
if (route !== "/threads") {
await expect(menu.getByRole("textbox")).toHaveCount(0);
await menu.getByTestId("mobile-search-toggle").tap();
await testPage.getByTestId("mobile-topbar-page-context").tap();
const options = testPage.getByRole("dialog", { name: "View options", exact: true });
await options.getByTestId("mobile-search-toggle").tap();
const search = testPage.getByTestId("mobile-search-bar");
await expect(menu).toHaveCount(0);
await expect(search.getByRole("textbox")).toBeFocused();
await assertNoDocumentHorizontalOverflow(testPage, "phone search");
} else {
await testPage.keyboard.press("Escape");
}
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
}
@@ -43,7 +43,7 @@ test.describe("Mobile prompt-history fixture", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
const panelsButton = testPage.getByRole("button", { name: "Panels" });
const panelsButton = testPage.getByRole("button", { name: "Panels", exact: true });
await expect(panelsButton).toBeVisible({ timeout: 15_000 });
expect((await panelsButton.boundingBox())?.height).toBeGreaterThanOrEqual(44);
await panelsButton.tap();
@@ -69,7 +69,7 @@ test.describe("Mobile Status drawer", () => {
await testPage.goto("/");
await testPage.reload();
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("app-nav-trigger").click();
const statusTrigger = testPage.getByTestId("mobile-home-status-button");
await expect(statusTrigger).toBeVisible();
const triggerBox = await statusTrigger.boundingBox();
@@ -30,8 +30,8 @@ test.describe("Mobile plugin task-list facet", () => {
{ taskId },
);
await testPage.getByRole("button", { name: "Open menu" }).tap();
const menu = testPage.getByRole("dialog", { name: "Menu" });
await testPage.getByTestId("mobile-topbar-page-context").tap();
const menu = testPage.getByRole("dialog", { name: "View options" });
const groupSelect = menu.getByTestId("mobile-tasks-list-group");
await groupSelect.evaluate((element) => element.scrollIntoView({ block: "center" }));
await expect(groupSelect).toBeInViewport();
@@ -78,7 +78,7 @@ test.describe("Mobile sidebar PR automation indicators", () => {
await testPage.goto(`/t/${navigationTaskId}`);
await new SessionPage(testPage).waitForLoad();
await testPage.getByTestId("mobile-session-menu").tap();
await testPage.getByTestId("mobile-task-picker-trigger").tap();
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const targetRow = sheet.locator(`[data-task-row-id="${targetTaskId}"]`);
@@ -81,7 +81,10 @@ test.describe("Review file status on mobile", () => {
git.createFile(MOBILE_FILE, "mobile added file\n");
git.stageFile(MOBILE_FILE);
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible();
await expect(
@@ -41,7 +41,10 @@ test.describe("Review Markdown preview on mobile", () => {
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.stageFile(MARKDOWN_FILE);
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible();
await expect(
@@ -64,7 +64,10 @@ async function openMobileReview(
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await waitForMultiPRFixture(testPage, session, taskId);
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible({ timeout: 15_000 });
const prFiles = changesPanel.getByTestId("pr-files-section");
@@ -30,7 +30,10 @@ test.describe("Nested submodule Review on mobile", () => {
const worktreePath = await fixture.waitForWorktree(apiClient);
await fixture.applyNestedChanges(worktreePath);
await testPage.getByRole("button", { name: "Changes" }).tap();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.tap();
const changesPanel = testPage.getByTestId("mobile-changes-panel");
await expect(changesPanel).toBeVisible({ timeout: 15_000 });
await expect(changesPanel.getByText("README.md").first()).toBeVisible({
@@ -95,7 +95,10 @@ test.describe("Mobile code walkthrough", () => {
}) => {
await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-setup", "changes ready");
await testPage.getByRole("button", { name: "Changes" }).click();
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.click();
const changes = testPage.getByTestId("mobile-changes-panel");
await expect(changes).toBeVisible({ timeout: 15_000 });
const request = changes.getByTestId("changes-request-walkthrough");
@@ -120,7 +123,7 @@ test.describe("Mobile code walkthrough", () => {
});
await request.click();
await testPage.getByRole("button", { name: "Chat" }).click();
await testPage.getByRole("button", { name: "Chat", exact: true }).click();
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.activeChat()).toContainText("Walkthrough: Tour of the change", {

Some files were not shown because too many files have changed in this diff Show More