Files
kandev/apps/web/components/settings/workflow-cycle-diagnostic.tsx
T
2026-07-17 18:47:27 +01:00

189 lines
6.8 KiB
TypeScript

"use client";
import { useRef } from "react";
import { IconAlertTriangle, IconArrowRight, IconUser } from "@tabler/icons-react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import type {
WorkflowReplayCycleDiagnostic,
WorkflowReplayCycleHop,
} from "@/lib/workflows/replay-cycle-analysis";
import type { WorkflowMutationProposal } from "./workflow-mutation-guard";
import { cn } from "@/lib/utils";
const PROMPT_SOURCE_TEXT = {
task_description: (stepName: string) =>
`"${stepName}" has no step prompt, so re-entering it sends the task description.`,
step_prompt_with_task_description: (stepName: string) =>
`Re-entering "${stepName}" sends its rendered step prompt including the task description.`,
step_prompt: (stepName: string) =>
`Re-entering "${stepName}" sends its step prompt instead of the task description.`,
} as const;
const TRIGGER_LABELS = {
on_turn_start: "On turn start",
on_turn_complete: "On turn complete",
} as const;
const ACTION_LABELS = {
move_to_next: "Move to next step",
move_to_previous: "Move to previous step",
move_to_step: "Move to specific step",
} as const;
function CycleHop({ hop, index }: { hop: WorkflowReplayCycleHop; index: number }) {
return (
<li className="min-w-0 rounded-md border bg-background/60 p-3">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
<span className="min-w-0 break-words font-medium">{hop.sourceStepName}</span>
<IconArrowRight className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 break-words text-right font-medium">
{hop.destinationStepName}
</span>
</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span className="whitespace-nowrap rounded bg-muted px-2 py-1">
{TRIGGER_LABELS[hop.trigger]}
</span>
<span className="whitespace-nowrap rounded bg-muted px-2 py-1">
{ACTION_LABELS[hop.actionKind]}
</span>
</div>
{hop.requiresUserInvolvement && (
<div className="mt-2 flex items-center gap-1.5 text-xs font-medium text-foreground">
<IconUser className="size-3.5 shrink-0" aria-hidden="true" />
<span>User action required</span>
</div>
)}
<span className="sr-only">Hop {index + 1}</span>
</li>
);
}
export function WorkflowCycleDiagnostic({
diagnostic,
}: {
diagnostic: WorkflowReplayCycleDiagnostic;
}) {
const isBlocking = diagnostic.severity === "blocking";
const promptText = PROMPT_SOURCE_TEXT[diagnostic.promptSource](diagnostic.autoStartStepName);
return (
<Alert
variant={isBlocking ? "destructive" : "default"}
className={cn(
"min-w-0 overflow-hidden p-3 text-sm",
!isBlocking && "border-amber-500/60 bg-amber-500/5",
)}
data-testid={`workflow-cycle-diagnostic-${diagnostic.autoStartStepId}`}
>
<IconAlertTriangle className="mt-0.5 size-4" aria-hidden="true" />
<AlertTitle className="text-sm">
{isBlocking ? "Automatic workflow cycle" : "Potential repeated agent run"}
</AlertTitle>
<AlertDescription className="min-w-0 space-y-3 text-left text-sm text-pretty">
<p>
{isBlocking
? `This path re-enters "${diagnostic.autoStartStepName}" and can start the agent again without another user action.`
: `This path can re-enter "${diagnostic.autoStartStepName}" and start the agent again after a user action.`}
</p>
<ol
aria-label={`Replay path for ${diagnostic.autoStartStepName}`}
className="grid min-w-0 gap-2"
>
{diagnostic.trace.map((hop, index) => (
<CycleHop
key={`${diagnostic.identity}-${hop.sourceStepId}-${hop.trigger}-${index}`}
hop={hop}
index={index}
/>
))}
</ol>
<p className="break-words">{promptText}</p>
</AlertDescription>
</Alert>
);
}
type WorkflowCycleGuardDialogProps = {
proposal: WorkflowMutationProposal | null;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
};
export function WorkflowCycleGuardDialog({
proposal,
onCancel,
onConfirm,
}: WorkflowCycleGuardDialogProps) {
const isBlocking = proposal?.severity === "blocking";
const actionLabel = proposal?.intent === "create" ? "Create anyway" : "Apply anyway";
const confirming = useRef(false);
const handleConfirm = async () => {
confirming.current = true;
try {
await onConfirm();
} finally {
confirming.current = false;
}
};
return (
<AlertDialog
open={proposal !== null}
onOpenChange={(open) => !open && !confirming.current && onCancel()}
>
<AlertDialogContent
className="max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-2xl"
enterConfirms={!isBlocking}
data-testid="workflow-cycle-guard-dialog"
>
<AlertDialogHeader className="place-items-start p-4 pb-3 text-left sm:p-6 sm:pb-4">
<AlertDialogTitle className="text-lg">
{isBlocking ? "Workflow cycle blocked" : "Confirm workflow cycle"}
</AlertDialogTitle>
<AlertDialogDescription className="text-left text-sm">
{isBlocking
? "Change the workflow steps to remove the automatic cycle before continuing."
: "Review the repeated agent run before continuing."}
</AlertDialogDescription>
</AlertDialogHeader>
<div
className="min-h-0 overflow-x-hidden overflow-y-auto px-4 pb-4 sm:px-6"
data-testid="workflow-cycle-guard-scroll"
>
<div className="grid min-w-0 gap-3">
{proposal?.diagnostics.map((diagnostic) => (
<WorkflowCycleDiagnostic key={diagnostic.identity} diagnostic={diagnostic} />
))}
</div>
</div>
<AlertDialogFooter className="border-t bg-background p-4 sm:px-6">
<AlertDialogCancel className="min-h-12 w-full cursor-pointer sm:w-auto">
{isBlocking ? "Return to workflow" : "Cancel"}
</AlertDialogCancel>
{!isBlocking && (
<AlertDialogAction
data-dialog-default-action
className="min-h-12 w-full cursor-pointer sm:w-auto"
onClick={handleConfirm}
>
{actionLabel}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}