feat(ui): improve mobile kanban view (#400)

* feat(ui): add responsive layouts to swimlane kanban content

Mobile gets swipeable columns with tab navigation and touch-friendly
drag sensors. Tablet gets 2-column horizontal scroll with snap.
Desktop keeps the existing CSS grid layout.

* feat(ui): add mobile task sheet and search bar components

Bottom sheet (vaul Drawer) for task preview with Edit/Delete/Open Session
actions. Compact search bar component for filtering tasks on mobile.

* feat(ui): wire mobile components into kanban board

Integrate MobileFab, MobileSearchBar, and MobileTaskSheet into the
kanban board. Skip desktop preview panel on mobile — card taps open
a bottom sheet instead of navigating directly.

* refactor(ui): optimize swimlane container for mobile

Hide swimlane headers for single workflow on mobile, remove horizontal
padding on small screens. Extract filterTasks and WorkflowItem to keep
the main component under the line limit.

* test: add mobile kanban E2E tests

8 Playwright tests covering mobile layout, FAB, search, column tabs,
bottom sheet, preview panel suppression, and swimlane header hiding.
Adds mobile-chrome project (Pixel 5) to playwright config.

* fix(ui): fix mobile E2E test failures in CI

Exclude mobile tests from chromium project (wrong viewport), add
startIndex to Embla carousel to fix initial scroll race condition,
fix strict locator for menu heading, use first step for swimlane test.

* fix(ui): fix create task dialog button layout on mobile

Make StartTaskSplitButton full-width on mobile, hide dropdown chevron
on small screens, and show a visible "Create only" button instead.

* fix(ui): match header button sizes and add icon to create-only action

Align hamburger menu button with changelog/health buttons by removing
explicit h-10 w-10 override. Add IconPlus to "Create without starting
agent" in both the desktop dropdown and mobile button.

* fix(ui): address code review feedback for mobile kanban

- Base hideHeaders on workflowFilter/total count, not filtered result
- Clamp mobile column index via useMemo when steps change
- Fix wrong assertion in preview panel test (was checking toBeVisible)
- Separate altDisabled from splitDisabled (no agent needed for create-only)
- Add visible plan mode button on mobile (was hidden behind dropdown)
- Wait for mobile-kanban-layout in page object goto()
- Extract computeBaseDisabled to reduce complexity

* fix(test): fix flaky mobile E2E tests in headless Chrome

- Use instant jump (scrollTo with jump=true) for tab-triggered carousel
  navigation instead of animated scroll that doesn't complete in headless
- Add data-testid to swimlane header to avoid ambiguous Badge locator
  matching column tab buttons
- Add timeout to viewport assertion for resilience

* fix(ui): address review feedback and fix carousel reinitialization

- Stabilize Embla options to prevent carousel reinit on tab change
  (startIndex was causing re-initialization, resetting scroll position)
- Guard programmatic scrolls from triggering select callback revert
- Remove PointerSensor from mobile DnD sensors (conflicts with touch)
- Fix plan mode button using wrong disabled prop on mobile
- Use literal name matching in columnTab locator (avoid regex injection)
- Use toHaveCount(0) for stricter preview panel absence assertion

* fix(ui): use pointerDown gating for carousel swipe sync

Embla's scrollTo doesn't work in headless Chrome, causing the select
event to fire with index 0 and reset the active tab. Fix by only
syncing Embla→tabs on user-initiated pointer interactions (swipes),
not on programmatic scrollTo calls.

Update E2E test to verify tab active state instead of carousel
viewport positions, which are unreliable in headless environments.

* fix(test): add data-testid and data-active to column tabs

Use explicit data attributes for more reliable test targeting
instead of getByRole and CSS class assertions.

* refactor(ui): use touch sensors for tablet and deduplicate getTasksForStep

* fix(ui): improve spacing for stats and settings buttons in mobile menu
This commit is contained in:
José Almeida
2026-03-10 15:16:14 +00:00
committed by GitHub
parent 299d948af8
commit c0daa1901b
16 changed files with 903 additions and 173 deletions
+39 -1
View File
@@ -10,6 +10,9 @@ import type { WorkflowsState } from "@/lib/state/slices";
import { type MoveTaskError } from "@/hooks/use-drag-and-drop";
import { SwimlaneContainer } from "./kanban/swimlane-container";
import { KanbanHeader } from "./kanban/kanban-header";
import { MobileFab } from "./kanban/mobile-fab";
import { MobileSearchBar } from "./kanban/mobile-search-bar";
import { MobileTaskSheet } from "./kanban/mobile-task-sheet";
import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
@@ -214,6 +217,20 @@ function useKanbanBoardSetup(
setIsDialogOpen: hooks.setIsDialogOpen,
setTaskSessionAvailability: hooks.setTaskSessionAvailability,
});
// Mobile bottom sheet: intercept card clicks to show task info first
const [mobileSheetTask, setMobileSheetTask] = useState<Task | null>(null);
const mobileCardClick = useCallback(
(task: Task) => {
if (isMobile) {
setMobileSheetTask(task);
} else {
handleCardClick(task);
}
},
[isMobile, handleCardClick],
);
const automation = useMoveErrorState(router);
useWorkflowSelection({
@@ -227,6 +244,7 @@ function useKanbanBoardSetup(
});
return {
isMobile,
kanbanViewMode,
kanban,
workspaceState,
@@ -236,7 +254,9 @@ function useKanbanBoardSetup(
...hooks,
...automation,
handleOpenTask,
handleCardClick,
handleCardClick: mobileCardClick,
mobileSheetTask,
setMobileSheetTask,
};
}
@@ -262,6 +282,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
searchQuery={s.searchQuery}
onSearchChange={s.setSearchQuery}
/>
{s.isMobile && (
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
)}
<KanbanBoardDialogs
isDialogOpen={s.isDialogOpen}
handleDialogOpenChange={s.handleDialogOpenChange}
@@ -288,6 +311,21 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
searchQuery={s.searchQuery}
selectedRepositoryIds={s.userSettings.repositoryIds}
/>
{s.isMobile && (
<>
<MobileFab onClick={s.handleCreate} />
<MobileTaskSheet
task={s.mobileSheetTask}
open={!!s.mobileSheetTask}
onOpenChange={(open) => {
if (!open) s.setMobileSheetTask(null);
}}
onGoToSession={s.handleOpenTask}
onEdit={s.handleEdit}
onDelete={s.handleDelete}
/>
</>
)}
</div>
);
}
@@ -7,6 +7,7 @@ import { TaskPreviewPanel } from "./task-preview-panel";
import { useKanbanPreview } from "@/hooks/use-kanban-preview";
import { useKanbanLayout } from "@/hooks/use-kanban-layout";
import { useTaskSession } from "@/hooks/use-task-session";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStore } from "@/components/state-provider";
import { Task } from "./kanban-card";
import type { KanbanState } from "@/lib/state/slices";
@@ -87,6 +88,7 @@ function useResizeHandler(
export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) {
const router = useRouter();
const { isMobile } = useResponsiveBreakpoint();
// Get tasks from the kanban store
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
@@ -170,6 +172,15 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) {
const activeSessionId = selectedTaskId ? selectedTaskSessionId : null;
// On mobile, skip the preview panel entirely — card clicks navigate directly
if (isMobile) {
return (
<div className="h-dvh w-full flex flex-col bg-background">
<KanbanBoard />
</div>
);
}
return (
<div ref={containerRef} className="h-screen w-full flex flex-col bg-background relative">
{shouldFloat ? (
@@ -56,9 +56,9 @@ export function KanbanHeaderMobile({
variant="outline"
size="icon"
onClick={() => setMenuOpen(true)}
className="cursor-pointer h-10 w-10"
className="cursor-pointer"
>
<IconMenu2 className="h-5 w-5" />
<IconMenu2 className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</div>
@@ -49,6 +49,8 @@ export function MobileColumnTabs({
<button
key={step.id}
ref={index === activeIndex ? activeTabRef : null}
data-testid={`column-tab-${index}`}
data-active={index === activeIndex}
onClick={() => onColumnChange(index)}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors",
@@ -14,6 +14,7 @@ export function MobileFab({ onClick, isDragging = false }: MobileFabProps) {
<Button
onClick={onClick}
size="icon"
data-testid="mobile-fab"
className={cn(
"fixed z-40 h-14 w-14 rounded-full shadow-lg transition-all duration-200",
"cursor-pointer hover:scale-105 active:scale-95",
@@ -153,7 +153,7 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
function MobileNavLinks({ onOpenChange }: { onOpenChange: (open: boolean) => void }) {
return (
<div className="mt-auto space-y-2">
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<Link href="/stats" onClick={() => onOpenChange(false)}>
<Button variant="outline" className="w-full cursor-pointer">
<IconChartBar className="h-4 w-4 mr-2" />
@@ -0,0 +1,21 @@
"use client";
import { TaskSearchInput } from "./task-search-input";
type MobileSearchBarProps = {
searchQuery: string;
onSearchChange: (query: string) => void;
};
export function MobileSearchBar({ searchQuery, onSearchChange }: MobileSearchBarProps) {
return (
<div className="px-4 pb-2" data-testid="mobile-search-bar">
<TaskSearchInput
value={searchQuery}
onChange={onSearchChange}
placeholder="Search tasks..."
className="w-full [&_input]:w-full"
/>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import { Button } from "@kandev/ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerFooter,
} from "@kandev/ui/drawer";
import { Badge } from "@kandev/ui/badge";
import { IconArrowRight, IconEdit, IconTrash } from "@tabler/icons-react";
import type { Task } from "@/components/kanban-card";
type MobileTaskSheetProps = {
task: Task | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onGoToSession: (task: Task) => void;
onEdit: (task: Task) => void;
onDelete: (task: Task) => void;
};
export function MobileTaskSheet({
task,
open,
onOpenChange,
onGoToSession,
onEdit,
onDelete,
}: MobileTaskSheetProps) {
if (!task) return null;
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent data-testid="mobile-task-sheet">
<DrawerHeader className="text-left">
<DrawerTitle className="text-base">{task.title}</DrawerTitle>
{task.description && (
<DrawerDescription className="line-clamp-3">{task.description}</DrawerDescription>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{task.state ?? "not_started"}
</Badge>
</div>
</DrawerHeader>
<DrawerFooter className="flex-row gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 cursor-pointer"
onClick={() => {
onEdit(task);
onOpenChange(false);
}}
>
<IconEdit className="h-4 w-4 mr-1.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 cursor-pointer text-destructive hover:text-destructive"
onClick={() => {
onDelete(task);
onOpenChange(false);
}}
>
<IconTrash className="h-4 w-4 mr-1.5" />
Delete
</Button>
<Button
size="sm"
className="flex-[2] cursor-pointer"
onClick={() => {
onGoToSession(task);
onOpenChange(false);
}}
>
<IconArrowRight className="h-4 w-4 mr-1.5" />
Open Session
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
@@ -1,14 +1,20 @@
"use client";
import { useMemo } from "react";
import { type ComponentType, useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters";
import { SwimlaneSection } from "./swimlane-section";
import { getViewByStoredValue, getDefaultView } from "@/lib/kanban/view-registry";
import {
getViewByStoredValue,
getDefaultView,
type ViewContentProps,
} from "@/lib/kanban/view-registry";
import type { Task } from "@/components/kanban-card";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
import type { Repository } from "@/lib/types/http";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
export type SwimlaneContainerProps = {
viewMode: string;
@@ -50,6 +56,73 @@ function renderEmptyState(emptyMessage: string) {
);
}
function filterTasks(
snapshots: Record<string, { tasks: Task[] }>,
workflowId: string,
repoFilter: ReturnType<typeof mapSelectedRepositoryIds>,
searchQuery?: string,
): Task[] {
const snapshot = snapshots[workflowId];
if (!snapshot) return [];
let tasks = snapshot.tasks as Task[];
tasks = filterTasksByRepositories(tasks, repoFilter);
if (searchQuery) {
const q = searchQuery.toLowerCase();
tasks = tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)),
);
}
return tasks;
}
type WorkflowItemProps = {
wf: { id: string; name: string };
snapshot: WorkflowSnapshotData;
tasks: Task[];
ViewComponent: ComponentType<ViewContentProps>;
hideHeader: boolean;
isCollapsed: boolean;
onToggleCollapse: () => void;
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onMoveError?: (error: MoveTaskError) => void;
deletingTaskId?: string | null;
showMaximizeButton?: boolean;
};
function WorkflowItem({
wf,
snapshot,
tasks,
ViewComponent,
hideHeader,
isCollapsed,
onToggleCollapse,
...viewProps
}: WorkflowItemProps) {
const steps = [...snapshot.steps].sort((a, b) => a.position - b.position);
const content = <ViewComponent workflowId={wf.id} steps={steps} tasks={tasks} {...viewProps} />;
if (hideHeader) return <div key={wf.id}>{content}</div>;
return (
<SwimlaneSection
key={wf.id}
workflowId={wf.id}
workflowName={wf.name}
taskCount={tasks.length}
isCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
>
{content}
</SwimlaneSection>
);
}
export function SwimlaneContainer({
viewMode,
workflowFilter,
@@ -63,6 +136,7 @@ export function SwimlaneContainer({
searchQuery,
selectedRepositoryIds = [],
}: SwimlaneContainerProps) {
const { isMobile } = useResponsiveBreakpoint();
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const isLoading = useAppStore((state) => state.kanbanMulti.isLoading);
const workflows = useAppStore((state) => state.workflows.items);
@@ -73,43 +147,21 @@ export function SwimlaneContainer({
() => Object.values(repositoriesByWorkspace).flat() as Repository[],
[repositoriesByWorkspace],
);
const repoFilter = useMemo(
() => mapSelectedRepositoryIds(repositories, selectedRepositoryIds),
[repositories, selectedRepositoryIds],
);
// Build ordered list of workflows to render
const orderedWorkflows = useMemo(() => {
if (workflowFilter) {
// Single workflow mode
const snapshot = snapshots[workflowFilter];
if (!snapshot) return [];
return [{ id: workflowFilter, name: snapshot.workflowName }];
}
// All workflows - use workflow order, only show those with snapshots
return workflows.filter((wf) => snapshots[wf.id]);
}, [workflowFilter, workflows, snapshots]);
// Filter tasks per workflow
const getFilteredTasks = (workflowId: string): Task[] => {
const snapshot = snapshots[workflowId];
if (!snapshot) return [];
let tasks = snapshot.tasks as Task[];
tasks = filterTasksByRepositories(tasks, repoFilter);
if (searchQuery) {
const q = searchQuery.toLowerCase();
tasks = tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)),
);
}
return tasks;
};
const getFilteredTasks = (wfId: string) => filterTasks(snapshots, wfId, repoFilter, searchQuery);
const emptyMessage = getEmptyMessage(
isLoading,
@@ -118,45 +170,41 @@ export function SwimlaneContainer({
workflowFilter,
getFilteredTasks,
);
if (emptyMessage) {
return renderEmptyState(emptyMessage);
}
if (emptyMessage) return renderEmptyState(emptyMessage);
const visibleWorkflows = workflowFilter
? orderedWorkflows
: orderedWorkflows.filter((wf) => getFilteredTasks(wf.id).length > 0);
const ViewComponent = (getViewByStoredValue(viewMode) ?? getDefaultView()).component;
const hideHeaders = isMobile && (workflowFilter !== null || orderedWorkflows.length === 1);
const containerClass = isMobile
? "flex-1 min-h-0 overflow-y-auto pb-4 space-y-3"
: "flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-3";
return (
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-3">
<div className={containerClass} data-testid="swimlane-container">
{visibleWorkflows.map((wf) => {
const snapshot = snapshots[wf.id];
if (!snapshot) return null;
const tasks = getFilteredTasks(wf.id);
const steps = [...snapshot.steps].sort((a, b) => a.position - b.position);
return (
<SwimlaneSection
<WorkflowItem
key={wf.id}
workflowId={wf.id}
workflowName={wf.name}
taskCount={tasks.length}
wf={wf}
snapshot={snapshot}
tasks={getFilteredTasks(wf.id)}
ViewComponent={ViewComponent}
hideHeader={hideHeaders}
isCollapsed={isCollapsed(wf.id)}
onToggleCollapse={() => toggleCollapse(wf.id)}
>
<ViewComponent
workflowId={wf.id}
steps={steps}
tasks={tasks}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveError={onMoveError}
deletingTaskId={deletingTaskId}
showMaximizeButton={showMaximizeButton}
/>
</SwimlaneSection>
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveError={onMoveError}
deletingTaskId={deletingTaskId}
showMaximizeButton={showMaximizeButton}
/>
);
})}
</div>
@@ -22,6 +22,7 @@ export function SwimlaneHeader({
type="button"
onClick={onToggleCollapse}
className="flex items-center gap-2 py-1.5 w-full text-left cursor-pointer group"
data-testid="swimlane-header"
>
<div className="flex-1 border-t border-dashed border-border/50" />
<Badge variant="secondary" className="text-xs shrink-0 gap-1.5 px-2.5 py-0.5">
@@ -7,6 +7,7 @@ import {
DragOverlay,
DragStartEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
@@ -16,6 +17,10 @@ import type { WorkflowStep } from "@/components/kanban-column";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
import { useTaskActions } from "@/hooks/use-task-actions";
import { useAppStoreApi } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MobileColumnTabs } from "./mobile-column-tabs";
import { SwipeableColumns } from "./swipeable-columns";
import { MobileDropTargets } from "./mobile-drop-targets";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
export type SwimlaneKanbanContentProps = {
@@ -34,15 +39,26 @@ export type SwimlaneKanbanContentProps = {
type SwimlaneKanbanDndOptions = {
tasks: Task[];
workflowId: string;
useTouchSensors: boolean;
onMoveError?: (error: MoveTaskError) => void;
};
function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanbanDndOptions) {
function useSwimlaneKanbanDnd({
tasks,
workflowId,
useTouchSensors,
onMoveError,
}: SwimlaneKanbanDndOptions) {
const store = useAppStoreApi();
const { moveTaskById } = useTaskActions();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
}),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
@@ -87,8 +103,6 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban
workflow_step_id: targetStepId,
position: nextPosition,
});
// Backend handles on_enter actions (auto_start_agent, plan_mode, etc.)
// via the task.moved event → orchestrator processOnEnter()
} catch (error) {
const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId];
if (currentSnapshot) {
@@ -120,7 +134,208 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban
[tasks, activeTaskId],
);
return { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask };
// Touch-only sensors for mobile/tablet (PointerSensor conflicts with touch scroll)
const touchSensors = useSensors(
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
}),
);
return {
sensors: useTouchSensors ? touchSensors : sensors,
handleDragStart,
handleDragEnd,
handleDragCancel,
moveTaskToStep,
activeTask,
};
}
function getInitialColumnIndex(steps: WorkflowStep[], tasks: Task[]): number {
if (steps.length === 0) return 0;
const idx = steps.findIndex((step) => tasks.some((t) => t.workflowStepId === step.id));
return idx !== -1 ? idx : 0;
}
function useMobileColumnIndex(steps: WorkflowStep[], tasks: Task[]) {
const [rawIndex, setActiveIndex] = useState(() => getInitialColumnIndex(steps, tasks));
// Derive clamped index — avoids calling setState in an effect
const activeIndex = useMemo(() => {
if (steps.length === 0) return 0;
if (rawIndex >= steps.length) return getInitialColumnIndex(steps, tasks);
return rawIndex;
}, [steps, tasks, rawIndex]);
return { activeIndex, setActiveIndex };
}
function useTasksByStep(tasks: Task[]) {
return useCallback(
(stepId: string) =>
tasks
.filter((t) => t.workflowStepId === stepId)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
[tasks],
);
}
function MobileKanbanLayout({
steps,
tasks,
activeIndex,
onIndexChange,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
moveTaskToStep,
activeTask,
showMaximizeButton,
deletingTaskId,
}: {
steps: WorkflowStep[];
tasks: Task[];
activeIndex: number;
onIndexChange: (index: number) => void;
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
moveTaskToStep: (task: Task, targetStepId: string) => Promise<void>;
activeTask: Task | null;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
}) {
const taskCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const step of steps) {
counts[step.id] = tasks.filter((t) => t.workflowStepId === step.id).length;
}
return counts;
}, [steps, tasks]);
const currentStepId = steps[activeIndex]?.id ?? null;
return (
<div className="flex flex-col min-h-0" data-testid="mobile-kanban-layout">
<MobileColumnTabs
steps={steps}
activeIndex={activeIndex}
taskCounts={taskCounts}
onColumnChange={onIndexChange}
/>
<SwipeableColumns
steps={steps}
tasks={tasks}
activeIndex={activeIndex}
onIndexChange={onIndexChange}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={moveTaskToStep}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
/>
<MobileDropTargets steps={steps} currentStepId={currentStepId} isDragging={!!activeTask} />
</div>
);
}
function TabletKanbanLayout({
steps,
tasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
moveTaskToStep,
showMaximizeButton,
deletingTaskId,
}: {
steps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
moveTaskToStep: (task: Task, targetStepId: string) => Promise<void>;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
}) {
const getTasksForStep = useTasksByStep(tasks);
return (
<div
className="flex overflow-x-auto snap-x snap-mandatory gap-2 h-full scrollbar-hide"
data-testid="tablet-kanban-layout"
>
{steps.map((step) => (
<div key={step.id} className="flex-shrink-0 w-[calc(50%-4px)] snap-start h-full">
<KanbanColumn
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={moveTaskToStep}
steps={steps}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
/>
</div>
))}
</div>
);
}
function DesktopKanbanLayout({
steps,
tasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
moveTaskToStep,
showMaximizeButton,
deletingTaskId,
}: {
steps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
moveTaskToStep: (task: Task, targetStepId: string) => Promise<void>;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
}) {
const getTasksForStep = useTasksByStep(tasks);
return (
<div
className="grid gap-0"
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
>
{steps.map((step) => (
<KanbanColumn
key={step.id}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={moveTaskToStep}
steps={steps}
deletingTaskId={deletingTaskId}
showMaximizeButton={showMaximizeButton}
/>
))}
</div>
);
}
export function SwimlaneKanbanContent({
@@ -135,17 +350,41 @@ export function SwimlaneKanbanContent({
deletingTaskId,
showMaximizeButton,
}: SwimlaneKanbanContentProps) {
const { isMobile, isTablet } = useResponsiveBreakpoint();
const { activeIndex, setActiveIndex } = useMobileColumnIndex(steps, tasks);
const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } =
useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError });
const getTasksForStep = (stepId: string) => {
return tasks
.filter((t) => t.workflowStepId === stepId)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
};
useSwimlaneKanbanDnd({ tasks, workflowId, useTouchSensors: isMobile || isTablet, onMoveError });
if (steps.length === 0) return null;
const sharedProps = {
steps,
tasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
moveTaskToStep,
showMaximizeButton,
deletingTaskId,
};
let layoutContent: React.ReactNode;
if (isMobile) {
layoutContent = (
<MobileKanbanLayout
{...sharedProps}
activeIndex={activeIndex}
onIndexChange={setActiveIndex}
activeTask={activeTask}
/>
);
} else if (isTablet) {
layoutContent = <TabletKanbanLayout {...sharedProps} />;
} else {
layoutContent = <DesktopKanbanLayout {...sharedProps} />;
}
return (
<DndContext
sensors={sensors}
@@ -153,28 +392,7 @@ export function SwimlaneKanbanContent({
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="">
<div
className="grid gap-0"
style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}
>
{steps.map((step) => (
<KanbanColumn
key={step.id}
step={step}
tasks={getTasksForStep(step.id)}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onMoveTask={moveTaskToStep}
steps={steps}
deletingTaskId={deletingTaskId}
showMaximizeButton={showMaximizeButton}
/>
))}
</div>
</div>
{layoutContent}
<DragOverlay dropAnimation={null}>
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
</DragOverlay>
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useRef, useMemo, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { KanbanColumn, WorkflowStep } from "../kanban-column";
import { Task } from "../kanban-card";
@@ -32,11 +32,19 @@ export function SwipeableColumns({
showMaximizeButton,
deletingTaskId,
}: SwipeableColumnsProps) {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: "start",
containScroll: "trimSnaps",
watchDrag: true,
});
// Stable options to avoid Embla reinitializing on every activeIndex change
const [initialIndex] = useState(activeIndex);
const options = useMemo(
() => ({
align: "start" as const,
containScroll: "trimSnaps" as const,
watchDrag: true,
startIndex: initialIndex,
}),
[initialIndex],
);
const [emblaRef, emblaApi] = useEmblaCarousel(options);
const userInteracting = useRef(false);
const getTasksForStep = useCallback(
(stepId: string) => {
@@ -48,26 +56,33 @@ export function SwipeableColumns({
[tasks],
);
// Sync carousel position with external activeIndex
// Sync carousel position with external activeIndex (tab clicks)
useEffect(() => {
if (emblaApi && emblaApi.selectedScrollSnap() !== activeIndex) {
emblaApi.scrollTo(activeIndex);
emblaApi.scrollTo(activeIndex, true);
}
}, [emblaApi, activeIndex]);
// Update external activeIndex when user swipes
// Sync tab state when user swipes (only on user-initiated interactions)
useEffect(() => {
if (!emblaApi) return;
const onPointerDown = () => {
userInteracting.current = true;
};
const onSelect = () => {
if (!userInteracting.current) return;
userInteracting.current = false;
const selectedIndex = emblaApi.selectedScrollSnap();
if (selectedIndex !== activeIndex) {
onIndexChange(selectedIndex);
}
};
emblaApi.on("pointerDown", onPointerDown);
emblaApi.on("select", onSelect);
return () => {
emblaApi.off("pointerDown", onPointerDown);
emblaApi.off("select", onSelect);
};
}, [emblaApi, activeIndex, onIndexChange]);
+108 -77
View File
@@ -1,7 +1,13 @@
"use client";
import { memo } from "react";
import { IconLoader2, IconFileInvoice, IconSend, IconChevronDown } from "@tabler/icons-react";
import {
IconLoader2,
IconFileInvoice,
IconSend,
IconChevronDown,
IconPlus,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { DialogClose } from "@kandev/ui/dialog";
import {
@@ -43,6 +49,7 @@ function UpdateButton({ isCreatingTask, hasTitle, onUpdate }: UpdateButtonProps)
type StartTaskSplitButtonProps = {
isCreatingTask: boolean;
disabled: boolean;
altDisabled: boolean;
isEditMode: boolean;
onAltAction: () => void;
onPlanModeAction?: () => void;
@@ -51,57 +58,87 @@ type StartTaskSplitButtonProps = {
function StartTaskSplitButton({
isCreatingTask,
disabled,
altDisabled,
isEditMode,
onAltAction,
onPlanModeAction,
}: StartTaskSplitButtonProps) {
const altLabel = isEditMode ? "Update task" : "Create only";
return (
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
<Button
type="submit"
variant="default"
className="rounded-none border-0 cursor-pointer gap-1.5 h-full"
disabled={disabled}
data-testid="submit-start-agent"
>
{isCreatingTask ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<IconSend className="h-3.5 w-3.5" />
)}
{isCreatingTask ? "Starting..." : "Start task"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="default"
className="rounded-none border-0 border-l border-primary-foreground/20 px-2 cursor-pointer h-full"
disabled={disabled}
data-testid="submit-start-agent-chevron"
>
<IconChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-max">
{onPlanModeAction && (
<DropdownMenuItem
onClick={onPlanModeAction}
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
data-testid="submit-plan-mode"
>
<IconFileInvoice className="h-3.5 w-3.5 mr-1.5" />
Start task in plan mode
</DropdownMenuItem>
<div className="flex flex-col w-full sm:w-auto gap-2 sm:gap-0">
<div className="flex w-full sm:inline-flex sm:w-auto rounded-md border border-border overflow-hidden sm:h-7 h-10">
<Button
type="submit"
variant="default"
className="rounded-none border-0 cursor-pointer gap-1.5 h-full flex-1"
disabled={disabled}
data-testid="submit-start-agent"
>
{isCreatingTask ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<IconSend className="h-3.5 w-3.5" />
)}
<DropdownMenuItem
onClick={onAltAction}
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
>
{isEditMode ? "Update task" : "Create without starting agent"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isCreatingTask ? "Starting..." : "Start task"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="default"
className="rounded-none border-0 border-l border-primary-foreground/20 px-2 cursor-pointer h-full hidden sm:flex"
disabled={disabled}
data-testid="submit-start-agent-chevron"
>
<IconChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-max">
{onPlanModeAction && (
<DropdownMenuItem
onClick={onPlanModeAction}
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
data-testid="submit-plan-mode"
>
<IconFileInvoice className="h-3.5 w-3.5 mr-1.5" />
Start task in plan mode
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onAltAction}
className="cursor-pointer whitespace-nowrap focus:bg-muted/80 hover:bg-muted/80"
>
<IconPlus className="h-3.5 w-3.5 mr-1.5" />
{isEditMode ? "Update task" : "Create without starting agent"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Mobile-only: visible buttons for plan mode and creating without agent */}
{onPlanModeAction && (
<Button
type="button"
variant="outline"
className="w-full h-10 cursor-pointer gap-1.5 sm:hidden"
disabled={altDisabled}
onClick={onPlanModeAction}
data-testid="mobile-plan-mode"
>
<IconFileInvoice className="h-3.5 w-3.5" />
Plan mode
</Button>
)}
<Button
type="button"
variant="outline"
className="w-full h-10 cursor-pointer gap-1.5 sm:hidden"
disabled={altDisabled}
onClick={onAltAction}
>
<IconPlus className="h-3.5 w-3.5" />
{altLabel}
</Button>
</div>
);
}
@@ -198,37 +235,30 @@ function isMissingWorkflowCtx(
return isCreateMode && (!workspaceId || !effectiveWorkflowId);
}
function computeFooterState(props: TaskCreateDialogFooterProps) {
const {
isSessionMode,
isCreateMode,
isEditMode,
isPassthroughProfile,
isCreatingTask,
hasTitle,
hasDescription,
hasRepositorySelection,
branch,
agentProfileId,
workspaceId,
effectiveWorkflowId,
} = props;
const showStartTask =
(isCreateMode && (hasDescription || isPassthroughProfile)) ||
Boolean(isEditMode && agentProfileId);
const missingCtx = isMissingWorkflowCtx(isCreateMode, workspaceId, effectiveWorkflowId);
const splitDisabled =
isCreatingTask ||
!hasTitle ||
!hasRepositorySelection ||
!branch ||
!agentProfileId ||
missingCtx;
const defaultDisabled = isSessionMode
? !agentProfileId
: !hasTitle || !hasRepositorySelection || !branch || missingCtx;
function computeBaseDisabled(props: TaskCreateDialogFooterProps) {
const missingCtx = isMissingWorkflowCtx(
props.isCreateMode,
props.workspaceId,
props.effectiveWorkflowId,
);
return (
props.isCreatingTask ||
!props.hasTitle ||
!props.hasRepositorySelection ||
!props.branch ||
missingCtx
);
}
return { showStartTask, splitDisabled, defaultDisabled };
function computeFooterState(props: TaskCreateDialogFooterProps) {
const showStartTask =
(props.isCreateMode && (props.hasDescription || props.isPassthroughProfile)) ||
Boolean(props.isEditMode && props.agentProfileId);
const altDisabled = computeBaseDisabled(props);
const splitDisabled = altDisabled || !props.agentProfileId;
const defaultDisabled = props.isSessionMode ? !props.agentProfileId : altDisabled;
return { showStartTask, splitDisabled, altDisabled, defaultDisabled };
}
export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
@@ -250,7 +280,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
onCreateWithoutAgent,
onCreateWithPlanMode,
} = props;
const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props);
const { showStartTask, splitDisabled, altDisabled, defaultDisabled } = computeFooterState(props);
return (
<>
@@ -286,6 +316,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
<StartTaskSplitButton
isCreatingTask={isCreatingTask}
disabled={splitDisabled}
altDisabled={altDisabled}
isEditMode={isEditMode}
onAltAction={isEditMode ? onUpdateWithoutAgent : onCreateWithoutAgent}
onPlanModeAction={onCreateWithPlanMode}
+60
View File
@@ -0,0 +1,60 @@
import { type Locator, type Page } from "@playwright/test";
export class MobileKanbanPage {
readonly board: Locator;
readonly mobileFab: Locator;
readonly mobileSearchBar: Locator;
readonly mobileMenuButton: Locator;
readonly mobileTaskSheet: Locator;
readonly swimlaneContainer: Locator;
constructor(private page: Page) {
this.board = page.getByTestId("kanban-board");
this.mobileFab = page.getByTestId("mobile-fab");
this.mobileSearchBar = page.getByTestId("mobile-search-bar");
this.mobileMenuButton = page.getByRole("button", { name: "Open menu" });
this.mobileTaskSheet = page.getByTestId("mobile-task-sheet");
this.swimlaneContainer = page.getByTestId("swimlane-container");
}
async goto() {
await this.page.goto("/");
await this.board.waitFor({ state: "visible" });
// Wait for mobile-specific layout to render
await this.page.getByTestId("mobile-kanban-layout").waitFor({ state: "visible" });
}
mobileKanbanLayout(): Locator {
return this.page.getByTestId("mobile-kanban-layout");
}
columnTab(name: string): Locator {
return this.page.getByRole("button", { name });
}
taskCard(taskId: string): Locator {
return this.page.getByTestId(`task-card-${taskId}`);
}
taskCardByTitle(title: string): Locator {
return this.board.locator(`[data-testid^="task-card-"]`, {
hasText: title,
});
}
searchInput(): Locator {
return this.mobileSearchBar.getByPlaceholder("Search tasks...");
}
sheetGoToSession(): Locator {
return this.mobileTaskSheet.getByRole("button", { name: /Open Session/ });
}
sheetEditButton(): Locator {
return this.mobileTaskSheet.getByRole("button", { name: /Edit/ });
}
sheetDeleteButton(): Locator {
return this.mobileTaskSheet.getByRole("button", { name: /Delete/ });
}
}
+6
View File
@@ -26,8 +26,14 @@ export default defineConfig({
projects: [
{
name: "chromium",
testIgnore: /mobile-.*\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "mobile-chrome",
testMatch: /mobile-.*\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
// No webServer — each Playwright worker spawns its own frontend
+189
View File
@@ -0,0 +1,189 @@
import { test, expect } from "../fixtures/test-base";
import { MobileKanbanPage } from "../pages/mobile-kanban-page";
test.describe("Mobile kanban view", () => {
test("renders mobile layout with column tabs and swipeable columns", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "Mobile Layout Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// Mobile layout should be rendered (swipeable columns, not CSS grid)
await expect(mobile.mobileKanbanLayout()).toBeVisible();
// FAB should be visible for creating tasks
await expect(mobile.mobileFab).toBeVisible();
// Search bar should be visible on mobile
await expect(mobile.mobileSearchBar).toBeVisible();
// Task card should be visible
await expect(mobile.taskCardByTitle("Mobile Layout Task")).toBeVisible();
});
test("shows mobile menu via hamburger button", async ({ testPage }) => {
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await expect(mobile.mobileMenuButton).toBeVisible();
await mobile.mobileMenuButton.click();
// Menu sheet should open with display options
await expect(testPage.getByRole("heading", { name: "Menu" })).toBeVisible();
await expect(testPage.getByText("Display Options")).toBeVisible();
});
test("column tabs allow switching between workflow steps", async ({
testPage,
apiClient,
seedData,
}) => {
// Create tasks in different steps
const steps = seedData.steps;
await apiClient.createTask(seedData.workspaceId, "Task In First Step", {
workflow_id: seedData.workflowId,
workflow_step_id: steps[0].id,
});
if (steps.length > 1) {
await apiClient.createTask(seedData.workspaceId, "Task In Second Step", {
workflow_id: seedData.workflowId,
workflow_step_id: steps[1].id,
});
}
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// First step's task should be visible
await expect(mobile.taskCardByTitle("Task In First Step")).toBeVisible();
// If there are multiple steps, switch to second step via tab
if (steps.length > 1) {
const firstTab = testPage.getByTestId("column-tab-0");
const secondTab = testPage.getByTestId("column-tab-1");
// Verify tab counts reflect tasks in each step
await expect(firstTab).toContainText("1");
await expect(secondTab).toContainText("1");
// First tab should be active initially
await expect(firstTab).toHaveAttribute("data-active", "true");
// Click second tab — active tab should switch
await secondTab.click();
await expect(secondTab).toHaveAttribute("data-active", "true", { timeout: 5000 });
await expect(firstTab).toHaveAttribute("data-active", "false");
// Second step task should exist in the DOM
await expect(mobile.taskCardByTitle("Task In Second Step")).toBeVisible();
}
});
test("mobile search bar filters tasks", async ({ testPage, apiClient, seedData }) => {
await apiClient.createTask(seedData.workspaceId, "Searchable Alpha", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, "Hidden Beta", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// Both tasks should be visible initially
await expect(mobile.taskCardByTitle("Searchable Alpha")).toBeVisible();
await expect(mobile.taskCardByTitle("Hidden Beta")).toBeVisible();
// Type in search
await mobile.searchInput().fill("Alpha");
// Only matching task should remain visible
await expect(mobile.taskCardByTitle("Searchable Alpha")).toBeVisible({ timeout: 5000 });
await expect(mobile.taskCardByTitle("Hidden Beta")).not.toBeVisible({ timeout: 5000 });
});
test("tapping a task card opens bottom sheet", async ({ testPage, apiClient, seedData }) => {
const task = await apiClient.createTask(seedData.workspaceId, "Sheet Test Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// Tap on the task card
await mobile.taskCard(task.id).click();
// Bottom sheet should appear with task title and action buttons
await expect(mobile.mobileTaskSheet).toBeVisible({ timeout: 5000 });
await expect(mobile.mobileTaskSheet.getByText("Sheet Test Task")).toBeVisible();
await expect(mobile.sheetGoToSession()).toBeVisible();
await expect(mobile.sheetEditButton()).toBeVisible();
await expect(mobile.sheetDeleteButton()).toBeVisible();
});
test("FAB opens create task dialog", async ({ testPage }) => {
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileFab.click();
// Create task dialog should open
await expect(testPage.getByRole("dialog")).toBeVisible({ timeout: 5000 });
});
test("does not show desktop preview panel on mobile", async ({
testPage,
apiClient,
seedData,
}) => {
// Enable preview-on-click to test that it's still hidden on mobile
await apiClient.saveUserSettings({ enable_preview_on_click: true });
await apiClient.createTask(seedData.workspaceId, "No Preview Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// Tap task card
await mobile.taskCardByTitle("No Preview Task").click();
// Bottom sheet should appear, NOT the desktop preview panel
await expect(mobile.mobileTaskSheet).toBeVisible({ timeout: 5000 });
// Desktop preview panel should NOT exist in the DOM at all
await expect(testPage.getByTestId("preview-panel")).toHaveCount(0);
// No ?taskId= URL param (that's the desktop preview behavior)
await expect(testPage).not.toHaveURL(/taskId=/);
});
test("swimlane header is hidden when single workflow on mobile", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "Single Workflow Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.steps[0].id,
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
// With a single workflow, the swimlane header badge should not be shown
// (the swimlane section header contains the workflow name in a badge)
await expect(mobile.swimlaneContainer).toBeVisible();
await expect(mobile.taskCardByTitle("Single Workflow Task")).toBeVisible();
// The swimlane header (collapse toggle) should not exist for single workflow
await expect(testPage.getByTestId("swimlane-header")).not.toBeVisible();
});
});