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

@ -1,7 +1,7 @@
- `create_automation` — Draft and author a new automation. You describe the - `create_automation` — Draft and author a new automation. You describe the
user's intent; a focused drafter inside the tool turns it into the full user's intent; a focused drafter inside the tool turns it into the full
automation JSON; the user reviews and edits it on an approval card; on automation JSON; the user sees a preview on an approval card and chooses
approval it's saved. All three phases happen in a single tool call. approve or reject. All three phases happen in a single tool call.
- Call when the user wants SurfSense to do something on its own: anything - Call when the user wants SurfSense to do something on its own: anything
recurring or scheduled ("every morning…", "each Monday…", "weekly recurring or scheduled ("every morning…", "each Monday…", "weekly
recap…"). recap…").
@ -17,13 +17,16 @@
explicitly ("the Notion parent page id was not specified") so the explicitly ("the Notion parent page id was not specified") so the
drafter leaves a placeholder. drafter leaves a placeholder.
- Do NOT prompt the user to confirm before calling — the approval card - Do NOT prompt the user to confirm before calling — the approval card
IS the confirmation. The user can edit any field on the card. IS the confirmation. The card shows a structured preview plus the raw
JSON; it offers approve/reject only. If the user wants changes after
seeing the draft, they reply in chat and you call this tool again with
a refined `intent` — that's the edit path.
- Returns: - Returns:
- `{status: "saved", automation_id, name}` — confirm briefly to the - `{status: "saved", automation_id, name}` — confirm briefly to the
user ("Saved as automation #N — runs <when>."). Don't dump JSON back. user ("Saved as automation #N — runs <when>."). Don't dump JSON back.
- `{status: "rejected", message}` — the user declined on the card. - `{status: "rejected", message}` — the user declined on the card.
Acknowledge once ("Understood, I didn't create it.") and stop. Do Acknowledge once ("Understood, I didn't create it.") and stop. Do
NOT retry or pitch variants. NOT retry or pitch variants without a fresh user request.
- `{status: "invalid", issues, raw?}` — drafting/validation failed - `{status: "invalid", issues, raw?}` — drafting/validation failed
before the card was shown. Read the issues, refine your `intent` before the card was shown. Read the issues, refine your `intent`
with the missing details, call again. with the missing details, call again.

View file

@ -66,9 +66,11 @@ def create_create_automation_tool(
names, ) it needs. names, ) it needs.
The tool drafts the full automation JSON internally, shows the user The tool drafts the full automation JSON internally, shows the user
an approval card for review, and persists on approval. Do NOT a structured preview on an approval card, and persists on approval.
prompt the user to confirm before calling the card IS the The card supports approve/reject only if the user wants edits
confirmation. The user can edit any field there. after seeing the draft, they say so in chat and you call this tool
again with a refined intent. Do NOT prompt the user to confirm
before calling the card IS the confirmation.
Args: Args:
intent: Concrete restatement of the user's request. Include intent: Concrete restatement of the user's request. Include

View file

@ -6,8 +6,8 @@ import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutat
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { Trigger } from "@/contracts/types/automation.types"; import type { Trigger } from "@/contracts/types/automation.types";
import { describeCron } from "@/lib/automations/describe-cron";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
import { describeCron } from "../../lib/describe-cron";
import { DeleteTriggerDialog } from "./delete-trigger-dialog"; import { DeleteTriggerDialog } from "./delete-trigger-dialog";
interface TriggerCardProps { interface TriggerCardProps {

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { CalendarClock, Pause } from "lucide-react"; import { CalendarClock, Pause } from "lucide-react";
import type { Trigger } from "@/contracts/types/automation.types"; import type { Trigger } from "@/contracts/types/automation.types";
import { describeCron } from "../lib/describe-cron"; import { describeCron } from "@/lib/automations/describe-cron";
interface AutomationTriggersSummaryProps { interface AutomationTriggersSummaryProps {
triggers: Trigger[]; triggers: Trigger[];

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";

View file

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

View file

@ -25,6 +25,7 @@ import {
SearchCheck, SearchCheck,
Send, Send,
Trash2, Trash2,
Workflow,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
@ -47,6 +48,8 @@ const TOOL_ICONS: Record<string, LucideIcon> = {
scrape_webpage: ScanLine, scrape_webpage: ScanLine,
web_search: Globe, web_search: Globe,
search_surfsense_docs: BookOpen, search_surfsense_docs: BookOpen,
// Automations
create_automation: Workflow,
// Memory // Memory
update_memory: Brain, update_memory: Brain,
// Filesystem (built-in deepagent + middleware) // Filesystem (built-in deepagent + middleware)
@ -150,6 +153,8 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
scrape_webpage: "Read webpage", scrape_webpage: "Read webpage",
web_search: "Search the web", web_search: "Search the web",
search_surfsense_docs: "Search knowledge base", search_surfsense_docs: "Search knowledge base",
// Automations
create_automation: "Create automation",
// Memory // Memory
update_memory: "Update memory", update_memory: "Update memory",
// Calendar // Calendar

View file

@ -17,6 +17,11 @@ const UpdateMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })), () => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
{ ssr: false } { ssr: false }
); );
const CreateAutomationToolUI = dynamic(
() =>
import("@/components/tool-ui/automation").then((m) => ({ default: m.CreateAutomationToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic( const SandboxExecuteToolUI = dynamic(
() => () =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({ import("@/components/tool-ui/sandbox-execute").then((m) => ({
@ -184,6 +189,7 @@ const NullTimelineBody: TimelineToolComponent = () => null;
*/ */
const TOOLS_BY_NAME = { const TOOLS_BY_NAME = {
task: NullTimelineBody, task: NullTimelineBody,
create_automation: CreateAutomationToolUI,
update_memory: UpdateMemoryToolUI, update_memory: UpdateMemoryToolUI,
execute: SandboxExecuteToolUI, execute: SandboxExecuteToolUI,
execute_code: SandboxExecuteToolUI, execute_code: SandboxExecuteToolUI,

View file

@ -4,8 +4,9 @@
* to the raw expression when unrecognized so the user still sees something * to the raw expression when unrecognized so the user still sees something
* honest instead of a guess. * honest instead of a guess.
* *
* Lives in the automations slice because it's a UI display concern with no * Lives under ``lib/automations/`` because both the dashboard slice and the
* consumers outside it. If reuse grows, lift to ``lib/cron-describe.ts``. * chat ``create_automation`` approval card render schedule descriptions
* keeping the helper outside either feature avoids a layering violation.
*/ */
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];