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(); + }); +});