feat(web): create_automation HITL approval card in chat

Closes the create loop in chat: the agent describes user intent → the
drafter sub-LLM produces an AutomationCreate JSON → this card surfaces
a structured preview → approve persists; reject cancels. Edits flow
through chat refinement (re-call with a refined intent), not in-card,
so the card stays simple and the multi-turn checkpointer carries the
context.

Tool UI (components/tool-ui/automation/):
- create-automation.tsx — entry dispatcher + ApprovalCard chrome
  (pending/processing/complete/rejected via useHitlPhase) + SavedCard
  (links to the detail page) + InvalidCard (lists drafter validation
  issues) + ErrorCard (verbatim message). Rejection result is hidden
  because the approval card itself shows the rejected phase inline.
- automation-draft-preview.tsx — structured preview body: name +
  description + goal, triggers (humanised cron + tz + static-input
  keys), plan steps (step_id → action), and a collapsible raw JSON
  for power users.

Wiring:
- components/tool-ui/index.ts — re-export.
- features/chat-messages/timeline/tool-registry/registry.ts —
  register create_automation → CreateAutomationToolUI (dynamic import,
  same pattern as other connector tools).
- contracts/enums/toolIcons.tsx — Workflow icon + "Create automation"
  display name so fallback chrome (and timeline headers) are honest.

Shared util:
- lib/automations/describe-cron.ts — lifted from the route slice's
  lib/ folder since both the dashboard slice and the new approval card
  now render schedule descriptions. Slice imports updated; the now-
  empty slice lib/ folder is gone.

Backend prompt fragments:
- main_agent/system_prompt/.../create_automation/description.md and
  the tool's docstring no longer promise in-card edits. They make the
  refinement path explicit: if the user wants changes after seeing the
  draft, they reply in chat and the agent calls the tool again with a
  refined intent.

v1 deliberately excludes:
- In-card edit form / right-side edit panel — defer until we see real
  demand. The chat refinement loop covers the common case.
- approve_always / persistent allow rules — automations are a single
  artifact, not a repeated mutation, so the "trust this kind of call"
  affordance doesn't apply.
This commit is contained in:
CREDO23 2026-05-28 01:32:04 +02:00
parent c0a9ea368f
commit 2e572d7818
11 changed files with 541 additions and 11 deletions

View file

