"use client"; import { memo, useCallback, useMemo, useRef, useState } from "react"; import { cn } from "@kandev/ui/lib/utils"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { Button } from "@kandev/ui/button"; import { IconArrowRight } from "@tabler/icons-react"; import { moveTask } from "@/lib/api"; import { StepCapabilityIcons } from "@/components/step-capability-icons"; import { useAppStore } from "@/components/state-provider"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types"; type Step = { id: string; name: string; color: string; position: number; events?: KanbanStepEvents; allow_manual_move?: boolean; prompt?: string; is_start_step?: boolean; agent_profile_id?: string; }; const PLAN_CONTEXT_PATH = "plan:context"; /** Returns a callback that disables plan mode for the active session of a task. */ function useDisablePlanMode() { const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const planModeEnabled = useAppStore((s) => activeSessionId ? (s.chatInput.planModeBySessionId[activeSessionId] ?? false) : false, ); const setPlanMode = useAppStore((s) => s.setPlanMode); const setActiveDocument = useAppStore((s) => s.setActiveDocument); const closeDocument = useLayoutStore((s) => s.closeDocument); const removeContextFile = useContextFilesStore((s) => s.removeFile); const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset); return useCallback(() => { if (!activeSessionId || !planModeEnabled) return; applyBuiltInPreset("default"); closeDocument(activeSessionId); setActiveDocument(activeSessionId, null); setPlanMode(activeSessionId, false); removeContextFile(activeSessionId, PLAN_CONTEXT_PATH); }, [ activeSessionId, planModeEnabled, setPlanMode, setActiveDocument, closeDocument, removeContextFile, applyBuiltInPreset, ]); } type WorkflowStepperProps = { steps: Step[]; currentStepId: string | null; taskId?: string | null; workflowId?: string | null; isArchived?: boolean; }; const WorkflowStepper = memo(function WorkflowStepper({ steps, currentStepId, taskId, workflowId, isArchived, }: WorkflowStepperProps) { const [movingToStepId, setMovingToStepId] = useState(null); const disablePlanMode = useDisablePlanMode(); const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); const currentIndex = useMemo( () => sortedSteps.findIndex((s) => s.id === currentStepId), [sortedSteps, currentStepId], ); const handleMove = useCallback( async (stepId: string) => { if (!taskId || !workflowId) return; disablePlanMode(); setMovingToStepId(stepId); try { await moveTask(taskId, { workflow_id: workflowId, workflow_step_id: stepId, position: 0, }); } catch (err) { console.error("[WorkflowStepper] Failed to move task:", err); } finally { setMovingToStepId(null); } }, [taskId, workflowId, disablePlanMode], ); // Collapse to a minimal view when the full stepper can't fit (w-full keeps the measurement track-driven). const containerRef = useRef(null); const isCollapsed = useToolbarCollapsed(containerRef); if (sortedSteps.length === 0) return null; return (
{isCollapsed ? ( ) : ( <>
{sortedSteps.map((step, index) => ( ))}
{isArchived && ( <>
Archived )} )}
); }); /** Minimal stepper: current step only (or archived badge), keeping the per-step test id + aria-current. */ function MinimalWorkflowStepper({ sortedSteps, currentIndex, isArchived, }: { sortedSteps: Step[]; currentIndex: number; isArchived?: boolean; }) { if (isArchived) { return ( Archived ); } const current = currentIndex >= 0 ? sortedSteps[currentIndex] : sortedSteps[0]; if (!current) return null; return (
= 0 ? "step" : undefined} className="flex min-w-0 items-center gap-1.5 text-xs" > {current.name}
{sortedSteps.length > 1 && ( {(currentIndex >= 0 ? currentIndex : 0) + 1}/{sortedSteps.length} )}
); } /** Check if a step can be moved to */ function canMoveToStep(params: { isArchived: boolean | undefined; isCurrent: boolean; taskId: string | null | undefined; workflowId: string | null | undefined; isAdjacent: boolean; allowManualMove: boolean | undefined; }): boolean { if (params.isArchived || params.isCurrent || !params.taskId || !params.workflowId) return false; return params.isAdjacent || !!params.allowManualMove; } /** Individual step in the workflow stepper */ function WorkflowStepItem({ step, index, currentIndex, isArchived, taskId, workflowId, movingToStepId, onMove, }: { step: Step; index: number; currentIndex: number; isArchived?: boolean; taskId?: string | null; workflowId?: string | null; movingToStepId: string | null; onMove: (stepId: string) => void; }) { const isCompleted = !isArchived && currentIndex >= 0 && index < currentIndex; const isCurrent = !isArchived && index === currentIndex; const isAdjacent = currentIndex >= 0 && (index === currentIndex - 1 || index === currentIndex + 1); const canMove = canMoveToStep({ isArchived, isCurrent, taskId, workflowId, isAdjacent, allowManualMove: step.allow_manual_move, }); return (
{index > 0 && }
{step.name}
); } /** Connector line between steps */ function StepConnector({ isActive }: { isActive: boolean }) { return (
); } /** Hover content for a workflow step */ function StepHoverContent({ step, isCurrent, canMove, isMoving, onMove, }: { step: Step; isCurrent: boolean; canMove: boolean; isMoving: boolean; onMove: (stepId: string) => void; }) { return ( {canMove && ( )} {isCurrent &&
Current step
}
); } /** Circle indicator for step state */ function StepCircleIndicator({ isCurrent, isCompleted, }: { isCurrent: boolean; isCompleted: boolean; }) { if (isCurrent) { return ( ); } if (isCompleted) { return ( ); } return ( ); } /** Get CSS class for step label based on state */ function getStepLabelClass(isCurrent: boolean, isCompleted: boolean): string { if (isCurrent) return "text-foreground font-medium"; if (isCompleted) return "text-muted-foreground"; return "text-muted-foreground/60"; } export { WorkflowStepper }; export type { Step as WorkflowStepperStep };