feat(automations): added UI and improved mentions

- Added support for @-mentions in agent tasks, allowing users to reference documents, folders, and connectors directly in their queries.
- Updated `run_agent_task` to resolve mentions and include them in the context passed to the agent.
- Introduced new parameters in `AgentTaskActionParams` for handling mentioned document and connector IDs.
- Refactored the automation edit and new components to utilize the new `AutomationBuilderForm` for a more streamlined user experience.
- Removed deprecated JSON forms to simplify the automation creation process.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-28 21:26:32 -07:00
parent c601a9b102
commit d013617bf6
25 changed files with 2490 additions and 281 deletions

View file

@ -1,10 +1,11 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomation } from "@/hooks/use-automation";
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
import { AutomationDetailLoading } from "../components/automation-detail-loading";
import { AutomationNotFound } from "../components/automation-not-found";
import { AutomationEditForm } from "./components/automation-edit-form";
import { AutomationEditHeader } from "./components/automation-edit-header";
interface AutomationEditContentProps {
searchSpaceId: number;
@ -49,5 +50,10 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
}
return <AutomationEditForm automation={automation} searchSpaceId={searchSpaceId} />;
return (
<>
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
</>
);
}

View file

@ -1,118 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, ArrowLeft, Save } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { type Automation, automationUpdateRequest } from "@/contracts/types/automation.types";
interface AutomationEditFormProps {
automation: Automation;
searchSpaceId: number;
}
/**
* Edit-existing-automation form. Surfaces the four mutable fields
* (name, description, status, definition) as one editable JSON tree;
* triggers stay on the detail page where they have their own management
* UI. Validates with the same Zod schema the API expects, then PATCHes
* the changed shape back.
*/
export function AutomationEditForm({ automation, searchSpaceId }: AutomationEditFormProps) {
const router = useRouter();
const { mutateAsync: updateAutomation, isPending } = useAtomValue(updateAutomationMutationAtom);
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
const [value, setValue] = useState(() => ({
name: automation.name,
description: automation.description ?? null,
status: automation.status,
definition: automation.definition,
}));
const [issues, setIssues] = useState<string[]>([]);
async function handleSave() {
setIssues([]);
const result = automationUpdateRequest.safeParse(value);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
);
return;
}
try {
await updateAutomation({ automationId: automation.id, patch: result.data });
router.push(detailHref);
} catch (err) {
setIssues([(err as Error).message ?? "Update failed"]);
}
}
return (
<>
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link href={detailHref} className="text-xs text-muted-foreground">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automation
</Link>
</Button>
<div>
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
Edit automation
</h1>
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
</div>
</div>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Definition</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[36rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as typeof value)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button asChild type="button" variant="ghost" size="sm">
<Link href={detailHref}>Cancel</Link>
</Button>
<Button type="button" onClick={handleSave} disabled={isPending} size="sm">
{isPending ? (
<Spinner size="xs" className="mr-2" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save changes
</Button>
</div>
</CardContent>
</Card>
</>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import type { Automation } from "@/contracts/types/automation.types";
interface AutomationEditHeaderProps {
automation: Automation;
searchSpaceId: number;
}
export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
return (
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link href={detailHref} className="text-xs text-muted-foreground">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automation
</Link>
</Button>
<div>
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
Edit automation
</h1>
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BuilderExecution } from "@/lib/automations/builder-schema";
import { Field } from "./form-field";
interface AdvancedSectionProps {
execution: BuilderExecution;
tags: string[];
onExecutionChange: (patch: Partial<BuilderExecution>) => void;
onTagsChange: (tags: string[]) => void;
}
const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [
{ value: "exponential", label: "Exponential" },
{ value: "linear", label: "Linear" },
{ value: "none", label: "None" },
];
const CONCURRENCY_OPTIONS: ReadonlyArray<{
value: BuilderExecution["concurrency"];
label: string;
}> = [
{ value: "drop_if_running", label: "Skip if already running" },
{ value: "queue", label: "Queue the next run" },
{ value: "always", label: "Always run" },
];
function clampInt(raw: string, min: number, fallback: number): number {
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) return fallback;
return Math.max(min, value);
}
export function AdvancedSection({
execution,
tags,
onExecutionChange,
onTagsChange,
}: AdvancedSectionProps) {
const [tagsText, setTagsText] = useState(tags.join(", "));
function commitTags(text: string) {
const next = text
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
onTagsChange(next);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run.">
<Input
type="number"
min={1}
value={execution.timeoutSeconds}
onChange={(e) =>
onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) })
}
/>
</Field>
<Field label="Max retries" hint="Per-step retry budget.">
<Input
type="number"
min={0}
value={execution.maxRetries}
onChange={(e) => onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })}
/>
</Field>
<Field label="Retry backoff">
<Select
value={execution.retryBackoff}
onValueChange={(value) =>
onExecutionChange({ retryBackoff: value as BuilderExecution["retryBackoff"] })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BACKOFF_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="If already running">
<Select
value={execution.concurrency}
onValueChange={(value) =>
onExecutionChange({ concurrency: value as BuilderExecution["concurrency"] })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONCURRENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<Field label="Tags" hint="Comma-separated. Optional.">
<Input
value={tagsText}
placeholder="research, weekly"
onChange={(e) => setTagsText(e.target.value)}
onBlur={(e) => commitTags(e.target.value)}
/>
</Field>
</div>
);
}

View file