@ -0,0 +1,183 @@
"use client";
import { CalendarClock, ChevronDown, ChevronRight, ListOrdered, Target } from "lucide-react";
import { useState } from "react";
import { describeCron } from "@/lib/automations/describe-cron";
interface DraftTrigger {
type: string;
params: Record<string, unknown>;
static_inputs: Record<string, unknown>;
enabled: boolean;
}
interface DraftPlanStep {
step_id: string;
action: string;
when?: string | null;
}
interface AutomationDraft {
name: string;
description?: string | null;
definition: {
goal?: string | null;
plan: DraftPlanStep[];
};
triggers: DraftTrigger[];
}
interface AutomationDraftPreviewProps {
draft: AutomationDraft;
/** Full unmodified args dict — surfaced as the "raw JSON" escape hatch. */
raw: Record<string, unknown>;
}
/**
* Structured preview of a drafted automation rendered inside the chat
* approval card.
*
* Three layers, top to bottom:
* 1. Name + description (and goal when present).
* 2. Triggers humanised cron string + timezone + static_inputs hint.
* 3. Plan steps ordered list of ``step_id action``.
*
* A "View raw JSON" toggle reveals the full payload for power users who
* want to inspect every field; it's collapsed by default so the card
* stays scannable for the common case.
*/
export function AutomationDraftPreview({ draft, raw }: AutomationDraftPreviewProps) {
const [showRaw, setShowRaw] = useState(false);
return (
<div className="space-y-4 text-sm">
<div className="space-y-1">
<p className="font-medium text-foreground">{draft.name}</p>
{draft.description && <p className="text-xs text-muted-foreground">{draft.description}</p>}
</div>
{draft.definition.goal && (
<Section icon={Target} label="Goal">
<p className="text-xs text-foreground">{draft.definition.goal}</p>
</Section>
)}
<Section icon={CalendarClock} label={`Triggers · ${draft.triggers.length}`}>
{draft.triggers.length === 0 ? (
<p className="text-xs text-muted-foreground">
No triggers automation will need one before it can run.
</p>
) : (
<ul className="space-y-1.5">
{draft.triggers.map((trigger) => (
<li
key={triggerKey(trigger)}
className="rounded-md border border-border/60 bg-background/50 px-3 py-2 text-xs"
>
<TriggerLine trigger={trigger} />
</li>
))}
</ul>
)}
</Section>
<Section
icon={ListOrdered}
label={`Plan · ${draft.definition.plan.length} step${draft.definition.plan.length === 1 ? "" : "s"}`}
>
<ol className="space-y-1 text-xs">
{draft.definition.plan.map((step, idx) => (
<li key={step.step_id} className="flex items-start gap-2">
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground shrink-0 mt-0.5">
{idx + 1}
</span>
<div className="min-w-0">
<span className="font-medium text-foreground">{step.step_id}</span>
<span className="text-muted-foreground"> </span>
<code className="font-mono text-muted-foreground">{step.action}</code>
{step.when && <span className="ml-2 text-muted-foreground">when {step.when}</span>}
</div>
</li>
))}
</ol>
</Section>
<button
type="button"
onClick={() => setShowRaw((value) => !value)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{showRaw ? (
<ChevronDown className="h-3 w-3" aria-hidden />
) : (
<ChevronRight className="h-3 w-3" aria-hidden />
)}
{showRaw ? "Hide raw JSON" : "View raw JSON"}
</button>
{showRaw && (
<pre className="rounded-md bg-muted/40 px-3 py-2 text-[11px] font-mono text-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-72">
{JSON.stringify(raw, null, 2)}
</pre>
)}
</div>
);
}
/**
* Stable key derived from the trigger's identifying fields. Drafts are
* static snapshots so collisions only happen if the LLM emits two literally
* identical triggers harmless in practice.
*/
function triggerKey(trigger: DraftTrigger): string {
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "";
return `${trigger.type}|${cron}|${tz}`;
}
function TriggerLine({ trigger }: { trigger: DraftTrigger }) {
if (trigger.type === "schedule") {
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
const human = cron ? describeCron(cron) : "Schedule";
const staticKeys = Object.keys(trigger.static_inputs ?? {});
return (
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground">{human}</span>
<span className="text-muted-foreground">· {tz}</span>
{!trigger.enabled && (
<span className="rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] text-muted-foreground">
Disabled
</span>
)}
</div>
{cron && <code className="font-mono text-muted-foreground">{cron}</code>}
{staticKeys.length > 0 && (
<p className="text-muted-foreground">
Static inputs: <span className="text-foreground">{staticKeys.join(", ")}</span>
</p>
)}
</div>
);
}
return <span className="capitalize text-foreground">{trigger.type}</span>;
}
function Section({
icon: Icon,
label,
children,
}: {
icon: typeof Target;
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
<Icon className="h-3 w-3" aria-hidden />
{label}
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,328 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CornerDownLeftIcon, ExternalLink, Workflow } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
import { AutomationDraftPreview } from "./automation-draft-preview";
// ----------------------------------------------------------------------------
// 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<AutomationCreateContext>
| 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.
//
// v1 deliberately supports only approve/reject. The drafted JSON is complex
// (full plan + triggers) and we already have a multi-turn refinement path via
// chat ("make it run at 10am instead" → the agent re-calls the tool with a
// refined intent). An in-card edit form would duplicate that flow and add UX
// surface area we don't need yet — leave it for the raw-JSON path on the
// detail page.
// ----------------------------------------------------------------------------
interface ApprovalCardProps {
args: Record<string, unknown>;
interruptData: InterruptResult<AutomationCreateContext>;
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 draft = useMemo(() => extractDraft(args), [args]);
const handleApprove = useCallback(() => {
if (phase !== "pending" || !canApprove) return;
setProcessing();
onDecision({
type: "approve",
edited_action: {
name: interruptData.action_requests[0]?.name ?? "create_automation",
args,
},
});
}, [phase, canApprove, setProcessing, onDecision, interruptData, args]);
const handleReject = useCallback(() => {
if (phase !== "pending" || !canReject) return;
setRejected();
onDecision({ type: "reject", message: "User rejected the automation draft." });
}, [phase, canReject, setRejected, onDecision]);
useEffect(() => {
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]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start gap-3 px-5 pt-5 pb-4 select-none">
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? "Automation cancelled"
: phase === "processing"
? "Saving automation"
: phase === "complete"
? "Automation saved"
: "Create automation"}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Saving automation" size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
Automation created from this draft
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
No automation was saved ask in chat to refine and try again.
</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Review and approve to save. To change anything, reply in chat I'll redraft.
</p>
)}
</div>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<AutomationDraftPreview draft={draft} raw={args} />
</div>
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{canApprove && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{canReject && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={handleReject}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
// ----------------------------------------------------------------------------
// Terminal result cards.
// ----------------------------------------------------------------------------
function SavedCard({ result }: { result: SavedResult }) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const detailHref = searchSpaceId
? `/dashboard/${searchSpaceId}/automations/${result.automation_id}`
: null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-4">
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">Automation saved</p>
<p className="text-xs text-muted-foreground mt-0.5">{result.name}</p>
</div>
</div>
{detailHref && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3">
<Link
href={detailHref}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
Open automation #{result.automation_id}
</Link>
</div>
</>
)}
</div>
);
}
function InvalidCard({ result }: { result: InvalidResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Couldn't draft this automation</p>
<p className="text-xs text-muted-foreground mt-0.5">
The drafter produced output that didn't validate. I'll refine and retry.
</p>
</div>
{result.issues.length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<ul className="px-5 py-3 space-y-1 text-xs text-muted-foreground list-disc list-inside">
{result.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to create automation</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
// ----------------------------------------------------------------------------
// 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 (
<ApprovalCard
args={args as unknown as Record<string, unknown>}
interruptData={result as InterruptResult<AutomationCreateContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}
if (hasStatus(result, "rejected")) return null;
if (hasStatus(result, "saved")) return <SavedCard result={result as SavedResult} />;
if (hasStatus(result, "invalid")) return <InvalidCard result={result as InvalidResult} />;
if (hasStatus(result, "error")) return <ErrorCard result={result as ErrorResult} />;
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<string, unknown>) {
const definition = (args.definition ?? {}) as Record<string, unknown>;
const planSteps = Array.isArray(definition.plan)
? (definition.plan as Array<Record<string, unknown>>).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<Record<string, unknown>>).map((trigger) => ({
type: String(trigger.type ?? "schedule"),
params: (trigger.params ?? {}) as Record<string, unknown>,
static_inputs: (trigger.static_inputs ?? {}) as Record<string, unknown>,
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,
};
}

View file

@ -0,0 +1 @@
export { CreateAutomationToolUI } from "./create-automation";