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