@ -0,0 +1,459 @@
"use client";
import { useAtomValue } from "jotai";
import { Code2, LayoutList, Save } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import type { z } from "zod";
import {
addTriggerMutationAtom,
createAutomationMutationAtom,
removeTriggerMutationAtom,
updateAutomationMutationAtom,
updateTriggerMutationAtom,
} from "@/atoms/automations/automations-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import {
type Automation,
automationCreateRequest,
automationUpdateRequest,
} from "@/contracts/types/automation.types";
import {
type BuilderForm,
buildCreatePayload,
builderFormSchema,
buildScheduleTrigger,
buildUpdatePayload,
createEmptyForm,
formFromAutomation,
type HydratableTrigger,
hydrateForm,
} from "@/lib/automations/builder-schema";
import { cn } from "@/lib/utils";
import { AdvancedSection } from "./advanced-section";
import { BasicsSection } from "./basics-section";
import { BuilderSummary } from "./builder-summary";
import { JsonModePanel } from "./json-mode-panel";
import { ScheduleSection } from "./schedule-section";
import { TaskList } from "./task-list";
import { UnattendedToggle } from "./unattended-toggle";
interface AutomationBuilderFormProps {
mode: "create" | "edit";
searchSpaceId: number;
/** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation;
}
type Mode = "form" | "json";
function mapFormErrors(error: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const path = issue.path;
let key: string;
if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`;
else if (path[0] === "schedule") key = "schedule";
else key = String(path[0] ?? "_root");
if (!out[key]) out[key] = issue.message;
}
return out;
}
export function AutomationBuilderForm({
mode,
searchSpaceId,
automation,
}: AutomationBuilderFormProps) {
const router = useRouter();
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom);
const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom);
const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom);
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
// Initial state: create starts empty in form mode; edit hydrates, falling
// back to JSON mode when the definition can't be represented in the form.
const initial = useMemo(() => {
if (mode === "edit" && automation) {
const result = formFromAutomation(automation);
if (result.formable) {
return { mode: "form" as Mode, form: result.form, notice: undefined };
}
return {
mode: "json" as Mode,
form: createEmptyForm(),
notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`,
};
}
return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined };
}, [mode, automation]);
const [activeMode, setActiveMode] = useState<Mode>(initial.mode);
const [form, setForm] = useState<BuilderForm>(initial.form);
const [errors, setErrors] = useState<Record<string, string>>({});
const [rootError, setRootError] = useState<string | null>(null);
const [jsonValue, setJsonValue] = useState<Record<string, unknown>>(() =>
initial.mode === "json" ? jsonFromAutomation(automation) : {}
);
const [jsonIssues, setJsonIssues] = useState<string[]>([]);
const [jsonNotice, setJsonNotice] = useState<string | undefined>(initial.notice);
const [submitting, setSubmitting] = useState(false);
const cancelHref =
mode === "edit" && automation
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
: `/dashboard/${searchSpaceId}/automations`;
function patchForm(patch: Partial<BuilderForm>) {
setForm((prev) => ({ ...prev, ...patch }));
}
function jsonFromCurrentForm(): Record<string, unknown> {
if (mode === "edit" && automation) {
return { ...buildUpdatePayload(form), status: automation.status };
}
const { search_space_id: _ignored, ...rest } = buildCreatePayload(form, searchSpaceId);
return rest;
}
function switchToJson() {
setJsonValue(jsonFromCurrentForm());
setJsonIssues([]);
setJsonNotice(undefined);
setActiveMode("json");
}
function switchToForm() {
const result = tryJsonToForm();
if (result.ok) {
setForm(result.form);
setErrors({});
setRootError(null);
setActiveMode("form");
return;
}
setJsonIssues(result.issues);
setJsonNotice(result.notice);
}
function tryJsonToForm():
| { ok: true; form: BuilderForm }
| { ok: false; issues: string[]; notice?: string } {
// Read the raw tree defensively rather than strict-validating: an
// incomplete JSON edit should still round-trip into the form, where the
// form's own validation enforces completeness on submit.
const definition = jsonValue.definition;
if (!definition || typeof definition !== "object") {
return { ok: false, issues: [], notice: "Add a definition before switching to the form." };
}
const name =
typeof jsonValue.name === "string"
? jsonValue.name
: mode === "edit" && automation
? automation.name
: "";
const description = typeof jsonValue.description === "string" ? jsonValue.description : null;
const triggers =
mode === "edit" && automation
? (automation.triggers ?? [])
: extractTriggers(jsonValue.triggers);
const h = hydrateForm(name, description, definition, triggers);
return h.formable
? { ok: true, form: h.form }
: { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}.` };
}
function validateForm(): Record<string, string> | null {
const result = builderFormSchema.safeParse(form);
const next = result.success ? {} : mapFormErrors(result.error);
// The schedule model fields aren't deeply validated by the schema.
if (form.schedule?.mode === "preset") {
const m = form.schedule.model;
if (m.frequency === "weekly" && m.daysOfWeek.length === 0) {
next.schedule = "Pick at least one day for the weekly schedule";
}
} else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) {
next.schedule = "Enter a schedule expression";
}
return Object.keys(next).length > 0 ? next : null;
}
async function reconcileTriggers(automationId: number) {
const desired = buildScheduleTrigger(form);
const existing = (automation?.triggers ?? [])[0];
if (!existing && desired) {
await addTrigger({ automationId, payload: desired });
} else if (existing && !desired) {
await removeTrigger({ automationId, triggerId: existing.id });
} else if (existing && desired) {
await updateTrigger({
automationId,
triggerId: existing.id,
patch: { params: desired.params, enabled: desired.enabled },
});
}
}
async function submitForm() {
setRootError(null);
const formErrors = validateForm();
if (formErrors) {
setErrors(formErrors);
return;
}
setErrors({});
setSubmitting(true);
try {
if (mode === "edit" && automation) {
const payload = buildUpdatePayload(form);
const parsed = automationUpdateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
return;
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
await reconcileTriggers(automation.id);
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
} else {
const payload = buildCreatePayload(form, searchSpaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
}
} catch (err) {
setRootError((err as Error).message ?? "Submit failed");
} finally {
setSubmitting(false);
}
}
async function submitJson() {
setJsonIssues([]);
setSubmitting(true);
try {
if (mode === "edit" && automation) {
const parsed = automationUpdateRequest.safeParse(jsonValue);
if (!parsed.success) {
setJsonIssues(zodIssueList(parsed.error));
return;
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
} else {
const parsed = automationCreateRequest.safeParse({
...jsonValue,
search_space_id: searchSpaceId,
});
if (!parsed.success) {
setJsonIssues(zodIssueList(parsed.error));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
}
} catch (err) {
setJsonIssues([(err as Error).message ?? "Submit failed"]);
} finally {
setSubmitting(false);
}
}
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="inline-flex rounded-md border border-border/60 p-0.5">
<ModeButton
active={activeMode === "form"}
icon={LayoutList}
label="Form"
onClick={() => (activeMode === "form" ? undefined : switchToForm())}
/>
<ModeButton
active={activeMode === "json"}
icon={Code2}
label="Edit as JSON"
onClick={() => (activeMode === "json" ? undefined : switchToJson())}
/>
</div>
</div>
{activeMode === "json" ? (
<Card className="border-border/60 bg-accent">
<CardContent className="pt-6">
<JsonModePanel
value={jsonValue}
issues={jsonIssues}
notice={jsonNotice}
onChange={setJsonValue}
/>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
</CardHeader>
<CardContent>
<BasicsSection
name={form.name}
description={form.description}
errors={errors}
onChange={patchForm}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<TaskList
tasks={form.tasks}
errors={errors}
searchSpaceId={searchSpaceId}
onChange={(tasks) => patchForm({ tasks })}
/>
<UnattendedToggle
checked={form.unattended}
onChange={(unattended) => patchForm({ unattended })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
</CardHeader>
<CardContent>
<ScheduleSection
schedule={form.schedule}
timezone={form.timezone}
errors={errors}
onScheduleChange={(schedule) => patchForm({ schedule })}
onTimezoneChange={(timezone) => patchForm({ timezone })}
/>
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
</CardHeader>
<CardContent>
<AdvancedSection
execution={form.execution}
tags={form.tags}
onExecutionChange={(patch) =>
patchForm({ execution: { ...form.execution, ...patch } })
}
onTagsChange={(tags) => patchForm({ tags })}
/>
</CardContent>
</Card>
</div>
<div className="lg:col-span-1">
<Card className="border-border/60 bg-accent lg:sticky lg:top-4">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Summary</CardTitle>
</CardHeader>
<CardContent>
<BuilderSummary form={form} />
</CardContent>
</Card>
</div>
</div>
)}
{rootError && <p className="text-right text-xs text-destructive">{rootError}</p>}
<div className="flex items-center justify-end gap-2">
<Button asChild type="button" variant="ghost" size="sm">
<Link href={cancelHref}>Cancel</Link>
</Button>
<Button
type="button"
size="sm"
disabled={submitting}
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
{submitting ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
{submitLabel}
</Button>
</div>
</div>
);
}
function ModeButton({
active,
icon: Icon,
label,
onClick,
}: {
active: boolean;
icon: typeof Code2;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
}
function extractTriggers(raw: unknown): HydratableTrigger[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry) => {
const obj = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
return {
type: typeof obj.type === "string" ? obj.type : "",
params:
obj.params && typeof obj.params === "object" ? (obj.params as Record<string, unknown>) : {},
};
});
}
function zodIssueList(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
}
function jsonFromAutomation(automation: Automation | undefined): Record<string, unknown> {
if (!automation) return {};
return {
name: automation.name,
description: automation.description ?? null,
status: automation.status,
definition: automation.definition,
};
}

View file

@ -0,0 +1,42 @@
"use client";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Field } from "./form-field";
interface BasicsSectionProps {
name: string;
description: string | null;
errors: Record<string, string>;
onChange: (patch: { name?: string; description?: string | null }) => void;
}
export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
return (
<div className="space-y-4">
<Field label="Name" htmlFor="automation-name" required error={errors.name}>
<Input
id="automation-name"
value={name}
maxLength={200}
placeholder="Weekly competitor digest"
onChange={(e) => onChange({ name: e.target.value })}
/>
</Field>
<Field
label="Description"
htmlFor="automation-description"
hint="Optional. A short note about what this automation is for."
error={errors.description}
>
<Textarea
id="automation-description"
value={description ?? ""}
rows={2}
placeholder="Summarize what changed and email me the highlights."
onChange={(e) => onChange({ description: e.target.value })}
/>
</Field>
</div>
);
}

View file

@ -0,0 +1,96 @@
"use client";
import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
import { describeCron } from "@/lib/automations/describe-cron";
interface BuilderSummaryProps {
form: BuilderForm;
}
/**
* Live, read-only mirror of what will be created. Mirrors the layout of the
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
*/
export function BuilderSummary({ form }: BuilderSummaryProps) {
const scheduleLabel = form.schedule
? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
: "No schedule — won't run automatically";
return (
<div className="space-y-4 text-sm">
<div className="space-y-1">
<p className="font-medium text-foreground">{form.name.trim() || "Untitled automation"}</p>
{form.description?.trim() && (
<p className="text-xs text-muted-foreground">{form.description.trim()}</p>
)}
</div>
<Section icon={CalendarClock} label="Schedule">
<p className="text-xs text-foreground">{scheduleLabel}</p>
</Section>
<Section
icon={ListOrdered}
label={`Tasks · ${form.tasks.length} step${form.tasks.length === 1 ? "" : "s"}`}
>
<ol className="space-y-1.5 text-xs">
{form.tasks.map((task, index) => (
<li key={task.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">
{index + 1}
</span>
<span className="min-w-0 flex-1 space-y-1">
<span className="block text-foreground line-clamp-2">
{task.query.trim() || (
<span className="text-muted-foreground">No instructions yet</span>
)}
</span>
{task.mentions.length > 0 && (
<span className="flex flex-wrap gap-1">
{task.mentions.map((mention) => (
<span
key={`${mention.kind}:${mention.id}`}
className="inline-flex max-w-[140px] items-center truncate rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary/70"
>
@{mention.title}
</span>
))}
</span>
)}
</span>
</li>
))}
</ol>
</Section>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{form.unattended ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
) : (
<XCircle className="h-3.5 w-3.5" aria-hidden />
)}
{form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
</div>
</div>
);
}
function Section({
icon: Icon,
label,
children,
}: {
icon: LucideIcon;
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,42 @@
"use client";
import { AlertCircle } from "lucide-react";
import type { ReactNode } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface FieldProps {
label?: string;
htmlFor?: string;
hint?: string;
error?: string;
required?: boolean;
className?: string;
children: ReactNode;
}
/**
* Label + control + (hint | inline error) stack shared by every builder
* section. Keeps spacing and error styling consistent so individual sections
* stay focused on their inputs.
*/
export function Field({ label, htmlFor, hint, error, required, className, children }: FieldProps) {
return (
<div className={cn("space-y-1.5", className)}>
{label && (
<Label htmlFor={htmlFor} className="text-xs font-medium text-foreground">
{label}
{required && <span className="text-muted-foreground">*</span>}
</Label>
)}
{children}
{error ? (
<p className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden />
{error}
</p>
) : hint ? (
<p className="text-xs text-muted-foreground">{hint}</p>
) : null}
</div>
);
}

View file

@ -0,0 +1,51 @@
"use client";
import { AlertCircle } from "lucide-react";
import { JsonView } from "@/components/json-view";
interface JsonModePanelProps {
value: Record<string, unknown>;
issues: string[];
notice?: string;
onChange: (next: Record<string, unknown>) => void;
}
/**
* Raw-JSON escape hatch. Edits the same payload the form produces; the
* orchestrator validates it against the contract schema on submit. Shown when
* the user opts into "Edit as JSON" or when an existing definition uses
* features the form can't represent.
*/
export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanelProps) {
return (
<div className="space-y-4">
{notice && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
{notice}
</div>
)}
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-144 overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => onChange(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,258 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import {
InlineMentionEditor,
type InlineMentionEditorRef,
type MentionChipInput,
type MentionedDocument,
type SuggestionAnchorRect,
type SuggestionTriggerInfo,
} from "@/components/assistant-ui/inline-mention-editor";
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
interface MentionTaskInputProps {
searchSpaceId: number;
value: string;
mentions: MentionedDocumentInfo[];
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
placeholder?: string;
disabled?: boolean;
}
type AnchorPoint = { left: number; top: number };
// Mirror of thread.tsx's getComposerSuggestionAnchorPoint -- kept local so the
// chat composer stays untouched.
function getAnchorPoint(rect: SuggestionAnchorRect | null): AnchorPoint | null {
if (!rect) return null;
return { left: rect.left, top: rect.bottom };
}
/** Project the editor's chip shape into the canonical mention info union. */
function toMentionInfo(doc: MentionedDocument): MentionedDocumentInfo {
if (doc.kind === "connector") {
return {
id: doc.id,
title: doc.title,
kind: "connector",
connector_type: doc.connector_type ?? "UNKNOWN",
account_name: doc.account_name ?? doc.title,
};
}
if (doc.kind === "folder") {
return { id: doc.id, title: doc.title, kind: "folder" };
}
return {
id: doc.id,
title: doc.title,
document_type: doc.document_type ?? "UNKNOWN",
kind: "doc",
};
}
/** Project a mention info into the editor's chip-insertion shape. */
function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
if (mention.kind === "connector") {
return {
id: mention.id,
title: mention.title,
kind: "connector",
connector_type: mention.connector_type,
account_name: mention.account_name,
};
}
if (mention.kind === "folder") {
return { id: mention.id, title: mention.title, kind: "folder" };
}
return {
id: mention.id,
title: mention.title,
kind: "doc",
document_type: mention.document_type,
};
}
function removeFirstToken(text: string, token: string): string {
const index = text.indexOf(token);
if (index === -1) return text;
return text.slice(0, index) + text.slice(index + token.length);
}
/**
* Task input that reuses the chat ``@`` mention experience -- the same
* ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer, minus
* SurfSense product docs. The editor is the source of truth while mounted;
* ``onChange`` reports both the plain text (chips rendered as ``@Title``) and
* the structured mention list so the builder can persist IDs for the run.
*/
export function MentionTaskInput({
searchSpaceId,
value,
mentions,
onChange,
placeholder,
disabled,
}: MentionTaskInputProps) {
const editorRef = useRef<InlineMentionEditorRef>(null);
const pickerRef = useRef<DocumentMentionPickerRef>(null);
const [showPopover, setShowPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [anchorPoint, setAnchorPoint] = useState<AnchorPoint | null>(null);
// One-shot hydration of existing mentions into real chips. ``initialText``
// seeds the literal ``@Title`` text; here we strip those tokens and
// re-insert them as chips so the editor reports the structured docs (and
// editing can't silently drop the mention IDs). Position isn't preserved
// on re-hydration -- chips append after the remaining prose.
const didHydrateRef = useRef(false);
useEffect(() => {
if (didHydrateRef.current) return;
didHydrateRef.current = true;
if (mentions.length === 0) return;
const editor = editorRef.current;
if (!editor) return;
let baseText = value;
for (const mention of mentions) {
baseText = removeFirstToken(baseText, `@${mention.title}`);
}
baseText = baseText.replace(/[ \t]{2,}/g, " ").trim();
editor.setText(baseText);
for (const mention of mentions) {
editor.insertMentionChip(toChipInput(mention), { removeTriggerText: false });
}
}, [mentions, value]);
const closePopover = useCallback(() => {
setShowPopover(false);
setMentionQuery("");
setAnchorPoint(null);
}, []);
const handleEditorChange = useCallback(
(text: string, docs: MentionedDocument[]) => {
onChange(text, docs.map(toMentionInfo));
},
[onChange]
);
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
const point = getAnchorPoint(trigger.anchorRect);
if (!point) {
setShowPopover(false);
setMentionQuery("");
setAnchorPoint(null);
return;
}
setAnchorPoint((current) => current ?? point);
setShowPopover(true);
setMentionQuery(trigger.query);
}, []);
const handleMentionClose = useCallback(() => {
setShowPopover((open) => {
if (open) {
setMentionQuery("");
setAnchorPoint(null);
}
return false;
});
}, []);
const handlePopoverOpenChange = useCallback((open: boolean) => {
setShowPopover(open);
if (!open) {
setMentionQuery("");
setAnchorPoint(null);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!showPopover) return;
if (e.key === "ArrowDown") {
e.preventDefault();
pickerRef.current?.moveDown();
} else if (e.key === "ArrowUp") {
e.preventDefault();
pickerRef.current?.moveUp();
} else if (e.key === "Enter") {
e.preventDefault();
pickerRef.current?.selectHighlighted();
} else if (e.key === "Escape") {
e.preventDefault();
if (pickerRef.current?.goBack()) return;
closePopover();
}
},
[showPopover, closePopover]
);
const handleSelection = useCallback(
(picked: MentionedDocumentInfo[]) => {
const editor = editorRef.current;
const existing = new Set(
(editor?.getMentionedDocuments() ?? []).map((doc) => getMentionDocKey(doc))
);
for (const mention of picked) {
const key = getMentionDocKey(mention);
if (existing.has(key)) continue;
editor?.insertMentionChip(toChipInput(mention));
existing.add(key);
}
closePopover();
},
[closePopover]
);
return (
<div
className={cn(
"border-popover-border focus-within:border-ring focus-within:ring-ring/50 dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]",
disabled && "cursor-not-allowed opacity-50"
)}
>
<Popover open={showPopover} onOpenChange={handlePopoverOpenChange}>
{anchorPoint ? (
<>
<PopoverAnchor
className="pointer-events-none fixed size-0"
style={{ left: anchorPoint.left, top: anchorPoint.top }}
/>
<ComposerSuggestionPopoverContent side="bottom">
<DocumentMentionPicker
ref={pickerRef}
searchSpaceId={searchSpaceId}
includeSurfsenseDocs={false}
onSelectionChange={handleSelection}
onDone={closePopover}
initialSelectedDocuments={mentions}
externalSearch={mentionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<InlineMentionEditor
ref={editorRef}
initialText={value}
placeholder={placeholder ?? "Type @ to reference files, folders, or connectors"}
disabled={disabled}
onChange={handleEditorChange}
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onKeyDown={handleKeyDown}
/>
</div>
);
}

View file

@ -0,0 +1,275 @@
"use client";
import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type BuilderSchedule, scheduleToCron } from "@/lib/automations/builder-schema";
import { describeCron } from "@/lib/automations/describe-cron";
import {
DEFAULT_SCHEDULE,
FREQUENCY_OPTIONS,
fromCron,
type ScheduleFrequency,
type ScheduleModel,
toCron,
WEEKDAY_OPTIONS,
} from "@/lib/automations/schedule-builder";
import { cn } from "@/lib/utils";
import { Field } from "./form-field";
import { TimezoneCombobox } from "./timezone-combobox";
interface ScheduleSectionProps {
schedule: BuilderSchedule | null;
timezone: string;
errors: Record<string, string>;
onScheduleChange: (schedule: BuilderSchedule | null) => void;
onTimezoneChange: (timezone: string) => void;
}
function pad(value: number): string {
return value.toString().padStart(2, "0");
}
export function ScheduleSection({
schedule,
timezone,
errors,
onScheduleChange,
onTimezoneChange,
}: ScheduleSectionProps) {
if (schedule === null) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-6 text-center">
<CalendarOff className="mx-auto h-7 w-7 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm text-foreground">No schedule</p>
<p className="mt-0.5 text-xs text-muted-foreground">
This automation won't run automatically until you add one.
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
onClick={() => onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
>
<Plus className="mr-1.5 h-4 w-4" />
Add a schedule
</Button>
</div>
);
}
const cron = scheduleToCron(schedule);
const label = describeCron(cron);
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-background px-3 py-2">
<div className="flex items-center gap-2 text-sm min-w-0">
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="font-medium text-foreground truncate">{label}</span>
<span className="text-muted-foreground shrink-0">· {timezone}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Remove schedule"
onClick={() => onScheduleChange(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
{schedule.mode === "preset" ? (
<PresetEditor
model={schedule.model}
onChange={(model) => onScheduleChange({ mode: "preset", model })}
onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
/>
) : (
<CronEditor
cron={schedule.cron}
error={errors.schedule}
onChange={(value) => onScheduleChange({ mode: "cron", cron: value })}
onSwitchToPreset={() =>
onScheduleChange({
mode: "preset",
model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
})
}
/>
)}
<Field label="Timezone">
<TimezoneCombobox value={timezone} onChange={onTimezoneChange} />
</Field>
</div>
);
}
interface PresetEditorProps {
model: ScheduleModel;
onChange: (model: ScheduleModel) => void;
onSwitchToCron: () => void;
}
function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
const weeklyNoDays = model.frequency === "weekly" && model.daysOfWeek.length === 0;
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Frequency">
<Select
value={model.frequency}
onValueChange={(value) => onChange({ ...model, frequency: value as ScheduleFrequency })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{model.frequency === "hourly" ? (
<Field label="At minute">
<Input
type="number"
min={0}
max={59}
value={model.minute}
onChange={(e) => onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
/>
</Field>
) : (
<Field label="At time">
<Input
type="time"
value={`${pad(model.hour)}:${pad(model.minute)}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":");
onChange({
...model,
hour: clampInt(h, 0, 23),
minute: clampInt(m, 0, 59),
});
}}
/>
</Field>
)}
</div>
{model.frequency === "weekly" && (
<Field label="On days" error={weeklyNoDays ? "Pick at least one day" : undefined}>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_OPTIONS.map((day) => {
const active = model.daysOfWeek.includes(day.value);
return (
<button
key={day.value}
type="button"
aria-pressed={active}
onClick={() =>
onChange({ ...model, daysOfWeek: toggleDay(model.daysOfWeek, day.value) })
}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border/60 bg-background text-muted-foreground hover:bg-muted"
)}
>
{day.short}
</button>
);
})}
</div>
</Field>
)}
{model.frequency === "monthly" && (
<Field label="Day of month" hint={"1\u201331."}>
<Input
type="number"
min={1}
max={31}
value={model.dayOfMonth}
onChange={(e) => onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
className="w-24"
/>
</Field>
)}
<button
type="button"
onClick={onSwitchToCron}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Advanced: enter a schedule expression
</button>
</div>
);
}
interface CronEditorProps {
cron: string;
error?: string;
onChange: (cron: string) => void;
onSwitchToPreset: () => void;
}
function CronEditor({ cron, error, onChange, onSwitchToPreset }: CronEditorProps) {
const trimmed = cron.trim();
const label = trimmed ? describeCron(trimmed) : null;
return (
<div className="space-y-2">
<Field
label="Schedule expression"
hint="Five-field cron, e.g. 0 9 * * 1-5 (minute hour day month weekday)."
error={error}
>
<Input
value={cron}
placeholder="0 9 * * 1-5"
className="font-mono"
onChange={(e) => onChange(e.target.value)}
/>
</Field>
{label && label !== trimmed && <p className="text-xs text-muted-foreground">Runs: {label}</p>}
<button
type="button"
onClick={onSwitchToPreset}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Use the simple picker
</button>
</div>
);
}
function clampInt(raw: string, min: number, max: number): number {
const value = Number.parseInt(raw, 10);
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
function toggleDay(days: number[], value: number): number[] {
return days.includes(value)
? days.filter((day) => day !== value)
: [...days, value].sort((a, b) => a - b);
}

View file

@ -0,0 +1,136 @@
"use client";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { BuilderTask } from "@/lib/automations/builder-schema";
import { Field } from "./form-field";
import { MentionTaskInput } from "./mention-task-input";
interface TaskItemProps {
index: number;
total: number;
task: BuilderTask;
searchSpaceId: number;
error?: string;
onChange: (patch: Partial<BuilderTask>) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
function parseOptionalInt(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
const value = Number.parseInt(trimmed, 10);
return Number.isNaN(value) ? null : value;
}
export function TaskItem({
index,
total,
task,
searchSpaceId,
error,
onChange,
onMoveUp,
onMoveDown,
onRemove,
}: TaskItemProps) {
return (
<div className="rounded-lg border border-border/60 bg-background p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
{index + 1}
</span>
Task {index + 1}
</span>
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={index === 0}
aria-label="Move task up"
onClick={onMoveUp}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
disabled={index === total - 1}
aria-label="Move task down"
onClick={onMoveDown}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
disabled={total === 1}
aria-label="Remove task"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Field
error={error}
hint="Type @ to reference files, folders, or connectors for extra context."
>
<MentionTaskInput
searchSpaceId={searchSpaceId}
value={task.query}
mentions={task.mentions}
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
onChange={(query, mentions) => onChange({ query, mentions })}
/>
</Field>
<Accordion type="single" collapsible>
<AccordionItem value="advanced" className="border-b-0">
<AccordionTrigger className="py-1.5 text-xs text-muted-foreground hover:no-underline">
Advanced
</AccordionTrigger>
<AccordionContent className="pb-1">
<div className="grid grid-cols-2 gap-3">
<Field label="Max retries" hint="Leave blank to use the default.">
<Input
type="number"
min={0}
max={10}
value={task.maxRetries ?? ""}
placeholder="default"
onChange={(e) => onChange({ maxRetries: parseOptionalInt(e.target.value) })}
/>
</Field>
<Field label="Timeout (seconds)" hint="Leave blank to use the default.">
<Input
type="number"
min={1}
value={task.timeoutSeconds ?? ""}
placeholder="default"
onChange={(e) => onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
/>
</Field>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View file

@ -0,0 +1,65 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { type BuilderTask, emptyTask } from "@/lib/automations/builder-schema";
import { TaskItem } from "./task-item";
interface TaskListProps {
tasks: BuilderTask[];
errors: Record<string, string>;
searchSpaceId: number;
onChange: (tasks: BuilderTask[]) => void;
}
/**
* Ordered list of agent tasks. Steps run sequentially in the order shown.
* Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
*/
export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
function updateAt(index: number, patch: Partial<BuilderTask>) {
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
}
function removeAt(index: number) {
onChange(tasks.filter((_, i) => i !== index));
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= tasks.length) return;
const next = [...tasks];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
}
return (
<div className="space-y-3">
{tasks.map((task, index) => (
<TaskItem
key={task.id}
index={index}
total={tasks.length}
task={task}
searchSpaceId={searchSpaceId}
error={errors[`tasks.${index}.query`]}
onChange={(patch) => updateAt(index, patch)}
onMoveUp={() => move(index, -1)}
onMoveDown={() => move(index, 1)}
onRemove={() => removeAt(index)}
/>
))}
{errors.tasks && <p className="text-xs text-destructive">{errors.tasks}</p>}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onChange([...tasks, emptyTask()])}
>
<Plus className="mr-1.5 h-4 w-4" />
Add task
</Button>
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { Check, ChevronsUpDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getTimezones } from "@/lib/automations/builder-schema";
import { cn } from "@/lib/utils";
interface TimezoneComboboxProps {
value: string;
onChange: (value: string) => void;
}
/**
* Searchable IANA timezone picker. The full ``Intl.supportedValuesOf`` list is
* long, so it lives behind a Command search instead of a flat Select.
*/
export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
const [open, setOpen] = useState(false);
const timezones = useMemo(() => getTimezones(), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="truncate">{value || "Select timezone"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search timezone..." />
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<CommandGroup>
{timezones.map((tz) => (
<CommandItem
key={tz}
value={tz}
onSelect={() => {
onChange(tz);
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === tz ? "opacity-100" : "opacity-0")}
/>
{tz}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { Info } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface UnattendedToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
}
/**
* Maps to ``auto_approve_all`` on every agent task. Automations run with no one
* watching, so this defaults ON; turning it off means any approval prompt the
* agent raises is rejected and the step can stall.
*/
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
return (
<div className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background px-3 py-3">
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
Run without asking for approvals
</span>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="More info" className="text-muted-foreground">
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Automations run unattended. With this off, any approval the agent asks for is
rejected, which can stall a step.
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Auto-approve actions the agent would normally pause to confirm.
</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
aria-label="Run without asking for approvals"
/>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationJsonForm } from "./components/automation-json-form";
import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps {
@ -9,10 +9,10 @@ interface AutomationNewContentProps {
}
/**
* Orchestrator for the raw-JSON create route. Gates on
* ``automations:create`` so users who can't create don't even see the
* form; same panel as the detail page's access-denied state for
* consistency.
* Orchestrator for the create route. Gates on ``automations:create`` so users
* who can't create don't even see the form; same panel as the detail page's
* access-denied state for consistency. The builder defaults to the friendly
* form with a raw-JSON escape hatch.
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
@ -36,7 +36,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
return (
<>
<AutomationNewHeader searchSpaceId={searchSpaceId} />
<AutomationJsonForm searchSpaceId={searchSpaceId} />
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
</>
);
}

View file

@ -1,98 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, FileJson, Save } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { automationCreateRequest } from "@/contracts/types/automation.types";
import { DEFAULT_AUTOMATION_TEMPLATE } from "@/lib/automations/default-template";
interface AutomationJsonFormProps {
searchSpaceId: number;
}
/**
* Raw-JSON create form. Lets power users skip the chat drafter when they
* already know the shape they want. Flow:
* edit tree inject search_space_id Zod validate POST navigate
*
* ``search_space_id`` is injected here rather than required in the edited
* tree the user shouldn't have to know their numeric id, and it keeps
* the template copy-paste-friendly across search spaces.
*/
export function AutomationJsonForm({ searchSpaceId }: AutomationJsonFormProps) {
const router = useRouter();
const { mutateAsync: createAutomation, isPending } = useAtomValue(createAutomationMutationAtom);
const [value, setValue] = useState<Record<string, unknown>>(
() => DEFAULT_AUTOMATION_TEMPLATE as Record<string, unknown>
);
const [issues, setIssues] = useState<string[]>([]);
async function handleSubmit() {
setIssues([]);
const payload = { ...value, search_space_id: searchSpaceId };
const result = automationCreateRequest.safeParse(payload);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
);
return;
}
try {
const created = await createAutomation(result.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
} catch (err) {
setIssues([(err as Error).message ?? "Submit failed"]);
}
}
const hasIssues = issues.length > 0;
return (
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
<FileJson className="h-4 w-4 text-muted-foreground" aria-hidden />
Definition + triggers
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[32rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{hasIssues && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" onClick={handleSubmit} disabled={isPending} size="sm">
{isPending ? <Spinner size="xs" className="mr-2" /> : <Save className="mr-2 h-4 w-4" />}
Create automation
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -22,12 +22,9 @@ export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps)
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<h1 className="text-xl md:text-2xl font-semibold text-foreground">
New automation · raw JSON
</h1>
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
Paste an ``AutomationCreate`` payload and submit. Validated against the schema before
save. Prefer natural language? Use chat instead.
Set up a task and a schedule. Prefer natural language? Use chat instead.
</p>
</div>
<Button asChild variant="outline" size="sm">

View file

@ -57,6 +57,13 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string;
/**
* Whether to surface the "SurfSense Docs" (product documentation) branch
* and include those docs in search results. Defaults to ``true`` so the
* chat composer is unchanged; callers like the automation task input pass
* ``false`` to reference only the user's own knowledge base + connectors.
*/
includeSurfsenseDocs?: boolean;
}
const PAGE_SIZE = 20;
@ -228,7 +235,14 @@ export const DocumentMentionPicker = forwardRef<
DocumentMentionPickerRef,
DocumentMentionPickerProps
>(function DocumentMentionPicker(
{ searchSpaceId, onSelectionChange, onDone, initialSelectedDocuments = [], externalSearch = "" },
{
searchSpaceId,
onSelectionChange,
onDone,
initialSelectedDocuments = [],
externalSearch = "",
includeSurfsenseDocs = true,
},
ref
) {
const search = externalSearch;
@ -307,7 +321,7 @@ export const DocumentMentionPicker = forwardRef<
queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000,
enabled: !hasSearch || isSearchValid,
enabled: includeSurfsenseDocs && (!hasSearch || isSearchValid),
placeholderData: keepPreviousData,
});
@ -324,7 +338,7 @@ export const DocumentMentionPicker = forwardRef<
if (currentPage !== 0) return;
const combinedDocs: Pick<Document, "id" | "title" | "document_type">[] = [];
if (surfsenseDocs?.items) {
if (includeSurfsenseDocs && surfsenseDocs?.items) {
for (const doc of surfsenseDocs.items) {
combinedDocs.push({
id: doc.id,
@ -340,7 +354,7 @@ export const DocumentMentionPicker = forwardRef<
}
setAccumulatedDocuments(filterBySearchTerm(combinedDocs));
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm]);
}, [titleSearchResults, surfsenseDocs, currentPage, filterBySearchTerm, includeSurfsenseDocs]);
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
@ -449,7 +463,7 @@ export const DocumentMentionPicker = forwardRef<
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments]
);
const showSurfsenseDocsRoot = surfsenseDocsList.length > 0;
const showSurfsenseDocsRoot = includeSurfsenseDocs && surfsenseDocsList.length > 0;
const selectMention = useCallback(
(mention: MentionedDocumentInfo) => {

View file

@ -0,0 +1,456 @@
/**
* The form builder's own data model plus the mappers that bridge it to the
* backend contract (``automation.types.ts``).
*
* The builder deliberately exposes a *subset* of the full automation
* definition: a name, one or more natural-language agent tasks, a single
* schedule, and a few execution knobs. Anything richer (goal, per-step
* ``when`` predicates, ``inputs`` schema, ``on_failure`` steps, multiple or
* non-schedule triggers, custom metadata) is not representable here, so on
* edit we detect it and bounce the user to raw-JSON mode rather than silently
* dropping their data. ``goal`` is the one exception: it is carried through
* invisibly so the common drafter-produced automation stays form-editable.
*/
import { z } from "zod";
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
import {
type Automation,
type AutomationCreateRequest,
type AutomationDefinition,
type AutomationUpdateRequest,
execution as executionContract,
type TriggerCreateRequest,
} from "@/contracts/types/automation.types";
import { DEFAULT_SCHEDULE, fromCron, type ScheduleModel, toCron } from "./schedule-builder";
const EXECUTION_DEFAULTS = executionContract.parse({});
// ---------------------------------------------------------------------------
// Form model
// ---------------------------------------------------------------------------
export const builderTaskSchema = z.object({
/** Client-side identity for stable React keys across reorder; not persisted. */
id: z.string(),
query: z.string().trim().min(1, "Describe what the agent should do"),
/**
* Files / folders / connectors @-mentioned in the query. Mirrors the chat
* composer's mention list and is forwarded to the run as step params so the
* agent scopes retrieval to them. The query text already carries ``@Title``
* for each; this is the structured side-channel of IDs.
*/
mentions: z.array(z.custom<MentionedDocumentInfo>()),
maxRetries: z.number().int().min(0).max(10).nullable(),
timeoutSeconds: z.number().int().positive().max(86_400).nullable(),
});
export type BuilderTask = z.infer<typeof builderTaskSchema>;
export const builderScheduleSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("preset"),
model: z.custom<ScheduleModel>(),
}),
z.object({
mode: z.literal("cron"),
cron: z.string().trim().min(1, "Enter a schedule expression"),
}),
]);
export type BuilderSchedule = z.infer<typeof builderScheduleSchema>;
export const builderExecutionSchema = z.object({
timeoutSeconds: z.number().int().positive().max(86_400),
maxRetries: z.number().int().min(0).max(10),
retryBackoff: z.enum(["exponential", "linear", "none"]),
concurrency: z.enum(["drop_if_running", "queue", "always"]),
});
export type BuilderExecution = z.infer<typeof builderExecutionSchema>;
export const builderFormSchema = z.object({
name: z.string().trim().min(1, "Give your automation a name").max(200),
description: z.string().trim().max(2000).nullable(),
tasks: z.array(builderTaskSchema).min(1, "Add at least one task"),
unattended: z.boolean(),
schedule: builderScheduleSchema.nullable(),
timezone: z.string().min(1),
execution: builderExecutionSchema,
tags: z.array(z.string()),
/** Carried through from an edited definition so we don't drop it. */
goal: z.string().nullable(),
});
export type BuilderForm = z.infer<typeof builderFormSchema>;
// ---------------------------------------------------------------------------
// Defaults / construction
// ---------------------------------------------------------------------------
export function getDefaultTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
export function getTimezones(): string[] {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) return supported;
} catch {
// fall through
}
return ["UTC", getDefaultTimezone()];
}
function newId(): string {
try {
return crypto.randomUUID();
} catch {
return `task_${Math.random().toString(36).slice(2)}`;
}
}
export function emptyTask(): BuilderTask {
return { id: newId(), query: "", mentions: [], maxRetries: null, timeoutSeconds: null };
}
export function createEmptyForm(): BuilderForm {
return {
name: "",
description: null,
tasks: [emptyTask()],
unattended: true,
schedule: { mode: "preset", model: { ...DEFAULT_SCHEDULE } },
timezone: getDefaultTimezone(),
execution: {
timeoutSeconds: EXECUTION_DEFAULTS.timeout_seconds,
maxRetries: EXECUTION_DEFAULTS.max_retries,
retryBackoff: EXECUTION_DEFAULTS.retry_backoff,
concurrency: EXECUTION_DEFAULTS.concurrency,
},
tags: [],
goal: null,
};
}
/** The cron string a schedule resolves to, regardless of preset/raw mode. */
export function scheduleToCron(schedule: BuilderSchedule): string {
return schedule.mode === "preset" ? toCron(schedule.model) : schedule.cron.trim();
}
// ---------------------------------------------------------------------------
// Form -> contract payloads
// ---------------------------------------------------------------------------
/**
* Project a task's @-mentions into the ``agent_task`` param fields the backend
* understands (the same names the chat ``new_chat`` request uses, minus
* SurfSense docs). Returns an empty object when there are no mentions so the
* params stay clean. ``mentioned_documents`` carries full chip metadata so the
* run can resolve titles/paths and the form can round-trip the chips back.
*/
function mentionParams(mentions: MentionedDocumentInfo[]): Record<string, unknown> {
if (mentions.length === 0) return {};
const documentIds: number[] = [];
const folderIds: number[] = [];
const connectorIds: number[] = [];
const connectors: MentionedDocumentInfo[] = [];
for (const mention of mentions) {
if (mention.kind === "folder") {
folderIds.push(mention.id);
} else if (mention.kind === "connector") {
connectorIds.push(mention.id);
connectors.push(mention);
} else {
documentIds.push(mention.id);
}
}
const out: Record<string, unknown> = { mentioned_documents: mentions };
if (documentIds.length > 0) out.mentioned_document_ids = documentIds;
if (folderIds.length > 0) out.mentioned_folder_ids = folderIds;
if (connectorIds.length > 0) {
out.mentioned_connector_ids = connectorIds;
out.mentioned_connectors = connectors;
}
return out;
}
function buildPlan(form: BuilderForm) {
return form.tasks.map((task, index) => {
const step: Record<string, unknown> = {
step_id: `step_${index + 1}`,
action: "agent_task",
params: {
query: task.query.trim(),
auto_approve_all: form.unattended,
...mentionParams(task.mentions),
},
};
if (task.maxRetries !== null) step.max_retries = task.maxRetries;
if (task.timeoutSeconds !== null) step.timeout_seconds = task.timeoutSeconds;
return step;
});
}
function buildDefinition(form: BuilderForm): AutomationDefinition {
return {
schema_version: "1.0",
name: form.name.trim(),
goal: form.goal,
// Triggers are attached at the top level of the create payload, not in
// the definition; the in-definition list stays empty.
triggers: [],
plan: buildPlan(form),
execution: {
timeout_seconds: form.execution.timeoutSeconds,
max_retries: form.execution.maxRetries,
retry_backoff: form.execution.retryBackoff,
concurrency: form.execution.concurrency,
on_failure: [],
},
metadata: { tags: form.tags },
} as unknown as AutomationDefinition;
}
/** The desired schedule trigger for this form, or ``null`` if none. */
export function buildScheduleTrigger(form: BuilderForm): TriggerCreateRequest | null {
if (!form.schedule) return null;
return {
type: "schedule",
params: { cron: scheduleToCron(form.schedule), timezone: form.timezone },
static_inputs: {},
enabled: true,
};
}
export function buildCreatePayload(
form: BuilderForm,
searchSpaceId: number
): AutomationCreateRequest {
const trigger = buildScheduleTrigger(form);
return {
search_space_id: searchSpaceId,
name: form.name.trim(),
description: form.description?.trim() ? form.description.trim() : null,
definition: buildDefinition(form),
triggers: trigger ? [trigger] : [],
};
}
export function buildUpdatePayload(form: BuilderForm): AutomationUpdateRequest {
return {
name: form.name.trim(),
description: form.description?.trim() ? form.description.trim() : null,
definition: buildDefinition(form),
};
}
// ---------------------------------------------------------------------------
// Contract -> form (edit hydration with safe fallback)
// ---------------------------------------------------------------------------
export type HydrateResult =
| { formable: true; form: BuilderForm }
| { formable: false; reason: string };
/** A trigger as seen by the hydrator: both ``Trigger`` and ``TriggerCreateRequest`` fit. */
export interface HydratableTrigger {
type: string;
params: Record<string, unknown>;
}
const BACKOFF_VALUES = ["exponential", "linear", "none"] as const;
const CONCURRENCY_VALUES = ["drop_if_running", "queue", "always"] as const;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
/** Best-effort projection of a stored ``mentioned_documents`` entry into a chip. */
function coerceMention(raw: unknown): MentionedDocumentInfo | null {
const o = asRecord(raw);
if (typeof o.id !== "number" || typeof o.title !== "string") return null;
if (o.kind === "folder") {
return { id: o.id, title: o.title, kind: "folder" };
}
if (o.kind === "connector") {
if (typeof o.connector_type !== "string" || typeof o.account_name !== "string") return null;
return {
id: o.id,
title: o.title,
kind: "connector",
connector_type: o.connector_type,
account_name: o.account_name,
};
}
return {
id: o.id,
title: o.title,
kind: "doc",
document_type: typeof o.document_type === "string" ? o.document_type : "UNKNOWN",
};
}
/**
* Rebuild a task's mention chips from step params. Returns ``null`` when the
* step carries mention IDs that aren't backed by usable ``mentioned_documents``
* metadata (e.g. hand-edited JSON), so the caller can fall back to JSON mode
* rather than silently dropping those IDs on the next save.
*/
function mentionsFromParams(params: Record<string, unknown>): MentionedDocumentInfo[] | null {
const rawList = Array.isArray(params.mentioned_documents) ? params.mentioned_documents : [];
const mentions: MentionedDocumentInfo[] = [];
for (const raw of rawList) {
const mention = coerceMention(raw);
if (mention) mentions.push(mention);
}
const haveByKind = {
doc: new Set(mentions.filter((m) => m.kind === "doc").map((m) => m.id)),
folder: new Set(mentions.filter((m) => m.kind === "folder").map((m) => m.id)),
connector: new Set(mentions.filter((m) => m.kind === "connector").map((m) => m.id)),
};
const idChecks: Array<[unknown, Set<number>]> = [
[params.mentioned_document_ids, haveByKind.doc],
[params.mentioned_folder_ids, haveByKind.folder],
[params.mentioned_connector_ids, haveByKind.connector],
];
for (const [arr, have] of idChecks) {
if (!Array.isArray(arr)) continue;
for (const id of arr) {
if (typeof id === "number" && !have.has(id)) return null;
}
}
return mentions;
}
/**
* Core projection of a definition + triggers into the builder form. Returns
* ``formable: false`` whenever something can't be represented, so the caller
* can drop into raw-JSON mode without losing data. Shared by the edit
* hydrator and the JSON-mode round-trip.
*
* The definition is read defensively (``unknown``) so a partially edited JSON
* tree can still round-trip into the form; completeness is enforced by the
* form's own validation at submit time, not here.
*/
export function hydrateForm(
name: string,
description: string | null,
def: unknown,
triggers: HydratableTrigger[]
): HydrateResult {
const d = asRecord(def);
if (d.inputs) {
return { formable: false, reason: "uses an inputs schema" };
}
const exec = asRecord(d.execution);
const onFailure = Array.isArray(exec.on_failure) ? exec.on_failure : [];
if (onFailure.length > 0) {
return { formable: false, reason: "has on-failure steps" };
}
const metadata = asRecord(d.metadata);
const extraMetadataKeys = Object.keys(metadata).filter((key) => key !== "tags");
if (extraMetadataKeys.length > 0) {
return { formable: false, reason: "has custom metadata" };
}
const plan = Array.isArray(d.plan) ? d.plan : [];
const tasks: BuilderTask[] = [];
let unattended = true;
for (const rawStep of plan) {
const step = asRecord(rawStep);
if (step.action !== "agent_task") {
return { formable: false, reason: `uses the "${String(step.action)}" action` };
}
if (step.when) {
return { formable: false, reason: "uses conditional steps" };
}
const params = asRecord(step.params);
const query = typeof params.query === "string" ? params.query : "";
// auto_approve_all is a single global toggle in the form; if any step is
// explicitly false we surface the toggle as off.
if (params.auto_approve_all === false) unattended = false;
const mentions = mentionsFromParams(params);
if (mentions === null) {
return { formable: false, reason: "references mentions without metadata" };
}
tasks.push({
id: newId(),
query,
mentions,
maxRetries: typeof step.max_retries === "number" ? step.max_retries : null,
timeoutSeconds: typeof step.timeout_seconds === "number" ? step.timeout_seconds : null,
});
}
if (tasks.length === 0) {
return { formable: false, reason: "has no steps" };
}
if (triggers.length > 1) {
return { formable: false, reason: "has multiple triggers" };
}
const trigger = triggers[0];
let schedule: BuilderSchedule | null = null;
let timezone = getDefaultTimezone();
if (trigger) {
if (trigger.type !== "schedule") {
return { formable: false, reason: `has a "${trigger.type}" trigger` };
}
const cron = typeof trigger.params?.cron === "string" ? trigger.params.cron : "";
timezone = typeof trigger.params?.timezone === "string" ? trigger.params.timezone : timezone;
const model = fromCron(cron);
schedule = model ? { mode: "preset", model } : { mode: "cron", cron };
}
const retryBackoff = BACKOFF_VALUES.includes(exec.retry_backoff as never)
? (exec.retry_backoff as BuilderExecution["retryBackoff"])
: EXECUTION_DEFAULTS.retry_backoff;
const concurrency = CONCURRENCY_VALUES.includes(exec.concurrency as never)
? (exec.concurrency as BuilderExecution["concurrency"])
: EXECUTION_DEFAULTS.concurrency;
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter((tag): tag is string => typeof tag === "string")
: [];
return {
formable: true,
form: {
name,
description: description ?? null,
tasks,
unattended,
schedule,
timezone,
execution: {
timeoutSeconds:
typeof exec.timeout_seconds === "number"
? exec.timeout_seconds
: EXECUTION_DEFAULTS.timeout_seconds,
maxRetries:
typeof exec.max_retries === "number" ? exec.max_retries : EXECUTION_DEFAULTS.max_retries,
retryBackoff,
concurrency,
},
tags,
goal: typeof d.goal === "string" ? d.goal : null,
},
};
}
/**
* Project an existing automation into the builder form for editing.
*/
export function formFromAutomation(automation: Automation): HydrateResult {
return hydrateForm(
automation.name,
automation.description ?? null,
automation.definition,
automation.triggers ?? []
);
}

View file

@ -1,44 +0,0 @@
/**
* Minimal valid ``AutomationCreate`` skeleton used to seed the raw-JSON
* create form. ``search_space_id`` is omitted on purpose the form
* injects it from the route so users never have to know their id.
*
* The shape matches the Pydantic ``AutomationCreate`` model less the
* search_space_id field; Zod validates the merged payload before submit.
*/
export const DEFAULT_AUTOMATION_TEMPLATE = {
name: "My automation",
description: null,
definition: {
name: "My automation",
goal: null,
plan: [
{
step_id: "step_1",
action: "agent_task",
params: {
query: "Summarize new docs added to folder 12 since the last run.",
},
},
],
execution: {
timeout_seconds: 600,
max_retries: 2,
retry_backoff: "exponential",
concurrency: "drop_if_running",
on_failure: [],
},
metadata: { tags: [] },
},
triggers: [
{
type: "schedule",
params: {
cron: "0 9 * * 1-5",
timezone: "UTC",
},
static_inputs: {},
enabled: true,
},
],
} as const;

View file

@ -0,0 +1,132 @@
/**
* Bidirectional bridge between a friendly schedule model and the 5-field cron
* expression the backend ``schedule`` trigger expects (see
* ``app/automations/triggers/schedule/params.py``).
*
* The form builder never asks users to type cron. They pick a frequency + time
* (+ days), which ``toCron`` compiles. On edit we ``fromCron`` an existing
* expression back into the model; anything we don't recognize returns ``null``
* so the caller can fall back to a raw-cron escape hatch instead of silently
* losing the user's schedule.
*
* The recognized patterns are intentionally the same family that
* ``describe-cron.ts`` humanizes, keeping the picker and the label in sync.
*/
export type ScheduleFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "monthly";
export interface ScheduleModel {
frequency: ScheduleFrequency;
/** 0-23. Ignored for ``hourly``. */
hour: number;
/** 0-59. */
minute: number;
/** 0 (Sun) - 6 (Sat). Used by ``weekly``. */
daysOfWeek: number[];
/** 1-31. Used by ``monthly``. */
dayOfMonth: number;
}
/** Sunday-first, matching cron's 0-6 day-of-week numbering. */
export const WEEKDAY_OPTIONS: ReadonlyArray<{ value: number; short: string; long: string }> = [
{ value: 1, short: "Mon", long: "Monday" },
{ value: 2, short: "Tue", long: "Tuesday" },
{ value: 3, short: "Wed", long: "Wednesday" },
{ value: 4, short: "Thu", long: "Thursday" },
{ value: 5, short: "Fri", long: "Friday" },
{ value: 6, short: "Sat", long: "Saturday" },
{ value: 0, short: "Sun", long: "Sunday" },
];
export const FREQUENCY_OPTIONS: ReadonlyArray<{ value: ScheduleFrequency; label: string }> = [
{ value: "hourly", label: "Every hour" },
{ value: "daily", label: "Every day" },
{ value: "weekdays", label: "Every weekday (Mon\u2013Fri)" },
{ value: "weekly", label: "Specific days of the week" },
{ value: "monthly", label: "Once a month" },
];
export const DEFAULT_SCHEDULE: ScheduleModel = {
frequency: "weekdays",
hour: 9,
minute: 0,
daysOfWeek: [1],
dayOfMonth: 1,
};
function isInt(value: string): boolean {
return /^\d+$/.test(value);
}
function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(max, Math.max(min, value));
}
/** Compile a schedule model into a 5-field cron expression. */
export function toCron(model: ScheduleModel): string {
const minute = clamp(model.minute, 0, 59);
const hour = clamp(model.hour, 0, 23);
switch (model.frequency) {
case "hourly":
return `${minute} * * * *`;
case "daily":
return `${minute} ${hour} * * *`;
case "weekdays":
return `${minute} ${hour} * * 1-5`;
case "weekly": {
const days = [...new Set(model.daysOfWeek)].sort((a, b) => a - b);
// Guard against an empty selection producing an invalid cron.
const dow = days.length > 0 ? days.join(",") : "1";
return `${minute} ${hour} * * ${dow}`;
}
case "monthly":
return `${minute} ${hour} ${clamp(model.dayOfMonth, 1, 31)} * *`;
}
}
/**
* Parse a 5-field cron expression back into a schedule model. Returns ``null``
* for anything outside the recognized pattern family so callers can fall back
* to the raw-cron field.
*/
export function fromCron(cron: string): ScheduleModel | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minute, hour, dom, month, dow] = parts;
// Hourly: "M * * * *"
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && isInt(minute)) {
return { ...DEFAULT_SCHEDULE, frequency: "hourly", minute: Number(minute) };
}
// Everything below requires concrete minute + hour.
if (!isInt(minute) || !isInt(hour)) return null;
const base = { hour: Number(hour), minute: Number(minute) };
// Daily: "M H * * *"
if (month === "*" && dom === "*" && dow === "*") {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "daily" };
}
// Weekdays: "M H * * 1-5"
if (month === "*" && dom === "*" && dow === "1-5") {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekdays" };
}
// Weekly: "M H * * 1,3,5"
if (month === "*" && dom === "*" && /^[0-6](,[0-6])*$/.test(dow)) {
const daysOfWeek = [...new Set(dow.split(",").map(Number))].sort((a, b) => a - b);
return { ...DEFAULT_SCHEDULE, ...base, frequency: "weekly", daysOfWeek };
}
// Monthly: "M H D * *"
if (month === "*" && dow === "*" && isInt(dom)) {
return { ...DEFAULT_SCHEDULE, ...base, frequency: "monthly", dayOfMonth: Number(dom) };
}
return null;
}