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

549 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import { IconRobot, IconTrash } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
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 } from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useDebouncedCallback } from "@/hooks/use-debounce";
import { cn } from "@/lib/utils";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import {
HelpTip,
STEP_COLORS,
PROMPT_TEMPLATES,
hasOnEnterAction,
hasOnExitAction,
} from "./workflow-pipeline-editor-helpers";
import {
useStepActions,
TurnStartSelect,
TurnCompleteSelect,
} from "./workflow-pipeline-editor-step-actions";
const STEP_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "task_prompt",
description: "The original task description provided by the user",
example: "Implement user authentication with OAuth2",
executor_types: [],
},
];
// --- StepAgentProfileSelect ---
function StepAgentProfileSelect({
step,
onUpdate,
readOnly,
}: {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
readOnly: boolean;
}) {
const healthyProfiles = useHealthyAgentProfiles(step.agent_profile_id);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Select
value={step.agent_profile_id || "none"}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ agent_profile_id: value === "none" ? "" : value });
}}
disabled={readOnly}
>
<SelectTrigger
className="w-[220px] h-8 cursor-pointer"
data-testid="step-agent-profile-select"
>
<IconRobot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<SelectValue placeholder="No profile override" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
No profile override
</SelectItem>
{healthyProfiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Override the agent profile for this step. A different profile creates a new session with
fresh context when entering this step.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// --- StepConfigHeader ---
type StepConfigHeaderProps = {
step: WorkflowStep;
localName: string;
onLocalNameChange: (name: string) => void;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
readOnly: boolean;
debouncedUpdateName: (name: string) => void;
};
function StepConfigHeader({
step,
localName,
onLocalNameChange,
onUpdate,
onRemove,
readOnly,
debouncedUpdateName,
}: StepConfigHeaderProps) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Input
id={`${step.id}-name`}
value={localName}
onChange={(e) => {
if (readOnly) return;
onLocalNameChange(e.target.value);
debouncedUpdateName(e.target.value);
}}
placeholder="Step name"
disabled={readOnly}
className="max-w-[240px] h-8"
/>
<Select
value={step.color}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ color: value });
}}
disabled={readOnly}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
{STEP_COLORS.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center gap-2">
<div className={cn("w-3 h-3 rounded-full", color.value)} />
{color.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<StepAgentProfileSelect step={step} onUpdate={onUpdate} readOnly={readOnly} />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
disabled={readOnly}
className="text-destructive hover:text-destructive h-8"
>
<IconTrash className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</div>
);
}
// --- StepAutoArchiveRow ---
type StepAutoArchiveRowProps = {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
readOnly: boolean;
};
function StepAutoArchiveRow({ step, onUpdate, readOnly }: StepAutoArchiveRowProps) {
return (
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-auto-archive`}
checked={(step.auto_archive_after_hours ?? 0) > 0}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ auto_archive_after_hours: checked ? 24 : 0 });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-archive`} className="text-sm">
Auto-archive
</Label>
<HelpTip text="Automatically archive tasks after they have been in this step for a set number of hours. Useful for the last step of a workflow (e.g., Done) to keep the board clean." />
{(step.auto_archive_after_hours ?? 0) > 0 && (
<>
<span className="text-sm text-muted-foreground">after</span>
<Input
id={`${step.id}-auto-archive-hours`}
type="number"
min={1}
className="w-20 h-7 text-sm"
value={step.auto_archive_after_hours ?? 24}
onChange={(e) => {
if (readOnly) return;
const val = parseInt(e.target.value, 10);
onUpdate({ auto_archive_after_hours: isNaN(val) || val < 1 ? 1 : val });
}}
disabled={readOnly}
/>
<span className="text-sm text-muted-foreground">hours</span>
</>
)}
</div>
);
}
// --- StepCheckboxRow ---
type StepCheckboxRowProps = {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
label: string;
helpText: string;
};
function StepCheckboxRow({
id,
checked,
onCheckedChange,
disabled,
label,
helpText,
}: StepCheckboxRowProps) {
return (
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(v) => onCheckedChange(v === true)}
disabled={disabled}
/>
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<HelpTip text={helpText} />
</div>
);
}
// --- StepBehaviorSection ---
type StepBehaviorSectionProps = {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
toggleOnEnterAction: (type: string) => void;
readOnly: boolean;
};
function StepBehaviorSection({
step,
onUpdate,
toggleOnEnterAction,
readOnly,
}: StepBehaviorSectionProps) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<StepCheckboxRow
id={`${step.id}-start-step`}
checked={step.is_start_step === true}
onCheckedChange={(c) => !readOnly && onUpdate({ is_start_step: c })}
disabled={readOnly}
label="Start step"
helpText="New tasks start in this step. Only one step per workflow can be the start step."
/>
<StepCheckboxRow
id={`${step.id}-auto-start`}
checked={hasOnEnterAction(step, "auto_start_agent")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("auto_start_agent")}
disabled={readOnly}
label="Auto-start agent"
helpText="Automatically start the agent when a task enters this step."
/>
<StepCheckboxRow
id={`${step.id}-plan-mode`}
checked={hasOnEnterAction(step, "enable_plan_mode")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("enable_plan_mode")}
disabled={readOnly}
label="Plan mode"
helpText="Agent proposes a plan instead of making changes directly."
/>
<StepCheckboxRow
id={`${step.id}-reset-context`}
checked={hasOnEnterAction(step, "reset_agent_context")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("reset_agent_context")}
disabled={readOnly || !!step.agent_profile_id}
label="Reset agent context"
helpText={
step.agent_profile_id
? "Not needed — switching agent profiles already creates a new session with fresh context."
: "Restart the agent with a fresh conversation context when entering this step. Useful for review steps that need an unbiased perspective."
}
/>
<StepCheckboxRow
id={`${step.id}-manual-move`}
checked={step.allow_manual_move !== false}
onCheckedChange={(c) => !readOnly && onUpdate({ allow_manual_move: c })}
disabled={readOnly}
label="Allow manual move"
helpText="Allow dragging tasks into this step on the board."
/>
<StepCheckboxRow
id={`${step.id}-command-panel`}
checked={step.show_in_command_panel !== false}
onCheckedChange={(c) => !readOnly && onUpdate({ show_in_command_panel: c })}
disabled={readOnly}
label="Show in command panel"
helpText="Show tasks in this step when opening the command panel (Cmd+K). Useful for hiding backlog or done steps from quick access."
/>
<StepAutoArchiveRow step={step} onUpdate={onUpdate} readOnly={readOnly} />
</div>
</div>
);
}
// --- StepTransitionsSection ---
type StepTransitionsSectionProps = {
step: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
setTransition: (type: string) => void;
setOnTurnStartTransition: (type: string) => void;
toggleDisablePlanMode: () => void;
toggleOnExitAction: (type: string) => void;
readOnly: boolean;
};
function StepTransitionsSection({
step,
steps,
onUpdate,
setTransition,
setOnTurnStartTransition,
toggleDisablePlanMode,
toggleOnExitAction,
readOnly,
}: StepTransitionsSectionProps) {
const otherSteps = steps.filter((s) => s.id !== step.id);
const planModeEnabled = hasOnEnterAction(step, "enable_plan_mode");
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Transitions
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<TurnStartSelect
step={step}
otherSteps={otherSteps}
onUpdate={onUpdate}
setOnTurnStartTransition={setOnTurnStartTransition}
readOnly={readOnly}
/>
<TurnCompleteSelect
step={step}
otherSteps={otherSteps}
onUpdate={onUpdate}
setTransition={setTransition}
toggleDisablePlanMode={toggleDisablePlanMode}
planModeEnabled={planModeEnabled}
readOnly={readOnly}
/>
</div>
{planModeEnabled && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">On Exit</Label>
<HelpTip text="Runs when leaving this step (before entering the next step)." />
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${step.id}-exit-disable-plan`}
checked={hasOnExitAction(step, "disable_plan_mode")}
onCheckedChange={() => {
if (readOnly) return;
toggleOnExitAction("disable_plan_mode");
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-exit-disable-plan`} className="text-sm">
Disable plan mode
</Label>
<HelpTip text="Turn off plan mode when leaving this step." />
</div>
</div>
)}
</div>
);
}
// --- StepPromptSection ---
type StepPromptSectionProps = {
step: WorkflowStep;
localPrompt: string;
onLocalPromptChange: (prompt: string) => void;
debouncedUpdatePrompt: (prompt: string) => void;
readOnly: boolean;
};
function StepPromptSection({
step,
localPrompt,
onLocalPromptChange,
debouncedUpdatePrompt,
readOnly,
}: StepPromptSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label
htmlFor={`${step.id}-prompt`}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Step Prompt
</Label>
<HelpTip text="Custom instructions for the agent on this step. Use {{task_prompt}} to include the task description." />
</div>
{!readOnly && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-muted-foreground/60">Templates:</span>
{PROMPT_TEMPLATES.map((template) => (
<button
key={template.label}
type="button"
onClick={() => {
onLocalPromptChange(template.prompt);
debouncedUpdatePrompt(template.prompt);
}}
className="text-[11px] px-2 py-0.5 rounded-md border border-border bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
{template.label}
</button>
))}
</div>
)}
<div className="rounded-md border overflow-hidden">
<ScriptEditor
value={localPrompt}
onChange={(v) => {
if (readOnly) return;
onLocalPromptChange(v);
debouncedUpdatePrompt(v);
}}
language="markdown"
height={computeEditorHeight(localPrompt)}
lineNumbers="off"
readOnly={readOnly}
placeholders={STEP_PROMPT_PLACEHOLDERS}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
Type {"{{"} to see available placeholders. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{task_prompt}}"}</code> to
include the original task description.
</p>
</div>
);
}
// --- StepConfigPanel ---
type StepConfigPanelProps = {
step: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
readOnly?: boolean;
};
export function StepConfigPanel({
step,
steps,
onUpdate,
onRemove,
readOnly = false,
}: StepConfigPanelProps) {
const [localName, setLocalName] = useState(step.name);
const [localPrompt, setLocalPrompt] = useState(step.prompt ?? "");
const debouncedUpdateName = useDebouncedCallback((name: string) => {
onUpdate({ name });
}, 500);
const debouncedUpdatePrompt = useDebouncedCallback((prompt: string) => {
onUpdate({ prompt });
}, 500);
const actions = useStepActions({ step, onUpdate });
return (
<div
key={step.id}
className="rounded-lg border bg-card animate-in fade-in-0 slide-in-from-top-2 duration-200"
>
<StepConfigHeader
step={step}
localName={localName}
onLocalNameChange={setLocalName}
onUpdate={onUpdate}
onRemove={onRemove}
readOnly={readOnly}
debouncedUpdateName={debouncedUpdateName}
/>
<div className="p-4 space-y-5">
<StepBehaviorSection
step={step}
onUpdate={onUpdate}
toggleOnEnterAction={actions.toggleOnEnterAction}
readOnly={readOnly}
/>
<StepTransitionsSection
step={step}
steps={steps}
onUpdate={onUpdate}
setTransition={actions.setTransition}
setOnTurnStartTransition={actions.setOnTurnStartTransition}
toggleDisablePlanMode={actions.toggleDisablePlanMode}
toggleOnExitAction={actions.toggleOnExitAction}
readOnly={readOnly}
/>
<StepPromptSection
step={step}
localPrompt={localPrompt}
onLocalPromptChange={setLocalPrompt}
debouncedUpdatePrompt={debouncedUpdatePrompt}
readOnly={readOnly}
/>
</div>
</div>
);
}