Files
kandev/apps/web/components/settings/workflow-step-editor.tsx
T
2026-02-01 19:07:52 +00:00

388 lines
13 KiB
TypeScript

'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconPlus,
IconTrash,
IconChevronDown,
IconChevronUp,
IconRobot,
IconCheck,
IconClipboard,
} from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Textarea } from '@kandev/ui/textarea';
import { Checkbox } from '@kandev/ui/checkbox';
import { Label } from '@kandev/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import type { WorkflowStep, WorkflowStepType, StepBehaviors } from '@/lib/types/http';
import { useDebouncedCallback } from '@/hooks/use-debounce';
const STEP_COLORS = [
{ value: 'bg-slate-500', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-purple-500', label: 'Purple' },
];
const STEP_TYPES: { value: WorkflowStepType; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'planning', label: 'Planning' },
{ value: 'implementation', label: 'Implementation' },
{ value: 'review', label: 'Review' },
{ value: 'verification', label: 'Verification' },
{ value: 'done', label: 'Done' },
{ value: 'blocked', label: 'Blocked' },
];
type WorkflowStepEditorProps = {
steps: WorkflowStep[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
readOnly?: boolean;
};
type SortableStepCardProps = {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
readOnly?: boolean;
};
// Inner component that uses step values directly - reset via key prop from parent
function SortableStepCardInner({ step, onUpdate, onRemove, readOnly = false }: SortableStepCardProps) {
const [expanded, setExpanded] = useState(false);
// Local state for text inputs to avoid API calls on every keystroke
const [localName, setLocalName] = useState(step.name);
const [localPromptPrefix, setLocalPromptPrefix] = useState(step.behaviors?.promptPrefix ?? '');
const [localPromptSuffix, setLocalPromptSuffix] = useState(step.behaviors?.promptSuffix ?? '');
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: step.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const behaviors = step.behaviors ?? {};
// Debounced update functions
const debouncedUpdateName = useDebouncedCallback((name: string) => {
onUpdate({ name });
}, 500);
const debouncedUpdatePromptPrefix = useDebouncedCallback((promptPrefix: string) => {
onUpdate({ behaviors: { ...behaviors, promptPrefix } });
}, 500);
const debouncedUpdatePromptSuffix = useDebouncedCallback((promptSuffix: string) => {
onUpdate({ behaviors: { ...behaviors, promptSuffix } });
}, 500);
const updateBehaviors = (updates: Partial<StepBehaviors>) => {
onUpdate({ behaviors: { ...behaviors, ...updates } });
};
return (
<Card ref={setNodeRef} style={style} className="mb-2">
<CardContent className="p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[auto_auto_1fr_160px_72px_32px_32px] sm:items-center min-w-0">
<button
type="button"
className={`p-1 rounded-md text-muted-foreground hover:text-foreground ${readOnly ? 'cursor-default' : 'cursor-grab'}`}
{...(readOnly ? {} : attributes)}
{...(readOnly ? {} : listeners)}
aria-disabled={readOnly}
>
<IconGripVertical className="h-4 w-4" />
</button>
<div className={`w-4 h-4 rounded shrink-0 ${step.color}`} />
<Input
value={localName}
onChange={(e) => {
if (readOnly) return;
setLocalName(e.target.value);
debouncedUpdateName(e.target.value);
}}
className="w-full min-w-0"
placeholder="Step name"
disabled={readOnly}
/>
<Select
value={step.step_type}
onValueChange={(value: WorkflowStepType) => {
if (readOnly) return;
onUpdate({ step_type: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-full sm:w-[160px] shrink-0">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-muted-foreground w-[72px] shrink-0">
{behaviors.autoStartAgent && <IconRobot className="h-4 w-4" title="Auto-start agent" />}
{behaviors.requireApproval && <IconCheck className="h-4 w-4" title="Require approval" />}
{behaviors.planMode && <IconClipboard className="h-4 w-4" title="Plan mode" />}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setExpanded(!expanded)}
className="shrink-0"
>
{expanded ? <IconChevronUp className="h-4 w-4" /> : <IconChevronDown className="h-4 w-4" />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
disabled={readOnly}
className="shrink-0"
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-auto-start`}
checked={behaviors.autoStartAgent ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ autoStartAgent: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-start`}>Auto-start agent</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-plan-mode`}
checked={behaviors.planMode ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ planMode: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-plan-mode`}>Plan mode</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-require-approval`}
checked={behaviors.requireApproval ?? false}
onCheckedChange={(checked) => {
if (readOnly) return;
updateBehaviors({ requireApproval: checked === true });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-require-approval`}>Require approval</Label>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-prefix`}>Prompt Prefix</Label>
<Textarea
id={`${step.id}-prompt-prefix`}
value={localPromptPrefix}
onChange={(e) => {
if (readOnly) return;
setLocalPromptPrefix(e.target.value);
debouncedUpdatePromptPrefix(e.target.value);
}}
placeholder="Text prepended to the task description when agent starts..."
rows={3}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-suffix`}>Prompt Suffix</Label>
<Textarea
id={`${step.id}-prompt-suffix`}
value={localPromptSuffix}
onChange={(e) => {
if (readOnly) return;
setLocalPromptSuffix(e.target.value);
debouncedUpdatePromptSuffix(e.target.value);
}}
placeholder="Text appended to the task description when agent starts..."
rows={3}
disabled={readOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-color`}>Color</Label>
<Select
value={step.color}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ color: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${step.color}`} />
<SelectValue placeholder="Select color" />
</div>
</SelectTrigger>
<SelectContent>
{STEP_COLORS.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${color.value}`} />
{color.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
);
}
// Wrapper component that uses key to reset local state when step data changes externally
function SortableStepCard({ step, onUpdate, onRemove, readOnly = false }: SortableStepCardProps) {
// Generate a stable key based on step values that should trigger a reset
// This resets local state when the step is reordered or updated externally
const resetKey = `${step.id}-${step.name}-${step.behaviors?.promptPrefix ?? ''}-${step.behaviors?.promptSuffix ?? ''}`;
return <SortableStepCardInner key={resetKey} step={step} onUpdate={onUpdate} onRemove={onRemove} readOnly={readOnly} />;
}
export function WorkflowStepEditor({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
readOnly = false,
}: WorkflowStepEditorProps) {
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
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;
const nextSteps = arrayMove(steps, oldIndex, newIndex).map((step, index) => ({
...step,
position: index,
}));
onReorderSteps(nextSteps);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Workflow Steps</h3>
<Button variant="outline" size="sm" onClick={onAddStep} disabled={readOnly}>
<IconPlus className="mr-2 h-4 w-4" />
Add Step
</Button>
</div>
<div className="space-y-2">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stepItems}>
{steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => {
if (readOnly) return;
onUpdateStep(step.id, updates);
}}
onRemove={() => {
if (readOnly) return;
onRemoveStep(step.id);
}}
readOnly={readOnly}
/>
))}
</SortableContext>
</DndContext>
) : (
steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => {
if (readOnly) return;
onUpdateStep(step.id, updates);
}}
onRemove={() => {
if (readOnly) return;
onRemoveStep(step.id);
}}
readOnly={readOnly}
/>
))
)}
</div>
</div>
);
}