"use client"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { JsonView } from "@/components/json-view"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Button } from "@/components/ui/button"; import { automationCreateRequest } from "@/contracts/types/automation.types"; import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl"; import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl"; import { AutomationDraftPreview } from "./automation-draft-preview"; const editArgsSchema = automationCreateRequest.omit({ search_space_id: true }); // ---------------------------------------------------------------------------- // Result discrimination — mirrors the backend return shapes in // app/agents/multi_agent_chat/main_agent/tools/automation/create.py. // ---------------------------------------------------------------------------- type AutomationCreateContext = { search_space_id?: number; }; interface SavedResult { status: "saved"; automation_id: number; name: string; } interface RejectedResult { status: "rejected"; message?: string; } interface InvalidResult { status: "invalid"; issues: string[]; raw?: unknown; } interface ErrorResult { status: "error"; message: string; } type CreateAutomationResult = | InterruptResult | SavedResult | RejectedResult | InvalidResult | ErrorResult; function hasStatus(value: unknown, status: string): boolean { return ( typeof value === "object" && value !== null && "status" in value && (value as { status: unknown }).status === status ); } // ---------------------------------------------------------------------------- // Approval card — pending → processing → complete / rejected. // // Edit toggle reuses the same primitives as the Create-via-JSON page: raw // textarea, Format, Zod validation against ``AutomationCreate`` (minus the // ``search_space_id`` field, which the backend injects). Approve dispatches // an ``edit`` decision with the parsed args when edits are pending, otherwise // a plain ``approve``. Multi-turn chat refinement still works as a fallback. // ---------------------------------------------------------------------------- interface ApprovalCardProps { args: Record; interruptData: InterruptResult; onDecision: (decision: HitlDecision) => void; } function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { const { phase, setProcessing, setRejected } = useHitlPhase(interruptData); const reviewConfig = interruptData.review_configs[0]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const canApprove = allowedDecisions.includes("approve"); const canReject = allowedDecisions.includes("reject"); const canEdit = allowedDecisions.includes("edit"); const [pendingEdits, setPendingEdits] = useState | null>(null); const [isEditing, setIsEditing] = useState(false); const effectiveArgs = pendingEdits ?? args; const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]); const handleApprove = useCallback(() => { if (phase !== "pending" || !canApprove || isEditing) return; setProcessing(); onDecision({ type: pendingEdits ? "edit" : "approve", edited_action: { name: interruptData.action_requests[0]?.name ?? "create_automation", args: pendingEdits ?? args, }, }); }, [phase, canApprove, isEditing, setProcessing, onDecision, interruptData, args, pendingEdits]); const handleReject = useCallback(() => { if (phase !== "pending" || !canReject || isEditing) return; setRejected(); onDecision({ type: "reject", message: "User rejected the automation draft." }); }, [phase, canReject, isEditing, setRejected, onDecision]); useEffect(() => { if (isEditing) return; const handler = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { handleApprove(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [handleApprove, isEditing]); return (

{phase === "rejected" ? "Automation cancelled" : phase === "processing" ? "Saving automation" : phase === "complete" ? "Automation saved" : "Create automation"}

{phase === "processing" ? ( ) : phase === "complete" ? (

{pendingEdits ? "Automation saved with your edits" : "Automation created from this draft"}

) : phase === "rejected" ? (

No automation was saved — ask in chat to refine and try again.

) : (

{pendingEdits ? "Showing your edits. Approve to save, or edit again." : "Review and approve to save. Edit for fine-tuning, or reply in chat for a redraft."}

)}
{phase === "pending" && canEdit && !isEditing && ( )}
{isEditing ? ( { setPendingEdits(parsed); setIsEditing(false); }} onCancel={() => setIsEditing(false)} /> ) : ( )}
{phase === "pending" && !isEditing && ( <>
{canApprove && ( )} {canReject && ( )}
)}
); } interface JsonEditorProps { initialValue: Record; onSave: (parsed: Record) => void; onCancel: () => void; } function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) { const [value, setValue] = useState>(initialValue); const [issues, setIssues] = useState([]); function handleSave() { setIssues([]); const result = editArgsSchema.safeParse(value); if (!result.success) { setIssues( result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) ); return; } onSave(result.data as unknown as Record); } return (
setValue(next as Record)} collapsed={false} />
{issues.length > 0 && (
{issues.length} issue{issues.length === 1 ? "" : "s"}
    {issues.map((issue) => (
  • {issue}
  • ))}
)}
); } // ---------------------------------------------------------------------------- // Terminal result cards. // ---------------------------------------------------------------------------- function SavedCard({ result }: { result: SavedResult }) { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const detailHref = searchSpaceId ? `/dashboard/${searchSpaceId}/automations/${result.automation_id}` : null; return (

Automation saved

{result.name}

{detailHref && ( <>
Open automation #{result.automation_id}
)}
); } function InvalidCard({ result }: { result: InvalidResult }) { return (

Couldn't draft this automation

The drafter produced output that didn't validate. I'll refine and retry.

{result.issues.length > 0 && ( <>
    {result.issues.map((issue) => (
  • {issue}
  • ))}
)}
); } function ErrorCard({ result }: { result: ErrorResult }) { return (

Failed to create automation

{result.message}

); } // ---------------------------------------------------------------------------- // Entry — dispatches between the approval card and terminal result cards. // // Rejection is special: we hide the standalone "rejected" card because the // approval card itself already transitions to a "rejected" phase inline. A // second message in the timeline would be noisy. // ---------------------------------------------------------------------------- export const CreateAutomationToolUI = ({ args, result, }: ToolCallMessagePartProps<{ intent: string }, CreateAutomationResult>) => { const { dispatch } = useHitlDecision(); if (!result) return null; if (isInterruptResult(result)) { return ( } interruptData={result as InterruptResult} onDecision={(decision) => dispatch([decision])} /> ); } if (hasStatus(result, "rejected")) return null; if (hasStatus(result, "saved")) return ; if (hasStatus(result, "invalid")) return ; if (hasStatus(result, "error")) return ; return null; }; // ---------------------------------------------------------------------------- // Helpers. // ---------------------------------------------------------------------------- /** * Project raw args into the shape ``AutomationDraftPreview`` expects. * * The args dict is the full ``AutomationCreate`` payload (minus * ``search_space_id`` which is injected server-side), so we trust the * top-level fields but defend against missing nested defaults. */ function extractDraft(args: Record) { const definition = (args.definition ?? {}) as Record; const planSteps = Array.isArray(definition.plan) ? (definition.plan as Array>).map((step) => ({ step_id: String(step.step_id ?? "(unnamed)"), action: String(step.action ?? ""), when: typeof step.when === "string" ? step.when : null, })) : []; const triggers = Array.isArray(args.triggers) ? (args.triggers as Array>).map((trigger) => ({ type: String(trigger.type ?? "schedule"), params: (trigger.params ?? {}) as Record, static_inputs: (trigger.static_inputs ?? {}) as Record, enabled: trigger.enabled !== false, })) : []; return { name: String(args.name ?? "(unnamed automation)"), description: typeof args.description === "string" ? args.description : null, definition: { goal: typeof definition.goal === "string" ? definition.goal : null, plan: planSteps, }, triggers, }; }