Files
kandev/apps/web/components/settings/workflow-pipeline-editor.tsx
2026-09-10 18:14:05 +01:00

457 lines
15 KiB
TypeScript

"use client";
import { useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
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,
IconAlertTriangle,
} from "@tabler/icons-react";
import { StepCapabilityIcons } from "@/components/step-capability-icons";
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 type { WorkflowReplayCycleDiagnostic } from "@/lib/workflows/replay-cycle-analysis";
import { cn } from "@/lib/utils";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { StepConfigPanel } from "./workflow-pipeline-editor-panels";
import { isWorkflowStepDirty } from "./workflow-dirty-state";
import { WorkflowCycleDiagnostic } from "./workflow-cycle-diagnostic";
import { getWorkflowSessionTargetIssue } from "./workflow-session-target-validation";
type WorkflowPipelineEditorProps = {
steps: WorkflowStep[];
savedSteps?: WorkflowStep[];
diagnostics?: WorkflowReplayCycleDiagnostic[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
readOnly?: boolean;
onSessionConfigResolutionPendingChange?: (pending: boolean) => void;
};
// --- 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";
}
// The transition `type`s are wire values; only the connector caption is copy,
// so the mapping yields a catalog key that the caller resolves at render.
function transitionLabelKey(step: WorkflowStep): string {
const type = getTransitionType(step);
if (type === "move_to_next") return "workflows:transitionAuto";
if (type === "move_to_previous") return "workflows:transitionBack";
if (type === "move_to_step") return "workflows:transitionGoto";
return "workflows:transitionManual";
}
// --- Pipeline Node ---
type PipelineNodeProps = {
step: WorkflowStep;
isDirty: boolean;
isReplayCycleAffected: boolean;
isInvalidSessionTarget: boolean;
isSelected: boolean;
onSelect: () => void;
onRemove: () => void;
readOnly?: boolean;
};
function InvalidSessionTargetIndicator() {
const { t } = useTranslation();
return (
<IconAlertTriangle
className="h-3.5 w-3.5 shrink-0 text-destructive"
aria-label={t("workflows:sessionTargetRepairTitle")}
/>
);
}
function PipelineNode({
step,
isDirty,
isReplayCycleAffected,
isInvalidSessionTarget,
isSelected,
onSelect,
onRemove,
readOnly = false,
}: PipelineNodeProps) {
const { t } = useTranslation();
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",
isReplayCycleAffected && "border-dashed border-amber-500 ring-1 ring-amber-500/30",
isDragging && "opacity-50 z-50",
)}
onClick={onSelect}
data-settings-dirty={isDirty}
data-workflow-replay-cycle={isReplayCycleAffected}
data-workflow-invalid-session-target={isInvalidSessionTarget}
data-settings-dirty-level="container"
data-testid={`workflow-step-node-${step.id}`}
>
{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>{t("workflows:startStep")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isInvalidSessionTarget && <InvalidSessionTargetIndicator />}
<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">
{t("workflows:transitionManual")}
</span>
}
/>
{isReplayCycleAffected && (
<span
className="flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300"
aria-label={t("workflows:stepIsPartOfReplayCycle", { stepName: step.name })}
>
<IconAlertTriangle className="size-3 shrink-0" aria-hidden="true" />
{t("workflows:cycle")}
</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({ labelKey }: { labelKey: string }) {
const { t } = useTranslation();
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">
{t(labelKey)}
</span>
</div>
);
}
// --- Pipeline Area ---
type PipelineAreaProps = {
steps: WorkflowStep[];
savedStepsById: ReadonlyMap<string, WorkflowStep>;
affectedStepIds: Set<string>;
selectedStepId: string | null;
onSelectStep: (stepId: string) => void;
onRemoveStep: (stepId: string) => void;
onAddStep: () => void;
readOnly: boolean;
};
function PipelineArea({
steps,
savedStepsById,
affectedStepIds,
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 labelKey={transitionLabelKey(steps[index - 1])} />}
<PipelineNode
step={step}
isDirty={isWorkflowStepDirty(step, savedStepsById.get(step.id))}
isReplayCycleAffected={affectedStepIds.has(step.id)}
isInvalidSessionTarget={getWorkflowSessionTargetIssue(step, steps) !== undefined}
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={cn(
controlSizingClassName("icon"),
"shrink-0 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>
);
}
function affectedStepIds(diagnostics: WorkflowReplayCycleDiagnostic[]): Set<string> {
return new Set(diagnostics.flatMap((diagnostic) => diagnostic.affectedStepIds));
}
function WorkflowCycleAlerts({ diagnostics }: { diagnostics: WorkflowReplayCycleDiagnostic[] }) {
if (diagnostics.length === 0) return null;
return (
<div className="grid min-w-0 gap-3">
{diagnostics.map((diagnostic) => (
<WorkflowCycleDiagnostic key={diagnostic.identity} diagnostic={diagnostic} />
))}
</div>
);
}
const EMPTY_DIAGNOSTICS: WorkflowReplayCycleDiagnostic[] = [];
type RestoreWorkflowSourceStepParams = {
steps: WorkflowStep[];
savedSteps: WorkflowStep[];
savedStepsById: ReadonlyMap<string, WorkflowStep>;
sourceStepId: string;
onReorderSteps: (steps: WorkflowStep[]) => void;
};
function restoreWorkflowSourceStep({
steps,
savedSteps,
savedStepsById,
sourceStepId,
onReorderSteps,
}: RestoreWorkflowSourceStepParams) {
const savedSource = savedStepsById.get(sourceStepId);
if (!savedSource || !steps.some((step) => step.id === sourceStepId)) return;
const restored = steps.map((step) =>
step.id === sourceStepId
? {
...step,
agent_profile_id: savedSource.agent_profile_id,
session_target: savedSource.session_target ?? null,
}
: step,
);
const currentIndex = restored.findIndex((step) => step.id === sourceStepId);
const savedSourceIndex = [...savedSteps]
.sort((left, right) => left.position - right.position)
.findIndex((step) => step.id === sourceStepId);
if (currentIndex === -1 || savedSourceIndex === -1) return;
const [source] = restored.splice(currentIndex, 1);
restored.splice(Math.min(savedSourceIndex, restored.length), 0, source);
const positioned = restored.map((step, position) => ({ ...step, position }));
onReorderSteps(positioned);
}
function removeWorkflowStep(
stepId: string,
selectedStepId: string | null,
onRemoveStep: (stepId: string) => void,
setSelectedStepId: (stepId: string | null) => void,
) {
onRemoveStep(stepId);
if (selectedStepId === stepId) setSelectedStepId(null);
}
function reorderWorkflowSteps(
event: DragEndEvent,
steps: WorkflowStep[],
readOnly: boolean,
onReorderSteps: (steps: WorkflowStep[]) => void,
) {
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 })),
);
}
// --- Main Pipeline Editor ---
export function WorkflowPipelineEditor({
steps,
savedSteps = [],
diagnostics = EMPTY_DIAGNOSTICS,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
readOnly = false,
onSessionConfigResolutionPendingChange,
}: WorkflowPipelineEditorProps) {
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [prevStepCount, setPrevStepCount] = useState(steps.length);
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 savedStepsById = useMemo(
() => new Map(savedSteps.map((step) => [step.id, step])),
[savedSteps],
);
const affectedIds = useMemo(() => affectedStepIds(diagnostics), [diagnostics]);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
const handleDragEnd = (event: DragEndEvent) =>
reorderWorkflowSteps(event, steps, readOnly, onReorderSteps);
const handleSelectStep = (stepId: string) =>
setSelectedStepId((prev) => (prev === stepId ? null : stepId));
const handleRemoveStep = (stepId: string) =>
removeWorkflowStep(stepId, selectedStepId, onRemoveStep, setSelectedStepId);
const handleRestoreSourceStep = (sourceStepId: string) =>
restoreWorkflowSourceStep({
steps,
savedSteps,
savedStepsById,
sourceStepId,
onReorderSteps,
});
const selectedStep = steps.find((s) => s.id === selectedStepId);
const pipelineArea = (
<PipelineArea
steps={steps}
savedStepsById={savedStepsById}
affectedStepIds={affectedIds}
selectedStepId={selectedStepId}
onSelectStep={handleSelectStep}
onRemoveStep={handleRemoveStep}
onAddStep={onAddStep}
readOnly={readOnly}
/>
);
return (
<div className="space-y-3">
<WorkflowCycleAlerts diagnostics={diagnostics} />
<ScrollArea className="w-full max-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}
savedStep={savedStepsById.get(selectedStep.id)}
steps={steps}
onUpdate={(updates) => onUpdateStep(selectedStep.id, updates)}
onRestoreSourceStep={handleRestoreSourceStep}
onRemove={() => handleRemoveStep(selectedStep.id)}
readOnly={readOnly}
onSessionConfigResolutionPendingChange={onSessionConfigResolutionPendingChange}
/>
)}
</div>
);
}