feat(ui): unify mobile navigation and task access (#3830)
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user