From c0daa1901bdf716569802ff6d80172533e9d2d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:16:14 +0000 Subject: [PATCH] feat(ui): improve mobile kanban view (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/web/components/kanban-board.tsx | 40 ++- apps/web/components/kanban-with-preview.tsx | 11 + .../kanban/kanban-header-mobile.tsx | 4 +- .../components/kanban/mobile-column-tabs.tsx | 2 + apps/web/components/kanban/mobile-fab.tsx | 1 + .../components/kanban/mobile-menu-sheet.tsx | 2 +- .../components/kanban/mobile-search-bar.tsx | 21 ++ .../components/kanban/mobile-task-sheet.tsx | 89 ++++++ .../components/kanban/swimlane-container.tsx | 146 ++++++--- .../web/components/kanban/swimlane-header.tsx | 1 + .../kanban/swimlane-kanban-content.tsx | 286 +++++++++++++++--- .../components/kanban/swipeable-columns.tsx | 33 +- .../components/task-create-dialog-footer.tsx | 185 ++++++----- apps/web/e2e/pages/mobile-kanban-page.ts | 60 ++++ apps/web/e2e/playwright.config.ts | 6 + apps/web/e2e/tests/mobile-kanban.spec.ts | 189 ++++++++++++ 16 files changed, 903 insertions(+), 173 deletions(-) create mode 100644 apps/web/components/kanban/mobile-search-bar.tsx create mode 100644 apps/web/components/kanban/mobile-task-sheet.tsx create mode 100644 apps/web/e2e/pages/mobile-kanban-page.ts create mode 100644 apps/web/e2e/tests/mobile-kanban.spec.ts diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 24b0c169e9..cb87b4cb66 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -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(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 && ( + + )} + {s.isMobile && ( + <> + + { + if (!open) s.setMobileSheetTask(null); + }} + onGoToSession={s.handleOpenTask} + onEdit={s.handleEdit} + onDelete={s.handleDelete} + /> + + )} ); } diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 404be7971b..67d4f8ba5f 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -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 ( +
+ +
+ ); + } + return (
{shouldFloat ? ( diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index a0b6c9be2a..3fe53a5254 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -56,9 +56,9 @@ export function KanbanHeaderMobile({ variant="outline" size="icon" onClick={() => setMenuOpen(true)} - className="cursor-pointer h-10 w-10" + className="cursor-pointer" > - + Open menu
diff --git a/apps/web/components/kanban/mobile-column-tabs.tsx b/apps/web/components/kanban/mobile-column-tabs.tsx index 41aafe7933..14a90fe4a2 100644 --- a/apps/web/components/kanban/mobile-column-tabs.tsx +++ b/apps/web/components/kanban/mobile-column-tabs.tsx @@ -49,6 +49,8 @@ export function MobileColumnTabs({ + + + + + + ); +} diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index e4a5860239..726ee1667b 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -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, + workflowId: string, + repoFilter: ReturnType, + 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; + 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 = ; + + if (hideHeader) return
{content}
; + + return ( + + {content} + + ); +} + 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 ( -
+
{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 ( - toggleCollapse(wf.id)} - > - - + onPreviewTask={onPreviewTask} + onOpenTask={onOpenTask} + onEditTask={onEditTask} + onDeleteTask={onDeleteTask} + onMoveError={onMoveError} + deletingTaskId={deletingTaskId} + showMaximizeButton={showMaximizeButton} + /> ); })}
diff --git a/apps/web/components/kanban/swimlane-header.tsx b/apps/web/components/kanban/swimlane-header.tsx index 97e7825e1b..5e285e8902 100644 --- a/apps/web/components/kanban/swimlane-header.tsx +++ b/apps/web/components/kanban/swimlane-header.tsx @@ -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" >
diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index cff8593be0..b81b3886fc 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -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(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; + activeTask: Task | null; + showMaximizeButton?: boolean; + deletingTaskId?: string | null; +}) { + const taskCounts = useMemo(() => { + const counts: Record = {}; + 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 ( +
+ + + +
+ ); +} + +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; + showMaximizeButton?: boolean; + deletingTaskId?: string | null; +}) { + const getTasksForStep = useTasksByStep(tasks); + + return ( +
+ {steps.map((step) => ( +
+ +
+ ))} +
+ ); +} + +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; + showMaximizeButton?: boolean; + deletingTaskId?: string | null; +}) { + const getTasksForStep = useTasksByStep(tasks); + + return ( +
+ {steps.map((step) => ( + + ))} +
+ ); } 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 = ( + + ); + } else if (isTablet) { + layoutContent = ; + } else { + layoutContent = ; + } + return ( -
-
- {steps.map((step) => ( - - ))} -
-
+ {layoutContent} {activeTask ? : null} diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 1c624b743c..d859a458f9 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -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]); diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index a5b42ca663..902d9ad9fa 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -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 ( -
- - - - - - - {onPlanModeAction && ( - - - Start task in plan mode - +
+
+ + + + + + + {onPlanModeAction && ( + + + Start task in plan mode + + )} + + + {isEditMode ? "Update task" : "Create without starting agent"} + + + +
+ {/* Mobile-only: visible buttons for plan mode and creating without agent */} + {onPlanModeAction && ( + + )} +
); } @@ -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( { + 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(); + }); +});