Files
kandev/apps/web/components/settings/workflow-card.tsx
T
2026-08-12 11:12:56 +01:00

504 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { t as translate } from "@/lib/i18n";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
import type { WorkflowReplayCycleDiagnostic } from "@/lib/workflows/replay-cycle-analysis";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { WorkflowPipelineEditor } from "@/components/settings/workflow-pipeline-editor";
import { listWorkflowStepsAction } from "@/app/actions/workspaces";
import { HelpTip } from "./workflow-pipeline-editor-helpers";
import {
WorkflowCardDialogs,
type StepDeleteState,
type WorkflowDeleteState,
} from "./workflow-card-dialogs-content";
import {
useWorkflowStepActions,
useWorkflowDeleteHandlers,
useStepDeleteHandlers,
} from "./workflow-card-actions";
import { WorkflowCardHeaderActions } from "./workflow-card-header-actions";
import { SettingsCard } from "./settings-card";
import { isWorkflowFieldDirty } from "./workflow-dirty-state";
import { WorkflowSyncedBadge } from "./workflow-synced-badge";
import { useWorkflowMutationGuard } from "./workflow-mutation-guard";
import { useWorkflowDraftContributor } from "./use-workflow-draft-contributor";
import { WorkflowPromptSection } from "./workflow-prompt-section";
import { WorkflowDescriptionField } from "./workflow-description-field";
import { useWorkflowDuplication } from "@/app/settings/workspace/use-workflow-duplication";
const TEMP_WORKFLOW_PREFIX = "temp-workflow-";
type WorkflowCardProps = {
workflow: Workflow;
savedWorkflow?: Workflow;
isWorkflowDirty: boolean;
isOrderDirty?: boolean;
initialWorkflowSteps?: WorkflowStep[];
otherWorkflows?: Workflow[];
/** Workflows in the dedicated Improve Kandev workspace are read-only. */
isImproveWorkspace?: boolean;
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
}) => void;
onDeleteWorkflow: () => Promise<unknown>;
onDuplicateWorkflow: (steps: WorkflowStep[]) => void;
onWorkflowSaved: (params: {
clientWorkflow: Workflow;
submittedWorkflow: Workflow;
savedWorkflow: Workflow;
currentSteps: WorkflowStep[];
savedSteps: WorkflowStep[];
finalizeIdentity: boolean;
}) => void;
onDiscardWorkflow: () => void;
};
function useWorkflowSteps(
workflowId: string,
initialSteps: WorkflowStep[] | undefined,
toast: ReturnType<typeof useToast>["toast"],
) {
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>(initialSteps ?? []);
const [savedWorkflowSteps, setSavedWorkflowSteps] = useState<WorkflowStep[]>(initialSteps ?? []);
const [workflowLoading, setWorkflowLoading] = useState(false);
useEffect(() => {
if (workflowId.startsWith(TEMP_WORKFLOW_PREFIX)) return;
let cancelled = false;
const load = async () => {
setWorkflowLoading(true);
try {
const res = await listWorkflowStepsAction(workflowId);
if (!cancelled) {
setWorkflowSteps(res.steps ?? []);
setSavedWorkflowSteps(res.steps ?? []);
}
} catch {
if (!cancelled)
toast({ title: translate("workflows:failedToLoadWorkflowSteps"), variant: "error" });
} finally {
if (!cancelled) setWorkflowLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [workflowId, initialSteps, toast]);
const refreshWorkflowSteps = async () => {
try {
const res = await listWorkflowStepsAction(workflowId);
setWorkflowSteps(res.steps ?? []);
setSavedWorkflowSteps(res.steps ?? []);
} catch {
/* ignore */
}
};
return {
workflowSteps,
setWorkflowSteps,
savedWorkflowSteps,
setSavedWorkflowSteps,
workflowLoading,
refreshWorkflowSteps,
};
}
function useWorkflowDeleteState(): WorkflowDeleteState {
const [deleteOpen, setDeleteOpen] = useState(false);
const [workflowTaskCount, setWorkflowTaskCount] = useState<number | null>(null);
const [workflowDeleteLoading, setWorkflowDeleteLoading] = useState(false);
const [targetWorkflowId, setTargetWorkflowId] = useState<string>("");
const [targetWorkflowSteps, setTargetWorkflowSteps] = useState<WorkflowStep[]>([]);
const [targetStepId, setTargetStepId] = useState<string>("");
const [migrateLoading, setMigrateLoading] = useState(false);
return {
deleteOpen,
setDeleteOpen,
workflowTaskCount,
setWorkflowTaskCount,
workflowDeleteLoading,
setWorkflowDeleteLoading,
targetWorkflowId,
setTargetWorkflowId,
targetWorkflowSteps,
setTargetWorkflowSteps,
targetStepId,
setTargetStepId,
migrateLoading,
setMigrateLoading,
};
}
function useStepDeleteState(): StepDeleteState {
const [stepDeleteOpen, setStepDeleteOpen] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
const [stepTaskCount, setStepTaskCount] = useState<number | null>(null);
const [targetStepForMigration, setTargetStepForMigration] = useState<string>("");
const [stepMigrateLoading, setStepMigrateLoading] = useState(false);
const [stepDeletePending, setStepDeletePending] = useState(false);
return {
stepDeleteOpen,
setStepDeleteOpen,
stepToDelete,
setStepToDelete,
stepTaskCount,
setStepTaskCount,
targetStepForMigration,
setTargetStepForMigration,
stepMigrateLoading,
setStepMigrateLoading,
stepDeletePending,
setStepDeletePending,
};
}
type WorkflowCardBodyProps = {
workflow: Workflow;
savedWorkflow?: Workflow;
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
prompt?: string;
agent_profile_id?: string;
}) => void;
workflowLoading: boolean;
workflowSteps: WorkflowStep[];
savedWorkflowSteps: WorkflowStep[];
diagnostics: WorkflowReplayCycleDiagnostic[];
mutationPending: boolean;
/** Read-only reason label: Improve Kandev workspace vs GitHub sync. */
isImproveWorkspace?: boolean;
stepActions: {
handleUpdateWorkflowStep: (id: string, updates: Partial<WorkflowStep>) => Promise<void>;
handleAddWorkflowStep: () => Promise<void>;
handleRemoveWorkflowStep: (id: string) => Promise<void>;
handleReorderWorkflowSteps: (steps: WorkflowStep[]) => Promise<void>;
};
readOnly: boolean;
onSessionConfigResolutionPendingChange: (pending: boolean) => void;
};
function WorkflowNameField({
workflow,
savedWorkflow,
onUpdateWorkflow,
readOnly,
isImproveWorkspace,
}: Pick<
WorkflowCardBodyProps,
"workflow" | "savedWorkflow" | "onUpdateWorkflow" | "readOnly" | "isImproveWorkspace"
>) {
const { t } = useTranslation();
return (
<div className="flex-1 space-y-1.5">
<Label className="flex items-center gap-2">
<span>{t("workflows:workflowName")}</span>
{readOnly && workflow.source === "github" && (
<WorkflowSyncedBadge sourcePath={workflow.source_path} />
)}
{readOnly && (
<span className="text-xs text-muted-foreground">
{isImproveWorkspace
? t("workflows:readOnlyManagedByImproveKandev")
: t("workflows:readOnlyManagedBySync")}
</span>
)}
</Label>
<Input
value={workflow.name}
onChange={(e) => onUpdateWorkflow({ name: e.target.value })}
disabled={readOnly}
data-settings-dirty={isWorkflowFieldDirty(workflow, savedWorkflow, "name")}
/>
</div>
);
}
function WorkflowCardBody({
workflow,
savedWorkflow,
onUpdateWorkflow,
workflowLoading,
workflowSteps,
savedWorkflowSteps,
diagnostics,
mutationPending,
isImproveWorkspace,
stepActions,
readOnly,
onSessionConfigResolutionPendingChange,
}: WorkflowCardBodyProps) {
const { t } = useTranslation();
const healthyProfiles = useHealthyAgentProfiles(workflow.agent_profile_id);
return (
<>
<Label>{t("workflows:workflowDetails")}</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-2">
<WorkflowNameField
workflow={workflow}
savedWorkflow={savedWorkflow}
onUpdateWorkflow={onUpdateWorkflow}
readOnly={readOnly}
isImproveWorkspace={isImproveWorkspace}
/>
<div className="w-full space-y-1.5 sm:w-[240px] sm:shrink-0">
<Label className="flex items-center gap-1">
<span>{t("workflows:agentProfile")}</span>
<HelpTip text={t("workflows:agentProfileHelp")} />
</Label>
<Select
value={workflow.agent_profile_id || "none"}
onValueChange={(value) =>
onUpdateWorkflow({ agent_profile_id: value === "none" ? "" : value })
}
disabled={readOnly}
>
<SelectTrigger
className="w-full cursor-pointer"
data-testid="workflow-agent-profile-select"
data-settings-dirty={isWorkflowFieldDirty(
workflow,
savedWorkflow,
"agent_profile_id",
)}
>
<SelectValue placeholder={t("workflows:noneUseTaskDefault")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
{t("workflows:noneUseTaskDefault")}
</SelectItem>
{healthyProfiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<WorkflowDescriptionField
workflow={workflow}
savedWorkflow={savedWorkflow}
readOnly={readOnly}
onUpdate={(description) => onUpdateWorkflow({ description })}
/>
<WorkflowPromptSection
workflow={workflow}
savedWorkflow={savedWorkflow}
readOnly={readOnly}
onUpdate={(prompt) => onUpdateWorkflow({ prompt })}
/>
<div className="space-y-2">
<Label>{t("workflows:workflowSteps")}</Label>
{workflowLoading ? (
<div className="text-sm text-muted-foreground">{t("workflows:loadingWorkflowSteps")}</div>
) : (
<WorkflowPipelineEditor
steps={workflowSteps}
savedSteps={savedWorkflowSteps}
diagnostics={diagnostics}
onUpdateStep={stepActions.handleUpdateWorkflowStep}
onAddStep={stepActions.handleAddWorkflowStep}
onRemoveStep={stepActions.handleRemoveWorkflowStep}
onReorderSteps={stepActions.handleReorderWorkflowSteps}
readOnly={mutationPending || readOnly}
onSessionConfigResolutionPendingChange={onSessionConfigResolutionPendingChange}
/>
)}
</div>
</>
);
}
function useWorkflowCardState(props: WorkflowCardProps) {
const { workflow, initialWorkflowSteps, otherWorkflows = [] } = props;
const { onDeleteWorkflow } = props;
const { toast } = useToast();
const [exportOpen, setExportOpen] = useState(false);
const [exportYaml, setExportYaml] = useState("");
const wfDel = useWorkflowDeleteState();
const stepDel = useStepDeleteState();
// Workflows synced from a configured GitHub repo are read-only: the
// backend rejects definition mutations with a 409, so the UI disables the
// matching affordances (name/agent-profile/steps/delete) up front.
const readOnly = workflow.source === "github" || props.isImproveWorkspace === true;
const deleteWorkflowRequest = useRequest(onDeleteWorkflow);
const {
workflowSteps,
setWorkflowSteps,
savedWorkflowSteps,
setSavedWorkflowSteps,
workflowLoading,
refreshWorkflowSteps,
} = useWorkflowSteps(workflow.id, initialWorkflowSteps, toast);
const isNewWorkflow = workflow.id.startsWith(TEMP_WORKFLOW_PREFIX);
const mutationGuard = useWorkflowMutationGuard(workflowSteps);
const [sessionConfigResolutionPending, setSessionConfigResolutionPending] = useState(false);
const stepActions = useWorkflowStepActions({
workflow,
isNewWorkflow,
readOnly,
workflowSteps,
setWorkflowSteps,
refreshWorkflowSteps,
setStepToDelete: stepDel.setStepToDelete,
setStepTaskCount: stepDel.setStepTaskCount,
setTargetStepForMigration: stepDel.setTargetStepForMigration,
setStepDeleteOpen: stepDel.setStepDeleteOpen,
toast,
mutationGuard,
});
const workflowDraft = useWorkflowDraftContributor({
workflow,
isWorkflowDirty: props.isWorkflowDirty,
workflowSteps,
savedWorkflowSteps,
setWorkflowSteps,
setSavedWorkflowSteps,
mutationGuard,
toast,
onWorkflowSaved: props.onWorkflowSaved,
onDiscardWorkflow: props.onDiscardWorkflow,
onDeleteWorkflow: props.onDeleteWorkflow,
isSessionConfigResolutionPending: sessionConfigResolutionPending,
});
const wfDeleteHandlers = useWorkflowDeleteHandlers({
workflow,
readOnly,
otherWorkflows,
wfDel,
deleteWorkflowRun: deleteWorkflowRequest.run,
toast,
});
const stepDeleteHandlers = useStepDeleteHandlers({
workflow,
stepDel,
refreshWorkflowSteps,
runMutation: stepActions.runMutation,
});
const stepsForStepMigration = stepDel.stepToDelete
? workflowSteps.filter((s) => s.id !== stepDel.stepToDelete)
: [];
return {
toast,
exportOpen,
setExportOpen,
exportYaml,
setExportYaml,
wfDel,
stepDel,
readOnly,
mutationGuard,
deleteWorkflowRequest,
workflowSteps,
savedWorkflowSteps,
workflowLoading,
stepActions,
wfDeleteHandlers,
stepDeleteHandlers,
stepsForStepMigration,
...workflowDraft,
sessionConfigResolutionPending,
setSessionConfigResolutionPending,
};
}
export function WorkflowCard(props: WorkflowCardProps) {
const { t } = useTranslation();
const { workflow, savedWorkflow, otherWorkflows = [], onUpdateWorkflow } = props;
const s = useWorkflowCardState(props);
const visibleSavedSteps = savedWorkflow ? s.savedWorkflowSteps : [];
const duplicate = useWorkflowDuplication({
workflow,
hasUnsavedChanges: s.hasUnsavedChanges,
mutationPending: s.mutationGuard.isMutationPending,
isImproveWorkspace: props.isImproveWorkspace,
onDuplicateWorkflow: props.onDuplicateWorkflow,
toast: s.toast,
});
return (
<SettingsCard
isDirty={s.hasUnsavedChanges || props.isOrderDirty}
data-testid={`workflow-card-${workflow.id}`}
>
<CardContent className="pt-6">
<div className="space-y-4">
<WorkflowCardBody
workflow={workflow}
savedWorkflow={savedWorkflow}
onUpdateWorkflow={onUpdateWorkflow}
workflowLoading={s.workflowLoading}
workflowSteps={s.workflowSteps}
savedWorkflowSteps={visibleSavedSteps}
diagnostics={s.mutationGuard.diagnostics}
mutationPending={s.mutationGuard.isMutationPending}
isImproveWorkspace={props.isImproveWorkspace}
stepActions={s.stepActions}
readOnly={s.readOnly}
onSessionConfigResolutionPendingChange={s.setSessionConfigResolutionPending}
/>
<WorkflowCardHeaderActions
workflowId={workflow.id}
setExportYaml={s.setExportYaml}
setExportOpen={s.setExportOpen}
toast={s.toast}
onDeleteClick={async () => {
if (workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)) await s.removeDraftWorkflow();
else await s.wfDeleteHandlers.handleDeleteWorkflowClick();
}}
onDuplicateClick={duplicate.handleDuplicateWorkflow}
deleteDisabled={
s.mutationGuard.isMutationPending ||
s.deleteWorkflowRequest.isLoading ||
s.wfDel.workflowDeleteLoading ||
s.isRemovingDraft ||
s.readOnly
}
readOnly={s.readOnly}
exportDisabled={workflow.id.startsWith(TEMP_WORKFLOW_PREFIX)}
duplicateDisabled={duplicate.duplicateDisabled}
duplicateDisabledReason={duplicate.duplicateDisabledReason}
duplicateLoading={duplicate.duplicateLoading}
/>
</div>
</CardContent>
<WorkflowCardDialogs
wfDel={s.wfDel}
otherWorkflows={otherWorkflows}
deleteWorkflowLoading={s.deleteWorkflowRequest.isLoading}
wfDeleteHandlers={s.wfDeleteHandlers}
exportOpen={s.exportOpen}
setExportOpen={s.setExportOpen}
exportYaml={s.exportYaml}
stepDel={s.stepDel}
stepToDeleteName={
s.workflowSteps.find((step) => step.id === s.stepDel.stepToDelete)?.name ??
t("workflows:selectedStepFallback")
}
stepsForStepMigration={s.stepsForStepMigration}
stepDeleteHandlers={s.stepDeleteHandlers}
hasUnsavedChanges={s.hasUnsavedChanges}
mutationGuard={s.mutationGuard}
/>
</SettingsCard>
);
}