Files
kandev/apps/web/components/settings/workflow-pipeline-editor.tsx
T
2026-04-16 10:14:45 +01:00

363 lines
11 KiB
TypeScript

"use client";
import { useMemo, useState, useSyncExternalStore } from "react";
import {
DndContext,
closestCenter,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
arrayMove,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
IconGripVertical,
IconPlus,
IconTrash,
IconChevronRight,
IconRosetteNumber1,
} from "@tabler/icons-react";
import { StepCapabilityIcons } from "@/components/step-capability-icons";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { ScrollArea, ScrollBar } from "@kandev/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import type { WorkflowStep } from "@/lib/types/http";
import { cn } from "@/lib/utils";
import { StepConfigPanel } from "./workflow-pipeline-editor-panels";
type WorkflowPipelineEditorProps = {
steps: WorkflowStep[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
readOnly?: boolean;
};
// --- Helpers ---
function getTransitionType(step: WorkflowStep): string {
const action = step.events?.on_turn_complete?.find((a) =>
["move_to_next", "move_to_previous", "move_to_step"].includes(a.type),
);
return action?.type ?? "none";
}
function getTransitionLabel(step: WorkflowStep): string {
const t = getTransitionType(step);
if (t === "move_to_next") return "auto";
if (t === "move_to_previous") return "back";
if (t === "move_to_step") return "goto";
return "manual";
}
// --- Pipeline Node ---
type PipelineNodeProps = {
step: WorkflowStep;
isSelected: boolean;
onSelect: () => void;
onRemove: () => void;
readOnly?: boolean;
};
function PipelineNode({
step,
isSelected,
onSelect,
onRemove,
readOnly = false,
}: PipelineNodeProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: step.id,
});
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 min-w-[120px] max-w-[160px] cursor-pointer transition-colors select-none",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-primary/50",
isDragging && "opacity-50 z-50",
)}
onClick={onSelect}
>
{step.is_start_step && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute -top-2 -left-2 flex items-center justify-center w-5 h-5 rounded-full bg-amber-500 text-white">
<IconRosetteNumber1 className="h-3.5 w-3.5" />
</div>
</TooltipTrigger>
<TooltipContent>Start step</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<button
type="button"
className={cn(
"shrink-0 p-0.5 rounded text-muted-foreground/40 hover:text-muted-foreground",
readOnly ? "cursor-default" : "cursor-grab",
)}
{...(readOnly ? {} : attributes)}
{...(readOnly ? {} : listeners)}
aria-disabled={readOnly}
onClick={(e) => e.stopPropagation()}
>
<IconGripVertical className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className={cn("w-3 h-3 rounded-full shrink-0", step.color)} />
<span className="text-sm font-medium truncate">{step.name}</span>
</div>
<StepCapabilityIcons
events={step.events}
agentProfileId={step.agent_profile_id}
fallback={<span className="text-xs text-muted-foreground/50">manual</span>}
/>
</div>
{!readOnly && (
<button
type="button"
className="absolute -top-2 -right-2 hidden group-hover:flex items-center justify-center w-5 h-5 rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<IconTrash className="h-3 w-3" />
</button>
)}
</div>
);
}
// --- Connector ---
function PipelineConnector({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center shrink-0 px-1">
<div className="flex items-center gap-0.5 text-muted-foreground/60">
<div className="w-4 h-px bg-border" />
<IconChevronRight className="h-3 w-3" />
</div>
<span className="text-[10px] text-muted-foreground/50 leading-none mt-0.5">{label}</span>
</div>
);
}
// --- Pipeline Area ---
type PipelineAreaProps = {
steps: WorkflowStep[];
selectedStepId: string | null;
onSelectStep: (stepId: string) => void;
onRemoveStep: (stepId: string) => void;
onAddStep: () => void;
readOnly: boolean;
};
function PipelineArea({
steps,
selectedStepId,
onSelectStep,
onRemoveStep,
onAddStep,
readOnly,
}: PipelineAreaProps) {
return (
<div className="flex items-center gap-0 py-2 px-1">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
{index > 0 && <PipelineConnector label={getTransitionLabel(steps[index - 1])} />}
<PipelineNode
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => onSelectStep(step.id)}
onRemove={() => onRemoveStep(step.id)}
readOnly={readOnly}
/>
</div>
))}
{steps.length > 0 && (
<div className="flex items-center">
<div className="w-4 h-px bg-border shrink-0" />
</div>
)}
<button
type="button"
onClick={readOnly ? undefined : onAddStep}
disabled={readOnly}
data-testid="add-step-button"
className="shrink-0 h-10 w-10 rounded-lg border border-dashed border-border text-muted-foreground hover:border-primary/50 hover:text-foreground flex items-center justify-center cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<IconPlus className="h-4 w-4" />
</button>
</div>
);
}
// --- Step Delete Confirmation ---
function StepDeleteConfirmation({
stepName,
open,
onOpenChange,
onConfirm,
}: {
stepName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent data-testid="step-delete-confirm-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete step &ldquo;{stepName}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the step from this workflow. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm} className="cursor-pointer">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// --- Main Pipeline Editor ---
export function WorkflowPipelineEditor({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
readOnly = false,
}: WorkflowPipelineEditorProps) {
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [prevStepCount, setPrevStepCount] = useState(steps.length);
const [stepToConfirmDelete, setStepToConfirmDelete] = useState<string | null>(null);
if (steps.length !== prevStepCount) {
if (steps.length > prevStepCount && steps.length > 0)
setSelectedStepId(steps[steps.length - 1].id);
setPrevStepCount(steps.length);
}
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleDragEnd = (event: DragEndEvent) => {
if (readOnly) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = steps.findIndex((step) => step.id === active.id);
const newIndex = steps.findIndex((step) => step.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
onReorderSteps(
arrayMove(steps, oldIndex, newIndex).map((step, index) => ({ ...step, position: index })),
);
};
const handleSelectStep = (stepId: string) =>
setSelectedStepId((prev) => (prev === stepId ? null : stepId));
const requestRemoveStep = (stepId: string) => setStepToConfirmDelete(stepId);
const confirmRemoveStep = () => {
if (!stepToConfirmDelete) return;
onRemoveStep(stepToConfirmDelete);
if (selectedStepId === stepToConfirmDelete) setSelectedStepId(null);
setStepToConfirmDelete(null);
};
const stepToDeleteName = stepToConfirmDelete
? (steps.find((s) => s.id === stepToConfirmDelete)?.name ?? "this step")
: "";
const selectedStep = steps.find((s) => s.id === selectedStepId);
const pipelineArea = (
<PipelineArea
steps={steps}
selectedStepId={selectedStepId}
onSelectStep={handleSelectStep}
onRemoveStep={requestRemoveStep}
onAddStep={onAddStep}
readOnly={readOnly}
/>
);
return (
<div className="space-y-3">
<ScrollArea className="w-full pb-1">
{isMounted ? (
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext items={stepItems} strategy={horizontalListSortingStrategy}>
{pipelineArea}
</SortableContext>
</DndContext>
) : (
pipelineArea
)}
<ScrollBar orientation="horizontal" forceMount className="mt-1" />
</ScrollArea>
{selectedStep && (
<StepConfigPanel
key={selectedStep.id}
step={selectedStep}
steps={steps}
onUpdate={(updates) => onUpdateStep(selectedStep.id, updates)}
onRemove={() => requestRemoveStep(selectedStep.id)}
readOnly={readOnly}
/>
)}
<StepDeleteConfirmation
stepName={stepToDeleteName}
open={!!stepToConfirmDelete}
onOpenChange={(open) => {
if (!open) setStepToConfirmDelete(null);
}}
onConfirm={confirmRemoveStep}
/>
</div>
);
}