"use client"; import { useWorkflowMoveSubmit, workflowMoveShortcutLabel } from "./use-workflow-move-submit"; import { useCallback, type Dispatch, type ReactNode, type SetStateAction, memo, useMemo, useRef, useState, } from "react"; import { cn } from "@kandev/ui/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import { IconAdjustments, IconArrowRight } from "@tabler/icons-react"; import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; import { WorkflowMoveOptionsFields, useWorkflowMoveOptionsForm, workflowMoveOptionsPayload, } from "./workflow-move-options"; import { StepCapabilityIcons } from "@/components/step-capability-icons"; import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import { usePresentationToken, useWorkflowStepMove, } from "@/hooks/domains/kanban/use-workflow-step-move"; import { useWorkflowMovePreview } from "@/hooks/domains/kanban/use-workflow-move-preview"; import { useWorkflowMovePreviewRevision } from "@/hooks/domains/kanban/use-workflow-move-preview-revision"; import { useWorkflowStepProgress, type WorkflowStepProgress, } from "@/hooks/domains/kanban/use-workflow-step-progress"; import { sortWorkflowStepsByPosition } from "@/lib/kanban/workflow-step-order"; import { useTranslation } from "react-i18next"; import { MinimalWorkflowStepper, canMoveToStep, getStepLabelClass, type WorkflowStepperStep, } from "./workflow-step-disclosure"; import { StepProgressDetails, workflowStepProgressTranslationKey, } from "./workflow-step-progress-details"; import { StepCircleIndicator } from "./workflow-step-marker"; import { useHoverPopover } from "@/components/integrations/use-hover-popover"; import { WorkflowMovePreviewDisclosure } from "./workflow-move-preview"; type Step = WorkflowStepperStep; type WorkflowStepperProps = { steps: Step[]; currentStepId: string | null; taskId?: string | null; workflowId?: string | null; taskState?: string | null; isArchived?: boolean; onMoveStart?: () => void; onMoveError?: (error: unknown) => void; }; const WorkflowStepper = memo(function WorkflowStepper({ steps, currentStepId, taskId, workflowId, taskState, isArchived, onMoveStart, onMoveError, }: WorkflowStepperProps) { const { t } = useTranslation(); // The task route's continuous presentation of this task: a navigation away // and back changes taskId and back, which must invalidate a request left // over from the earlier presentation the same way a preview close-and-reopen // does for the kanban preview header. const presentationToken = usePresentationToken(taskId ?? null); const { movingToStepId, progressingToStepId, handleMove } = useWorkflowStepMove({ taskId, workflowId, currentStepId, taskState, presentationToken, onMoveStart, onMoveError, }); const { progressByStepId, agentLabelsByProfileId } = useWorkflowStepProgress({ taskId, currentStepId, movingToStepId: progressingToStepId ?? movingToStepId, }); const [openStepId, setOpenStepId] = useState(null); const sortedSteps = useMemo(() => sortWorkflowStepsByPosition(steps), [steps]); const currentIndex = useMemo( () => sortedSteps.findIndex((s) => s.id === currentStepId), [sortedSteps, currentStepId], ); // 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 && ( <>
{t("task:filterDimensionArchived")} )} )}
); }); function useStepHover( stepId: string, openStepId: string | null, setOpenStepId: Dispatch>, ) { const onHoverOpenChange = useCallback( (open: boolean) => { setOpenStepId((current) => { if (open) return stepId; return current === stepId ? null : current; }); }, [setOpenStepId, stepId], ); return useHoverPopover({ openDelayMs: 200, closeDelayMs: 100, open: openStepId === stepId, onOpenChange: onHoverOpenChange, }); } /** Individual step in the workflow stepper */ function WorkflowStepItem({ step, openStepId, setOpenStepId, index, currentIndex, isArchived, taskId, workflowId, movingToStepId, progress, pendingLabel, agentLabelsByProfileId, onMove, }: { step: Step; openStepId: string | null; setOpenStepId: Dispatch>; index: number; currentIndex: number; isArchived?: boolean; taskId?: string | null; workflowId?: string | null; movingToStepId: string | null; progress?: WorkflowStepProgress; pendingLabel?: string; agentLabelsByProfileId: Readonly>; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; }) { const triggerRef = useRef(null); 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, }); const hover = useStepHover(step.id, openStepId, setOpenStepId); return (
{index > 0 && ( )} triggerRef.current?.focus()} />
); } /** Connector line between steps */ function StepConnector({ isActive, testId }: { isActive: boolean; testId?: string }) { return (
); } /** Hover content for a workflow step */ function StepHoverContent({ step, isCurrent, canMove, isMoving, taskId, workflowId, progress, agentLabelsByProfileId, onMove, hover, onReturnFocus, }: { step: Step; isCurrent: boolean; canMove: boolean; isMoving: boolean; taskId?: string | null; workflowId?: string | null; progress?: WorkflowStepProgress; agentLabelsByProfileId: Readonly>; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; hover: ReturnType; onReturnFocus: () => void; }) { const { t } = useTranslation(); const stepDetails = (
{progress && ( )}
); return ( event.preventDefault()} // Restoring focus after pointer exit would trigger another hover open. onCloseAutoFocus={(event) => event.preventDefault()} onEscapeKeyDown={onReturnFocus} > {canMove && ( )} {isCurrent && (
{t("task:currentStep")}
)} {!canMove && stepDetails}
); } /** * Move button plus an opt-in inline options draft. The options stay hidden * until the user reveals them, so a quick hover-and-click keeps the original * zero-config move; a revealed, filled draft rides along as one-shot * entry_options. Rendered only while the hover card is open, so the draft * hook subscribes lazily and resets whenever the pointer leaves the step. */ function StepMoveControls({ children, step, taskId, workflowId, previewEnabled, isMoving, onMove, }: { step: Step; taskId?: string | null; workflowId?: string | null; previewEnabled: boolean; children: ReactNode; isMoving: boolean; onMove: (stepId: string, entryOptions?: WorkflowMoveEntryOptions) => Promise; }) { const { t } = useTranslation(); const [showOptions, setShowOptions] = useState(false); const { draft, patchDraft } = useWorkflowMoveOptionsForm(); const entryOptions = workflowMoveOptionsPayload(draft); const invalidationKey = useWorkflowMovePreviewRevision(taskId, workflowId, step.id); const previewState = useWorkflowMovePreview({ taskId, workflowId, workflowStepId: step.id, entryOptions, enabled: previewEnabled, invalidationKey, }); const submission = useWorkflowMoveSubmit(isMoving, () => onMove(step.id, entryOptions)); return (
{showOptions ? (
{ submission.onKeyDown(event); event.stopPropagation(); }} >
) : ( )} {children}
); } export { WorkflowStepper }; export type { WorkflowStepperStep } from "./workflow-step-disclosure";