457 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|