Merge pull request #1443 from CREDO23/feature-automations

[Feat] Automation V1 — Scheduled Agent Tasks, Created via Chat (HITL) or JSON
This commit is contained in:
Rohan Verma 2026-05-28 12:41:41 -07:00 committed by GitHub
commit 4dda02c06c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
219 changed files with 13821 additions and 55 deletions

View file

@ -1,6 +1,6 @@
import { FileJson } from "lucide-react";
import React from "react";
import { defaultStyles, JsonView } from "react-json-view-lite";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -10,7 +10,6 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import "react-json-view-lite/dist/index.css";
interface JsonMetadataViewerProps {
title: string;
@ -56,13 +55,13 @@ export function JsonMetadataViewer({
{title} - Metadata
</DialogTitle>
</DialogHeader>
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" className="text-muted-foreground" />
</div>
) : (
<JsonView data={jsonData} style={defaultStyles} />
<JsonView src={jsonData} collapsed={2} />
)}
</div>
</DialogContent>
@ -87,8 +86,8 @@ export function JsonMetadataViewer({
{title} - Metadata
</DialogTitle>
</DialogHeader>
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
<JsonView data={jsonData} style={defaultStyles} />
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm overflow-auto">
<JsonView src={jsonData} collapsed={2} />
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,93 @@
"use client";
import ReactJson, { type InteractionProps } from "@microlink/react-json-view";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
/**
* Shared JSON viewer/editor wrapper around @microlink/react-json-view.
*
* One component, dual mode: passing ``editable`` + ``onChange`` enables
* inline value editing, key renaming, add and delete. Omitting them
* yields a read-only viewer. The underlying library is uncontrolled it
* mutates its own internal copy of ``src`` and surfaces the final tree on
* each interaction via ``updated_src``, which we forward to ``onChange``.
*
* Theme follows ``next-themes``: a dark base-16 palette in dark mode, the
* library's neutral default in light mode. Defaults are tuned for our
* compact UI surfaces (no data-type labels, no key quotes, triangle icons,
* tight indent).
*/
export interface JsonViewProps {
/** The JSON value to display. Primitives are wrapped under ``{ value }``
* because the underlying library requires an object root. */
src: unknown;
/** Enables value/key editing + add + delete. Requires ``onChange`` to
* observe the result; without it the toggle is silently a no-op. */
editable?: boolean;
/** Called with the full updated tree on every accepted interaction. */
onChange?: (next: unknown) => void;
/** Collapse depth. ``true`` collapses everything past the root; a number
* collapses from that depth onward. */
collapsed?: boolean | number;
/** Root label. Default ``false`` (no label — saves vertical space). */
name?: string | false;
className?: string;
}
const DARK_THEME = "monokai" as const;
const LIGHT_THEME = "rjv-default" as const;
const SHARED_DEFAULTS = {
iconStyle: "triangle" as const,
indentWidth: 2,
enableClipboard: true,
displayDataTypes: false,
displayObjectSize: true,
quotesOnKeys: false,
collapseStringsAfterLength: 80,
};
export function JsonView({
src,
editable = false,
onChange,
collapsed = 2,
name = false,
className,
}: JsonViewProps) {
const { resolvedTheme } = useTheme();
const theme = resolvedTheme === "dark" ? DARK_THEME : LIGHT_THEME;
// The library throws on non-object roots. Wrap primitives and null/undefined.
const safeSrc = useMemo(() => {
if (src && typeof src === "object") return src as object;
return { value: src };
}, [src]);
const handleChange = useCallback(
(interaction: InteractionProps) => {
onChange?.(interaction.updated_src);
return true;
},
[onChange]
);
const interactive = editable && onChange ? handleChange : (false as const);
return (
<div className={className}>
<ReactJson
src={safeSrc}
name={name}
theme={theme}
collapsed={collapsed}
onEdit={interactive}
onAdd={interactive}
onDelete={interactive}
style={{ backgroundColor: "transparent", fontSize: 12, fontFamily: "var(--font-mono)" }}
{...SHARED_DEFAULTS}
/>
</div>
);
}

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LibraryBig } from "lucide-react";
import { AlertTriangle, Inbox, LibraryBig, Workflow } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -334,9 +334,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}, [threadsData, searchSpaceId]);
// Navigation items
// Inbox is rendered explicitly below "New chat" in the sidebar (it is also
// surfaced in the icon rail's collapsed mode via this list). Announcements
// has been moved to the avatar dropdown and is no longer a nav item.
// Inbox, Automations, and Documents are rendered explicitly below "New chat"
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
// list). Announcements has been moved to the avatar dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const navItems: NavItem[] = useMemo(
() =>
(
@ -348,6 +349,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
isActive: isInboxSidebarOpen,
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
},
{
title: "Automations",
url: `/dashboard/${searchSpaceId}/automations`,
icon: Workflow,
isActive: isAutomationsActive,
},
isMobile
? {
title: "Documents",
@ -358,7 +365,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
: null,
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[isMobile, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount]
[
isMobile,
isInboxSidebarOpen,
isDocumentsSidebarOpen,
totalUnreadCount,
searchSpaceId,
isAutomationsActive,
]
);
// Handlers
@ -659,12 +673,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const isUserSettingsPage = pathname?.includes("/user-settings") === true;
const isSearchSpaceSettingsPage = pathname?.includes("/search-space-settings") === true;
const isTeamPage = pathname?.endsWith("/team") === true;
const isAutomationsPage = pathname?.includes("/automations") === true;
const useWorkspacePanel =
pathname?.endsWith("/buy-more") === true ||
pathname?.endsWith("/more-pages") === true ||
isUserSettingsPage ||
isSearchSpaceSettingsPage ||
isTeamPage;
isTeamPage ||
isAutomationsPage;
return (
<>
@ -704,12 +720,16 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
isChatPage={isChatPage}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage || isAutomationsPage
? "items-start justify-center px-6 py-8 md:px-10 md:py-10"
: undefined
}
workspacePanelContentClassName={
isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage ? "max-w-5xl" : undefined
isAutomationsPage
? "max-w-none"
: isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
? "max-w-5xl"
: undefined
}
isLoadingChats={isLoadingThreads}
activeSlideoutPanel={activeSlideoutPanel}

