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

563 lines
18 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconTrash } from "@tabler/icons-react";
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 { useDebouncedCallback } from "@/hooks/use-debounce";
import { cn } from "@/lib/utils";
import {
HelpTip,
STEP_COLORS,
hasOnEnterAction,
hasOnExitAction,
} from "./workflow-pipeline-editor-helpers";
import {
useStepActions,
CompleteTaskOnEnterToggle,
TurnStartSelect,
TurnCompleteSelect,
ChildrenCompletedSelect,
} from "./workflow-pipeline-editor-step-actions";
import { StepWipControls } from "./workflow-pipeline-editor-wip-controls";
import { SessionConfigEditor, SessionConfigToggle } from "./workflow-session-config-editor";
import { StepPromptSection } from "./workflow-step-prompt-section";
import { isWorkflowStepDirty, isWorkflowStepValueDirty } from "./workflow-dirty-state";
import { WorkflowStepAgentProfileSelector } from "./workflow-step-agent-profile-selector";
import { settingsActionClassName, settingsControlClassName } from "./settings-control";
// --- StepConfigHeader ---
type StepConfigHeaderProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
steps: WorkflowStep[];
localName: string;
onLocalNameChange: (name: string) => void;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRestoreSource?: () => void;
onRemove: () => void;
readOnly: boolean;
debouncedUpdateName: (name: string) => void;
};
function StepConfigHeader({
step,
savedStep,
steps,
localName,
onLocalNameChange,
onUpdate,
onRestoreSource,
onRemove,
readOnly,
debouncedUpdateName,
}: StepConfigHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<Input
id={`${step.id}-name`}
value={localName}
onChange={(e) => {
if (readOnly) return;
onLocalNameChange(e.target.value);
debouncedUpdateName(e.target.value);
}}
placeholder={t("workflows:stepNamePlaceholder")}
disabled={readOnly}
className={settingsControlClassName("w-full sm:max-w-[240px]")}
data-settings-dirty={!savedStep || localName !== savedStep.name}
/>
<Select
value={step.color}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ color: value });
}}
disabled={readOnly}
>
<SelectTrigger
className={settingsControlClassName("w-full sm:w-[120px]")}
data-settings-dirty={isWorkflowStepValueDirty(step, savedStep, (item) => item.color)}
>
<SelectValue placeholder={t("workflows: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)} />
{t(color.labelKey)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<WorkflowStepAgentProfileSelector
step={step}
savedStep={savedStep}
steps={steps}
onUpdate={onUpdate}
onRestoreSource={onRestoreSource}
readOnly={readOnly}
/>
<SessionConfigToggle
step={step}
savedStep={savedStep}
onUpdate={onUpdate}
readOnly={readOnly}
/>
</div>
<Button
type="button"
variant="ghost"
onClick={onRemove}
disabled={readOnly}
className={settingsActionClassName(
"self-end cursor-pointer text-destructive hover:text-destructive sm:self-auto",
)}
>
<IconTrash className="h-3.5 w-3.5 mr-1" />
{t("workflows:delete")}
</Button>
</div>
);
}
type StepAutoArchiveRowProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
readOnly: boolean;
};
function StepAutoArchiveRow({ step, savedStep, onUpdate, readOnly }: StepAutoArchiveRowProps) {
const { t } = useTranslation();
const isDirty = isWorkflowStepValueDirty(
step,
savedStep,
(item) => item.auto_archive_after_hours ?? 0,
);
return (
<div className="flex flex-wrap 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}
data-settings-dirty={isDirty}
/>
<Label htmlFor={`${step.id}-auto-archive`} className="text-sm">
{t("workflows:autoArchive")}
</Label>
<HelpTip text={t("workflows:autoArchiveHelp")} />
{(step.auto_archive_after_hours ?? 0) > 0 && (
<>
<span className="text-sm text-muted-foreground">{t("workflows:autoArchiveAfter")}</span>
<Input
id={`${step.id}-auto-archive-hours`}
type="number"
min={1}
className={settingsControlClassName("w-20 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}
data-settings-dirty={isDirty}
/>
<span className="text-sm text-muted-foreground">{t("workflows:autoArchiveHours")}</span>
</>
)}
</div>
);
}
type StepCheckboxRowProps = {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
label: string;
helpText: string;
isDirty: boolean;
};
function StepCheckboxRow({
id,
checked,
onCheckedChange,
disabled,
label,
helpText,
isDirty,
}: StepCheckboxRowProps) {
return (
<div className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(v) => onCheckedChange(v === true)}
disabled={disabled}
data-settings-dirty={isDirty}
/>
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<HelpTip text={helpText} />
</div>
);
}
// --- StepBehaviorSection ---
type StepBehaviorSectionProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
toggleOnEnterAction: (type: string) => void;
isFinalStep: boolean;
readOnly: boolean;
};
type StepToggleRowsProps = Omit<StepBehaviorSectionProps, "steps" | "isFinalStep">;
// The six on-enter / board-behavior toggles. Split out of StepBehaviorSection
// so that component stays under the 100-line function cap.
function StepToggleRows({
step,
savedStep,
onUpdate,
toggleOnEnterAction,
readOnly,
}: StepToggleRowsProps) {
const { t } = useTranslation();
return (
<>
<StepCheckboxRow
id={`${step.id}-start-step`}
checked={step.is_start_step === true}
onCheckedChange={(c) => !readOnly && onUpdate({ is_start_step: c })}
disabled={readOnly}
label={t("workflows:startStep")}
helpText={t("workflows:startStepHelp")}
isDirty={isWorkflowStepValueDirty(step, savedStep, (item) => item.is_start_step ?? false)}
/>
<StepCheckboxRow
id={`${step.id}-auto-start`}
checked={hasOnEnterAction(step, "auto_start_agent")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("auto_start_agent")}
disabled={readOnly}
label={t("workflows:autoStartAgent")}
helpText={t("workflows:autoStartAgentHelp")}
isDirty={isWorkflowStepValueDirty(step, savedStep, (item) =>
hasOnEnterAction(item, "auto_start_agent"),
)}
/>
<StepCheckboxRow
id={`${step.id}-plan-mode`}
checked={hasOnEnterAction(step, "enable_plan_mode")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("enable_plan_mode")}
disabled={readOnly}
label={t("workflows:planMode")}
helpText={t("workflows:planModeHelp")}
isDirty={isWorkflowStepValueDirty(step, savedStep, (item) =>
hasOnEnterAction(item, "enable_plan_mode"),
)}
/>
<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={t("workflows:resetAgentContext")}
helpText={
step.agent_profile_id
? t("workflows:resetAgentContextHelpWithProfile")
: t("workflows:resetAgentContextHelp")
}
isDirty={isWorkflowStepValueDirty(step, savedStep, (item) =>
hasOnEnterAction(item, "reset_agent_context"),
)}
/>
<StepCheckboxRow
id={`${step.id}-manual-move`}
checked={step.allow_manual_move !== false}
onCheckedChange={(c) => !readOnly && onUpdate({ allow_manual_move: c })}
disabled={readOnly}
label={t("workflows:allowManualMove")}
helpText={t("workflows:allowManualMoveHelp")}
isDirty={isWorkflowStepValueDirty(
step,
savedStep,
(item) => item.allow_manual_move ?? true,
)}
/>
<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={t("workflows:showInCommandPanel")}
helpText={t("workflows:showInCommandPanelHelp")}
isDirty={isWorkflowStepValueDirty(
step,
savedStep,
(item) => item.show_in_command_panel ?? false,
)}
/>
<StepAutoArchiveRow
step={step}
savedStep={savedStep}
onUpdate={onUpdate}
readOnly={readOnly}
/>
</>
);
}
function StepBehaviorSection({
step,
savedStep,
steps,
onUpdate,
toggleOnEnterAction,
isFinalStep,
readOnly,
}: StepBehaviorSectionProps) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<StepToggleRows
step={step}
savedStep={savedStep}
onUpdate={onUpdate}
toggleOnEnterAction={toggleOnEnterAction}
readOnly={readOnly}
/>
</div>
<CompleteTaskOnEnterToggle
step={step}
savedStep={savedStep}
onUpdate={onUpdate}
readOnly={readOnly}
isFinalStep={isFinalStep}
/>
<StepWipControls
step={step}
savedStep={savedStep}
steps={steps}
onUpdate={onUpdate}
readOnly={readOnly}
/>
</div>
);
}
// --- StepTransitionsSection ---
type StepTransitionsSectionProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
setTransition: (type: string) => void;
setOnTurnStartTransition: (type: string) => void;
setChildrenCompletedTransition: (type: string) => void;
toggleDisablePlanMode: () => void;
toggleOnExitAction: (type: string) => void;
readOnly: boolean;
};
function StepTransitionsSection({
step,
savedStep,
steps,
onUpdate,
setTransition,
setOnTurnStartTransition,
setChildrenCompletedTransition,
toggleDisablePlanMode,
toggleOnExitAction,
readOnly,
}: StepTransitionsSectionProps) {
const { t } = useTranslation();
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">
{t("workflows:transitions")}
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<TurnStartSelect
step={step}
savedStep={savedStep}
otherSteps={otherSteps}
onUpdate={onUpdate}
setOnTurnStartTransition={setOnTurnStartTransition}
readOnly={readOnly}
/>
<TurnCompleteSelect
step={step}
savedStep={savedStep}
otherSteps={otherSteps}
onUpdate={onUpdate}
setTransition={setTransition}
toggleDisablePlanMode={toggleDisablePlanMode}
planModeEnabled={planModeEnabled}
readOnly={readOnly}
/>
<ChildrenCompletedSelect
step={step}
savedStep={savedStep}
otherSteps={otherSteps}
onUpdate={onUpdate}
setChildrenCompletedTransition={setChildrenCompletedTransition}
readOnly={readOnly}
/>
</div>
{planModeEnabled && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">{t("workflows:onExit")}</Label>
<HelpTip text={t("workflows:onExitHelp")} />
</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}
data-settings-dirty={isWorkflowStepValueDirty(step, savedStep, (item) =>
hasOnExitAction(item, "disable_plan_mode"),
)}
/>
<Label htmlFor={`${step.id}-exit-disable-plan`} className="text-sm">
{t("workflows:disablePlanMode")}
</Label>
<HelpTip text={t("workflows:disablePlanModeOnExitHelp")} />
</div>
</div>
)}
</div>
);
}
type StepConfigPanelProps = {
step: WorkflowStep;
savedStep?: WorkflowStep;
steps: WorkflowStep[];
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
onRestoreSourceStep?: (sourceStepId: string) => void;
readOnly?: boolean;
onSessionConfigResolutionPendingChange?: (pending: boolean) => void;
};
export function StepConfigPanel({
step,
savedStep,
steps,
onUpdate,
onRemove,
onRestoreSourceStep,
readOnly = false,
onSessionConfigResolutionPendingChange,
}: 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 });
const sourceTarget = step.session_target?.kind === "step" ? step.session_target : undefined;
const isFinalStep = steps[steps.length - 1]?.id === step.id;
return (
<div
key={step.id}
className="rounded-lg border bg-card animate-in fade-in-0 slide-in-from-top-2 duration-200"
data-settings-dirty={isWorkflowStepDirty(step, savedStep)}
data-settings-dirty-level="container"
data-testid={`workflow-step-panel-${step.id}`}
>
<StepConfigHeader
step={step}
savedStep={savedStep}
steps={steps}
onRestoreSource={
sourceTarget && onRestoreSourceStep
? () => onRestoreSourceStep(sourceTarget.step_id)
: undefined
}
localName={localName}
onLocalNameChange={setLocalName}
onUpdate={onUpdate}
onRemove={onRemove}
readOnly={readOnly}
debouncedUpdateName={debouncedUpdateName}
/>
<div className="p-4 space-y-5">
<StepBehaviorSection
step={step}
savedStep={savedStep}
steps={steps}
onUpdate={onUpdate}
toggleOnEnterAction={actions.toggleOnEnterAction}
isFinalStep={isFinalStep}
readOnly={readOnly}
/>
<SessionConfigEditor
{...{ step, savedStep, steps, onUpdate, readOnly }}
onResolutionPendingChange={onSessionConfigResolutionPendingChange}
/>
<StepTransitionsSection
step={step}
savedStep={savedStep}
steps={steps}
onUpdate={onUpdate}
setTransition={actions.setTransition}
setOnTurnStartTransition={actions.setOnTurnStartTransition}
setChildrenCompletedTransition={actions.setChildrenCompletedTransition}
toggleDisablePlanMode={actions.toggleDisablePlanMode}
toggleOnExitAction={actions.toggleOnExitAction}
readOnly={readOnly}
/>
<StepPromptSection
step={step}
savedStep={savedStep}
localPrompt={localPrompt}
onLocalPromptChange={setLocalPrompt}
debouncedUpdatePrompt={debouncedUpdatePrompt}
readOnly={readOnly}
/>
</div>
</div>
);
}