View file

@ -140,16 +140,26 @@ export function Sidebar({
const t = useTranslations("sidebar");
const [openDropdownChatId, setOpenDropdownChatId] = useState<number | null>(null);
// Inbox and Documents are rendered explicitly right below New Chat. Pull
// them out of the nav items list so they don't also appear in the bottom
// NavSection. Documents is only present in navItems on mobile.
// Inbox, Automations, and Documents are rendered explicitly right below
// New Chat. Pull them out of the nav items list so they don't also appear
// in the bottom NavSection. Documents is only present in navItems on
// mobile; Automations is identified by URL suffix so the same code path
// works across search spaces.
const inboxItem = useMemo(() => navItems.find((item) => item.url === "#inbox"), [navItems]);
const automationsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
);
const documentsItem = useMemo(
() => navItems.find((item) => item.url === "#documents"),
[navItems]
);
const footerNavItems = useMemo(
() => navItems.filter((item) => item.url !== "#inbox" && item.url !== "#documents"),
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations")
),
[navItems]
);
@ -227,6 +237,16 @@ export function Sidebar({
}
/>
)}
{automationsItem && (
<SidebarButton
icon={automationsItem.icon}
label={automationsItem.title}
onClick={() => onNavItemClick?.(automationsItem)}
isCollapsed={isCollapsed}
isActive={automationsItem.isActive}
tooltipContent={isCollapsed ? automationsItem.title : undefined}
/>
)}
{documentsItem && (
<SidebarButton
icon={documentsItem.icon}

View file

@ -23,6 +23,7 @@ import {
Unplug,
Users,
Video,
Workflow,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@ -126,6 +127,12 @@ const CATEGORY_CONFIG: Record<
description: "Generate AI podcasts from content",
order: 5,
},
automations: {
label: "Automations",
icon: Workflow,
description: "Scheduled and event-driven agent tasks",
order: 5.5,
},
connectors: {
label: "Connectors",
icon: Unplug,
@ -200,6 +207,10 @@ const ROLE_PRESETS = {
"podcasts:create",
"podcasts:read",
"podcasts:update",
"automations:create",
"automations:read",
"automations:update",
"automations:execute",
"connectors:create",
"connectors:read",
"connectors:update",
@ -220,6 +231,7 @@ const ROLE_PRESETS = {
"comments:read",
"llm_configs:read",
"podcasts:read",
"automations:read",
"connectors:read",
"logs:read",
"members:view",
@ -240,6 +252,10 @@ const ROLE_PRESETS = {
"comments:read",
"llm_configs:read",
"podcasts:read",
"automations:create",
"automations:read",
"automations:update",
"automations:execute",
"connectors:read",
"logs:read",
"members:view",

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,427 @@
"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<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.
//
// 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<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 canEdit = allowedDecisions.includes("edit");
const [pendingEdits, setPendingEdits] = useState<Record<string, unknown> | 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 (
<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 justify-between gap-3 px-5 pt-5 pb-4 select-none">
<div className="flex items-start gap-3 min-w-0">
<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={pendingEdits ? "Saving with your edits" : "Saving automation"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits
? "Automation saved with your edits"
: "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">
{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."}
</p>
)}
</div>
</div>
{phase === "pending" && canEdit && !isEditing && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2 shrink-0"
onClick={() => setIsEditing(true)}
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
{isEditing ? (
<JsonEditor
initialValue={effectiveArgs}
onSave={(parsed) => {
setPendingEdits(parsed);
setIsEditing(false);
}}
onCancel={() => setIsEditing(false)}
/>
) : (
<AutomationDraftPreview draft={draft} raw={effectiveArgs} />
)}
</div>
{phase === "pending" && !isEditing && (
<>
<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>
);
}
interface JsonEditorProps {
initialValue: Record<string, unknown>;
onSave: (parsed: Record<string, unknown>) => void;
onCancel: () => void;
}
function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
const [value, setValue] = useState<Record<string, unknown>>(initialValue);
const [issues, setIssues] = useState<string[]>([]);
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<string, unknown>);
}
return (
<div className="space-y-3">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length} issue{issues.length === 1 ? "" : "s"}
</div>
<ul className="mt-1.5 space-y-0.5 text-xs text-destructive/90 list-disc list-inside">
{issues.map((issue) => (
<li key={issue} className="font-mono">
{issue}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleSave}>
Save edits
</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";

View file

@ -7,6 +7,7 @@
*/
export { Audio } from "./audio";
export { CreateAutomationToolUI } from "./automation";
export { CreateDropboxFileToolUI, DeleteDropboxFileToolUI } from "./dropbox";
export {
type GenerateImageArgs,