Merge pull request #1443 from CREDO23/feature-automations

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

View file

@ -0,0 +1,91 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomation } from "@/hooks/use-automation";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationDefinitionSection } from "./components/automation-definition-section";
import { AutomationDetailHeader } from "./components/automation-detail-header";
import { AutomationDetailLoading } from "./components/automation-detail-loading";
import { AutomationNotFound } from "./components/automation-not-found";
import { AutomationRunsSection } from "./components/automation-runs-section";
import { AutomationTriggersSection } from "./components/automation-triggers-section";
interface AutomationDetailContentProps {
searchSpaceId: number;
automationId: number;
}
/**
* Client orchestrator for one automation's detail view. Branches:
* - permissions loading skeleton
* - no read permission access denied panel
* - bad id (NaN) not-found panel
* - detail fetching skeleton
* - detail error / null not-found panel (we don't distinguish 404
* from 403 in the UI)
* - detail loaded header + definition + triggers
*
* Each child component is gated independently on the relevant permission
* so the orchestrator stays thin.
*/
export function AutomationDetailContent({
searchSpaceId,
automationId,
}: AutomationDetailContentProps) {
const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
if (perms.loading) {
return <AutomationDetailLoading />;
}
if (!perms.canRead) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
</p>
</div>
);
}
if (!validId) {
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
}
if (isLoading) {
return <AutomationDetailLoading />;
}
if (error || !automation) {
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
}
return (
<>
<AutomationDetailHeader
automation={automation}
searchSpaceId={searchSpaceId}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 min-w-0 lg:col-span-2">
<AutomationDefinitionSection definition={automation.definition} />
<AutomationRunsSection automationId={automation.id} />
</div>
<div className="space-y-6 min-w-0">
<AutomationTriggersSection
triggers={automation.triggers}
automationId={automation.id}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}
/>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { ListOrdered, Settings2, Tag, Target } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AutomationDefinition } from "@/contracts/types/automation.types";
import { ExecutionSummary } from "./execution-summary";
import { InputsSchemaPreview } from "./inputs-schema-preview";
import { PlanStepCard } from "./plan-step-card";
interface AutomationDefinitionSectionProps {
definition: AutomationDefinition;
}
/**
* The Definition card. Read view; editing happens on the sibling /edit
* route (Edit button in the header). Layout is top-down:
* goal tags execution defaults inputs schema (if any) plan
*
* The schema_version is rendered as a small badge next to the section
* title so it's discoverable but doesn't fight for attention.
*/
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
const hasTags = definition.metadata.tags.length > 0;
const hasInputs = !!definition.inputs;
return (
<Card className="border-border/60 bg-accent">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-base font-semibold">Definition</CardTitle>
<span className="text-xs font-mono text-muted-foreground border border-border/60 rounded px-1.5 py-0.5">
v{definition.schema_version}
</span>
</CardHeader>
<CardContent className="space-y-6">
{definition.goal && (
<Field icon={Target} label="Goal">
<p className="text-sm text-foreground">{definition.goal}</p>
</Field>
)}
{hasTags && (
<Field icon={Tag} label="Tags">
<div className="flex flex-wrap gap-1.5">
{definition.metadata.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
</div>
</Field>
)}
<Field icon={Settings2} label="Execution defaults">
<ExecutionSummary execution={definition.execution} />
</Field>
{hasInputs && (
<Field icon={Settings2} label="Inputs schema">
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
</Field>
)}
<Field
icon={ListOrdered}
label={`Plan · ${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`}
>
<div className="space-y-2">
{definition.plan.map((step, idx) => (
<PlanStepCard key={step.step_id} step={step} index={idx} />
))}
</div>
</Field>
</CardContent>
</Card>
);
}
function Field({
icon: Icon,
label,
children,
}: {
icon: typeof Target;
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Icon className="h-3.5 w-3.5" aria-hidden />
{label}
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Pause, Pencil, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { Automation } from "@/contracts/types/automation.types";
import { AutomationStatusBadge } from "../../components/automation-status-badge";
import { DeleteAutomationDialog } from "../../components/delete-automation-dialog";
interface AutomationDetailHeaderProps {
automation: Automation;
searchSpaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Title bar for the detail page: back link, name, status badge,
* description, and the two destructive-ish primary actions (pause /
* resume + delete). Same mutation atoms as the list-row actions to
* keep caches coherent.
*
* Archived automations hide the pause/resume toggle (we don't unarchive
* here that flow comes later if we need it).
*/
export function AutomationDetailHeader({
automation,
searchSpaceId,
canUpdate,
canDelete,
}: AutomationDetailHeaderProps) {
const router = useRouter();
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
updateAutomationMutationAtom
);
const [deleteOpen, setDeleteOpen] = useState(false);
const canToggle = canUpdate && automation.status !== "archived";
const nextStatus = automation.status === "active" ? "paused" : "active";
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
const PauseIcon = automation.status === "active" ? Pause : Play;
const handleDeleted = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/automations`);
}, [router, searchSpaceId]);
async function handleTogglePause() {
await updateAutomation({
automationId: automation.id,
patch: { status: nextStatus },
});
}
return (
<>
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link
href={`/dashboard/${searchSpaceId}/automations`}
className="text-xs text-muted-foreground"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automations
</Link>
</Button>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
{automation.name}
</h1>
<AutomationStatusBadge status={automation.status} />
</div>
{automation.description && (
<p className="text-sm text-muted-foreground max-w-3xl">{automation.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{canUpdate && (
<Button asChild type="button" variant="outline" size="sm">
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
)}
{canToggle && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleTogglePause}
disabled={updating}
>
{updating ? (
<Spinner size="xs" className="mr-2" />
) : (
<PauseIcon className="mr-2 h-4 w-4" />
)}
{pauseLabel}
</Button>
)}
{canDelete && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
)}
</div>
</div>
</div>
{canDelete && (
<DeleteAutomationDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
automationId={automation.id}
automationName={automation.name}
searchSpaceId={searchSpaceId}
onDeleted={handleDeleted}
/>
)}
</>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton for the detail page. Mirrors the loaded view's main/sidebar
* grid (Definition + Runs on the left, Triggers on the right) so layout
* doesn't reflow when data arrives.
*/
export function AutomationDetailLoading() {
return (
<>
<div className="space-y-3">
<Skeleton className="h-4 w-32" />
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-64" />
<Skeleton className="h-5 w-16 rounded-md" />
</div>
<Skeleton className="h-4 w-96" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 min-w-0 lg:col-span-2">
<Card className="border-border/60 bg-accent">
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
<Card className="border-border/60 bg-accent">
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
<div className="space-y-6 min-w-0">
<Card className="border-border/60 bg-accent">
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { ArrowLeft, FileWarning } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationNotFoundProps {
searchSpaceId: number;
error?: Error | null;
}
/**
* Rendered when the detail fetch fails (404 / 403 / network) or the id
* is not a number. We don't distinguish "missing" from "forbidden" in the
* UI on purpose leaking that an id exists you can't read is worse than
* a vague message.
*/
export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<FileWarning className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Automation not found</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
This automation doesn't exist or you don't have access to it.
{error?.message ? ` (${error.message})` : null}
</p>
<Button asChild variant="outline" size="sm" className="mt-6">
<Link href={`/dashboard/${searchSpaceId}/automations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to automations
</Link>
</Button>
</div>
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { History } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAutomationRuns } from "@/hooks/use-automation-runs";
import { RunRow } from "./run-row";
import { RunsLoading } from "./runs-loading";
interface AutomationRunsSectionProps {
automationId: number;
}
const LIMIT = 20;
/**
* Run history card. Shows the most recent ``LIMIT`` runs; pagination is
* intentionally deferred for the foreseeable v1 surface (one-trigger
* automations firing daily), 20 covers ~3 weeks of history which is
* enough to tell whether things are working. Real "load more" lands if
* we see usage spike past that.
*/
export function AutomationRunsSection({ automationId }: AutomationRunsSectionProps) {
const { data, isLoading, error } = useAutomationRuns(automationId, { limit: LIMIT });
const runs = data?.items ?? [];
return (
<Card className="border-border/60 bg-accent">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="space-y-1">
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
Recent runs
</CardTitle>
<p className="text-xs text-muted-foreground">
Most recent first. Click a row to inspect step results, output and artifacts.
</p>
</div>
{!isLoading && !error && data && (
<span className="text-xs text-muted-foreground">{data.total} total</span>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<RunsLoading />
) : error ? (
<p className="text-sm text-muted-foreground">
Couldn't load runs{error.message ? `: ${error.message}` : "."}
</p>
) : runs.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm font-medium text-foreground">No runs yet</p>
<p className="mt-1 text-xs text-muted-foreground">
This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs
will appear here.
</p>
</div>
) : (
<div className="space-y-2">
{runs.map((run) => (
<RunRow key={run.id} run={run} automationId={automationId} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { CalendarClock } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Trigger } from "@/contracts/types/automation.types";
import { TriggerCard } from "./trigger-card";
interface AutomationTriggersSectionProps {
triggers: Trigger[];
automationId: number;
canUpdate: boolean;
canDelete: boolean;
}
/**
* The Triggers card. Lists each attached trigger with its own enable
* toggle and remove button. v1 attaches triggers at automation-creation
* time only; there is no in-place "add trigger" affordance here.
*/
export function AutomationTriggersSection({
triggers,
automationId,
canUpdate,
canDelete,
}: AutomationTriggersSectionProps) {
return (
<Card className="border-border/60 bg-accent">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
<p className="text-xs text-muted-foreground">
When this automation fires. v1 supports scheduled triggers only.
</p>
</CardHeader>
<CardContent>
{triggers.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
<CalendarClock className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mt-2 text-sm font-medium text-foreground">No triggers attached</p>
<p className="mt-1 text-xs text-muted-foreground">
This automation can still be invoked, but nothing will fire it on its own.
</p>
</div>
) : (
<div className="space-y-3">
{triggers.map((trigger) => (
<TriggerCard
key={trigger.id}
trigger={trigger}
automationId={automationId}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { removeTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
interface DeleteTriggerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
automationId: number;
triggerId: number;
triggerLabel: string;
}
/**
* Confirm + detach one trigger from its automation. The automation itself
* is untouched; only this trigger row is removed. The mutation atom
* invalidates the parent automation detail so the page rerenders.
*/
export function DeleteTriggerDialog({
open,
onOpenChange,
automationId,
triggerId,
triggerLabel,
}: DeleteTriggerDialogProps) {
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
try {
await removeTrigger({ automationId, triggerId });
onOpenChange(false);
} finally {
setSubmitting(false);
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this trigger?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium text-foreground">{triggerLabel}</span> will be detached.
The automation itself stays, but it won't fire on this trigger anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={submitting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner size="xs" />
Removing
</span>
) : (
"Remove"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import type { Execution } from "@/contracts/types/automation.types";
interface ExecutionSummaryProps {
execution: Execution;
}
/**
* Compact view of an automation's execution defaults (wall-clock cap,
* retries, backoff, concurrency, on_failure presence). Per-step overrides
* are shown inside each PlanStepCard, not here.
*/
export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
return (
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
<Item label="Max retries" value={String(execution.max_retries)} />
<Item label="Retry backoff" value={execution.retry_backoff} />
<Item label="Concurrency" value={execution.concurrency} />
{execution.on_failure.length > 0 && (
<Item
label="On failure"
value={`${execution.on_failure.length} step${execution.on_failure.length === 1 ? "" : "s"}`}
/>
)}
</dl>
);
}
function Item({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5 min-w-0">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground font-medium truncate">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,21 @@
"use client";
import { JsonView } from "@/components/json-view";
import type { Inputs } from "@/contracts/types/automation.types";
interface InputsSchemaPreviewProps {
inputs: Inputs;
}
/**
* Read-only preview of an automation's accepted-inputs schema. Most
* automations don't define inputs (defaults are baked into the trigger's
* static_inputs), so the parent skips rendering this card when ``inputs``
* is null.
*/
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-72 overflow-auto">
<JsonView src={inputs.schema} collapsed={2} />
</div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
import { JsonView } from "@/components/json-view";
import type { PlanStep } from "@/contracts/types/automation.types";
interface PlanStepCardProps {
step: PlanStep;
index: number;
}
/**
* Read-only view of one plan step. Renders the step_id + action prominently,
* then a definition list of the per-step knobs, and finally the params as
* formatted JSON. Editable mode is out of scope here definition edits live
* on the (future) raw-JSON path.
*/
export function PlanStepCard({ step, index }: PlanStepCardProps) {
return (
<div className="rounded-md border border-border/60 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/60 bg-muted/30">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<span className="text-sm font-medium text-foreground">{step.step_id}</span>
<ArrowRightCircle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
<span className="text-xs font-mono text-muted-foreground">{step.action}</span>
</div>
<div className="px-4 py-3 space-y-3">
{(step.when ||
step.output_as ||
step.max_retries != null ||
step.timeout_seconds != null) && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
{step.when && (
<DefRow label="When" value={<code className="font-mono">{step.when}</code>} />
)}
{step.output_as && (
<DefRow
label="Output as"
value={<code className="font-mono">{step.output_as}</code>}
/>
)}
{step.max_retries != null && (
<DefRow label="Max retries" value={String(step.max_retries)} />
)}
{step.timeout_seconds != null && (
<DefRow label="Timeout" value={`${step.timeout_seconds}s`} />
)}
</dl>
)}
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1.5">
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden />
Params
</div>
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
<JsonView src={step.params} collapsed={1} />
</div>
</div>
</div>
</div>
);
}
function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline gap-2 min-w-0">
<dt className="text-muted-foreground shrink-0">{label}:</dt>
<dd className="text-foreground min-w-0 truncate">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react";
import { JsonView } from "@/components/json-view";
import { Skeleton } from "@/components/ui/skeleton";
import { useAutomationRun } from "@/hooks/use-automation-runs";
interface RunDetailsPanelProps {
automationId: number;
runId: number;
}
/**
* Expanded view of a single run. Fetches lazily the parent only renders
* this once the row is opened, so the list view stays cheap.
*
* We surface the four most actionable sections (error first when present,
* then output, step results, artifacts, inputs). The full
* ``definition_snapshot`` is omitted because it usually mirrors the live
* definition surfacing it would dominate the panel without informing
* what the user is trying to learn ("did this work? what did it do?").
*/
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
const { data: run, isLoading, error } = useAutomationRun(automationId, runId);
if (isLoading) {
return (
<div className="space-y-3 p-4 bg-muted/20 border-t border-border/60">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-24 w-full" />
</div>
);
}
if (error || !run) {
return (
<div className="p-4 bg-muted/20 border-t border-border/60 text-xs text-muted-foreground">
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
</div>
);
}
const hasError = run.error && Object.keys(run.error).length > 0;
const hasOutput = run.output && Object.keys(run.output).length > 0;
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
return (
<div className="space-y-4 p-4 bg-muted/20 border-t border-border/60">
{hasError && (
<Section icon={AlertCircle} label="Error" tone="destructive">
<JsonBlock value={run.error} />
</Section>
)}
{hasOutput && (
<Section icon={FileOutput} label="Output">
<JsonBlock value={run.output} />
</Section>
)}
<Section icon={GitCommitHorizontal} label={`Step results · ${run.step_results.length}`}>
{run.step_results.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps recorded.</p>
) : (
<JsonBlock value={run.step_results} />
)}
</Section>
{run.artifacts.length > 0 && (
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
<JsonBlock value={run.artifacts} />
</Section>
)}
{hasInputs && (
<Section icon={Settings2} label="Resolved inputs">
<JsonBlock value={run.inputs} />
</Section>
)}
</div>
);
}
function Section({
icon: Icon,
label,
tone = "default",
children,
}: {
icon: typeof AlertCircle;
label: string;
tone?: "default" | "destructive";
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div
className={
tone === "destructive"
? "flex items-center gap-1.5 text-[11px] font-medium text-destructive uppercase tracking-wider"
: "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>
);
}
function JsonBlock({ value }: { value: unknown }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-64 overflow-auto">
<JsonView src={value} collapsed={1} />
</div>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
import { useState } from "react";
import type { RunSummary } from "@/contracts/types/automation.types";
import { formatRelativeDate } from "@/lib/format-date";
import { RunDetailsPanel } from "./run-details-panel";
import { RunStatusBadge } from "./run-status-badge";
interface RunRowProps {
run: RunSummary;
automationId: number;
}
/**
* One run row. Click to expand fetches the full run and shows the
* details panel inline. State is local to each row so multiple panels
* can be open at once (or none).
*/
export function RunRow({ run, automationId }: RunRowProps) {
const [open, setOpen] = useState(false);
const duration = computeDuration(run.started_at, run.finished_at);
const startedLabel = run.started_at
? formatRelativeDate(run.started_at)
: formatRelativeDate(run.created_at);
return (
<div className="rounded-md border border-border/60 overflow-hidden">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3 min-w-0">
{open ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
)}
<RunStatusBadge status={run.status} />
<span className="text-xs text-muted-foreground truncate">{startedLabel}</span>
</div>
<div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
{duration && <span className="font-mono">{duration}</span>}
<TriggerSource triggerId={run.trigger_id ?? null} />
</div>
</button>
{open && <RunDetailsPanel automationId={automationId} runId={run.id} />}
</div>
);
}
function TriggerSource({ triggerId }: { triggerId: number | null }) {
if (triggerId == null) {
return (
<span className="inline-flex items-center gap-1">
<Hand className="h-3 w-3" aria-hidden />
Manual
</span>
);
}
return <span>via trigger #{triggerId}</span>;
}
function computeDuration(started: string | null | undefined, finished: string | null | undefined) {
if (!started || !finished) return null;
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}

View file

@ -0,0 +1,57 @@
"use client";
import { AlertCircle, CheckCircle2, Clock, Loader2, TimerOff, XCircle } from "lucide-react";
import type { RunStatus } from "@/contracts/types/automation.types";
import { cn } from "@/lib/utils";
const STATUS_STYLES: Record<
RunStatus,
{ label: string; icon: typeof CheckCircle2; classes: string; spin?: boolean }
> = {
pending: {
label: "Pending",
icon: Clock,
classes: "bg-muted text-muted-foreground border-border/60",
},
running: {
label: "Running",
icon: Loader2,
classes: "bg-blue-500/10 text-blue-600 border-blue-500/20",
spin: true,
},
succeeded: {
label: "Succeeded",
icon: CheckCircle2,
classes: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
},
failed: {
label: "Failed",
icon: XCircle,
classes: "bg-destructive/10 text-destructive border-destructive/20",
},
cancelled: {
label: "Cancelled",
icon: AlertCircle,
classes: "bg-muted text-muted-foreground border-border/60",
},
timed_out: {
label: "Timed out",
icon: TimerOff,
classes: "bg-amber-500/10 text-amber-600 border-amber-500/20",
},
};
export function RunStatusBadge({ status, className }: { status: RunStatus; className?: string }) {
const { label, icon: Icon, classes, spin } = STATUS_STYLES[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
classes,
className
)}
>
<Icon className={cn("h-3 w-3", spin && "animate-spin")} aria-hidden />
{label}
</span>
);
}

View file

@ -0,0 +1,23 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
const ROW_KEYS = ["a", "b", "c"] as const;
export function RunsLoading() {
return (
<div className="space-y-2">
{ROW_KEYS.map((key) => (
<div
key={key}
className="flex items-center justify-between gap-4 rounded-md border border-border/60 px-4 py-3"
>
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}

View file

@ -0,0 +1,274 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react";
import { useState } from "react";
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
import { describeCron } from "@/lib/automations/describe-cron";
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
interface TriggerCardProps {
trigger: Trigger;
automationId: number;
canUpdate: boolean;
canDelete: boolean;
}
interface TriggerDraft {
params: Record<string, unknown>;
static_inputs: Record<string, unknown>;
}
function draftFromTrigger(trigger: Trigger): TriggerDraft {
return {
params: trigger.params,
static_inputs: trigger.static_inputs ?? {},
};
}
/**
* One trigger row in the Triggers section of the detail page. Renders:
* - type icon + human-readable schedule + timezone
* - last_fired_at / next_fire_at hints
* - static_inputs as formatted JSON (when present)
* - enable toggle + remove button + inline edit (each gated independently)
*
* Inline edit covers ``params`` and ``static_inputs`` the two fields the
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
* ``enabled`` stays on the Switch so the two surfaces don't fight.
*/
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
const { mutateAsync: updateTrigger, isPending: updating } =
useAtomValue(updateTriggerMutationAtom);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<TriggerDraft>(() => draftFromTrigger(trigger));
const [issues, setIssues] = useState<string[]>([]);
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
const human = cron ? describeCron(cron) : trigger.type;
const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
async function handleToggle(checked: boolean) {
await updateTrigger({
automationId,
triggerId: trigger.id,
patch: { enabled: checked },
});
}
function startEdit() {
setDraft(draftFromTrigger(trigger));
setIssues([]);
setIsEditing(true);
}
function cancelEdit() {
setIsEditing(false);
setIssues([]);
}
async function saveEdit() {
setIssues([]);
const result = triggerUpdateRequest.safeParse(draft);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
);
return;
}
try {
await updateTrigger({
automationId,
triggerId: trigger.id,
patch: result.data,
});
setIsEditing(false);
} catch (err) {
setIssues([(err as Error).message ?? "Update failed"]);
}
}
return (
<>
<div className="rounded-md border border-border/60 overflow-hidden">
<div className="flex items-center justify-between gap-4 px-4 py-3 border-b border-border/60">
<div className="flex items-center gap-3 min-w-0">
<CalendarClock className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">{human}</span>
<span className="text-muted-foreground">· {tz}</span>
</div>
{cron && <code className="text-xs font-mono text-muted-foreground">{cron}</code>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{canUpdate && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{trigger.enabled ? "Enabled" : "Off"}
</span>
<Switch
checked={trigger.enabled}
onCheckedChange={handleToggle}
disabled={updating || isEditing}
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
/>
</div>
)}
{canUpdate && !isEditing && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={startEdit}
aria-label="Edit trigger"
>
<Pencil className="h-4 w-4" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={isEditing}
aria-label="Remove trigger"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="px-4 py-3 space-y-3 text-xs">
{isEditing ? (
<>
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
<JsonView
src={draft}
editable
onChange={(next) => setDraft(next as TriggerDraft)}
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 font-medium text-destructive mb-1">
<AlertCircle className="h-3 w-3" aria-hidden />
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
</div>
<ul className="space-y-0.5 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"
variant="ghost"
size="sm"
onClick={cancelEdit}
disabled={updating}
>
Cancel
</Button>
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
{updating ? (
<Spinner size="xs" className="mr-1.5" />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</>
) : (
<>
{(trigger.last_fired_at || trigger.next_fire_at) && (
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
{trigger.next_fire_at && (
<TimeRow
label="Next fire"
iso={trigger.next_fire_at}
tense="future"
highlight={trigger.enabled}
/>
)}
{trigger.last_fired_at && (
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
)}
</dl>
)}
{hasStaticInputs && (
<div>
<div className="text-muted-foreground mb-1">Static inputs</div>
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
<JsonView src={trigger.static_inputs} collapsed={1} />
</div>
</div>
)}
</>
)}
</div>
</div>
{canDelete && (
<DeleteTriggerDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
automationId={automationId}
triggerId={trigger.id}
triggerLabel={triggerLabel}
/>
)}
</>
);
}
function TimeRow({
label,
iso,
tense,
highlight = false,
}: {
label: string;
iso: string;
tense: "past" | "future";
highlight?: boolean;
}) {
const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso);
return (
<>
<dt className="text-muted-foreground inline-flex items-center gap-1.5 whitespace-nowrap">
<Clock className="h-3 w-3" aria-hidden />
{label}
</dt>
<dd
className={
highlight
? "text-foreground font-medium min-w-0 truncate"
: "text-muted-foreground min-w-0 truncate"
}
title={new Date(iso).toLocaleString()}
>
{formatted}
</dd>
</>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomation } from "@/hooks/use-automation";
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";
interface AutomationEditContentProps {
searchSpaceId: number;
automationId: number;
}
/**
* Client orchestrator for the edit route. Mirrors detail-content's branch
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who
* can read but not update is bounced to the access-denied panel.
*/
export function AutomationEditContent({
searchSpaceId,
automationId,
}: AutomationEditContentProps) {
const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
if (perms.loading) {
return <AutomationDetailLoading />;
}
if (!perms.canUpdate) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to edit automations in this search space.
</p>
</div>
);
}
if (!validId) {
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
}
if (isLoading) {
return <AutomationDetailLoading />;
}
if (error || !automation) {
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
}
return <AutomationEditForm automation={automation} searchSpaceId={searchSpaceId} />;
}

View file

@ -0,0 +1,121 @@
"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,18 @@
import { AutomationEditContent } from "./automation-edit-content";
export default async function AutomationEditPage({
params,
}: {
params: Promise<{ search_space_id: string; automation_id: string }>;
}) {
const { search_space_id, automation_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationEditContent
searchSpaceId={Number(search_space_id)}
automationId={Number(automation_id)}
/>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { AutomationDetailContent } from "./automation-detail-content";
export default async function AutomationDetailPage({
params,
}: {
params: Promise<{ search_space_id: string; automation_id: string }>;
}) {
const { search_space_id, automation_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationDetailContent
searchSpaceId={Number(search_space_id)}
automationId={Number(automation_id)}
/>
</div>
);
}

View file

@ -0,0 +1,102 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomations } from "@/hooks/use-automations";
import { AutomationsEmptyState } from "./components/automations-empty-state";
import { AutomationsHeader } from "./components/automations-header";
import { AutomationsTable } from "./components/automations-table";
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
interface AutomationsContentProps {
searchSpaceId: number;
}
/**
* Client orchestrator for the automations list page. Pulls the active
* search space's first page (via ``useAutomations`` ``automationsListAtom``)
* and the user's permissions, then decides between empty / loading / table.
*
* Read access is mandatory; anything else is hidden behind RBAC. The
* permissions hook is co-located in this slice so adding/removing
* surfaces is a one-file change.
*/
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
if (perms.loading) {
// Permissions gate the entire page; defer everything until we know.
return (
<>
<AutomationsHeader searchSpaceId={searchSpaceId} total={0} loading canCreate={false} />
<AutomationsTable
automations={[]}
searchSpaceId={searchSpaceId}
loading
canUpdate={false}
canDelete={false}
/>
</>
);
}
if (!perms.canRead) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
</p>
</div>
);
}
if (error) {
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
total={0}
loading={false}
canCreate={perms.canCreate}
/>
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center">
<p className="text-sm text-destructive">Couldn't load automations. {error.message}</p>
</div>
</>
);
}
if (!loading && automations.length === 0) {
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
total={0}
loading={false}
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
</>
);
}
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
total={total}
loading={loading}
canCreate={perms.canCreate}
/>
<AutomationsTable
automations={automations}
searchSpaceId={searchSpaceId}
loading={loading}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}
/>
</>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import { useAtomValue } from "jotai";
import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
import { useState } from "react";
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { AutomationSummary } from "@/contracts/types/automation.types";
import { DeleteAutomationDialog } from "./delete-automation-dialog";
interface AutomationRowActionsProps {
automation: AutomationSummary;
searchSpaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Three-dot menu on each row: pause/resume (if updatable) and delete
* (if deletable). The menu itself is hidden when the user has neither
* permission so we don't render an empty trigger.
*/
export function AutomationRowActions({
automation,
searchSpaceId,
canUpdate,
canDelete,
}: AutomationRowActionsProps) {
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
updateAutomationMutationAtom
);
const [deleteOpen, setDeleteOpen] = useState(false);
if (!canUpdate && !canDelete) return null;
const nextStatus = automation.status === "active" ? "paused" : "active";
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
const PauseIcon = automation.status === "active" ? Pause : Play;
const canToggle = canUpdate && automation.status !== "archived";
async function handleTogglePause() {
await updateAutomation({
automationId: automation.id,
patch: { status: nextStatus },
});
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={`Actions for ${automation.name}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{canToggle && (
<DropdownMenuItem onSelect={handleTogglePause} disabled={updating}>
<PauseIcon className="mr-2 h-4 w-4" />
{pauseLabel}
</DropdownMenuItem>
)}
{canToggle && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
onSelect={() => setDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{canDelete && (
<DeleteAutomationDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
automationId={automation.id}
automationName={automation.name}
searchSpaceId={searchSpaceId}
/>
)}
</>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { TableCell, TableRow } from "@/components/ui/table";
import type { AutomationSummary } from "@/contracts/types/automation.types";
import { formatRelativeDate } from "@/lib/format-date";
import { AutomationRowActions } from "./automation-row-actions";
import { AutomationStatusBadge } from "./automation-status-badge";
interface AutomationRowProps {
automation: AutomationSummary;
searchSpaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
/**
* One row in the automations table. The name links to the detail page;
* actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary
* is intentionally left to the detail page list responses don't
* include triggers and we want to avoid N+1 detail fetches.
*/
export function AutomationRow({
automation,
searchSpaceId,
canUpdate,
canDelete,
}: AutomationRowProps) {
return (
<TableRow className="border-b border-border/60 hover:bg-muted/40">
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
<div className="flex flex-col gap-0.5 min-w-0">
<Link
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
className="text-sm font-medium text-foreground hover:underline truncate"
>
{automation.name}
</Link>
{automation.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{automation.description}
</span>
)}
</div>
</TableCell>
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
<AutomationStatusBadge status={automation.status} />
</TableCell>
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40 text-xs text-muted-foreground">
{formatRelativeDate(automation.updated_at)}
</TableCell>
<TableCell className="px-4 md:px-6 py-3 w-16 text-right">
<AutomationRowActions
automation={automation}
searchSpaceId={searchSpaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</TableCell>
</TableRow>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { Archive, CircleDot, Pause } from "lucide-react";
import type { AutomationStatus } from "@/contracts/types/automation.types";
import { cn } from "@/lib/utils";
interface AutomationStatusBadgeProps {
status: AutomationStatus;
className?: string;
}
// Color + icon per status. Active = green, paused = amber, archived = muted.
const STATUS_STYLES: Record<
AutomationStatus,
{ label: string; icon: typeof CircleDot; classes: string }
> = {
active: {
label: "Active",
icon: CircleDot,
classes:
"bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50",
},
paused: {
label: "Paused",
icon: Pause,
classes:
"bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50",
},
archived: {
label: "Archived",
icon: Archive,
classes: "bg-muted text-muted-foreground border border-border/60",
},
};
export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
const { label, icon: Icon, classes } = STATUS_STYLES[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium",
classes,
className
)}
>
<Icon className="h-3 w-3" aria-hidden />
{label}
</span>
);
}

View file

@ -0,0 +1,52 @@
"use client";
import { CalendarClock, Pause } from "lucide-react";
import type { Trigger } from "@/contracts/types/automation.types";
import { describeCron } from "@/lib/automations/describe-cron";
interface AutomationTriggersSummaryProps {
triggers: Trigger[];
}
/**
* One-line summary of an automation's triggers for the list view.
*
* v1 only registers ``schedule`` so this stays compact:
* - 0 triggers "No triggers"
* - 1 schedule trigger "MonFri at 09:00 · UTC" + disabled badge if off
* - >1 "N triggers"
*
* The detail page renders the full per-trigger editor.
*/
export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) {
if (triggers.length === 0) {
return <span className="text-xs text-muted-foreground">No triggers</span>;
}
if (triggers.length > 1) {
return <span className="text-xs text-muted-foreground">{triggers.length} triggers</span>;
}
const [trigger] = triggers;
if (trigger.type === "schedule") {
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
const human = cron ? describeCron(cron) : "Schedule";
return (
<span className="inline-flex items-center gap-1.5 text-xs">
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
<span className="text-foreground">{human}</span>
<span className="text-muted-foreground">· {tz}</span>
{!trigger.enabled && (
<span className="inline-flex items-center gap-1 rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<Pause className="h-2.5 w-2.5" aria-hidden />
Off
</span>
)}
</span>
);
}
return <span className="text-xs text-muted-foreground capitalize">{trigger.type}</span>;
}

View file

@ -0,0 +1,50 @@
"use client";
import { FileJson, MessageSquarePlus, Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsEmptyStateProps {
searchSpaceId: number;
canCreate: boolean;
}
/**
* Zero-state for the automations list. The primary CTA points to a new
* chat creation happens via the ``create_automation`` HITL tool, not a
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="h-6 w-6" aria-hidden />
</div>
<h3 className="mt-4 text-base font-semibold text-foreground">No automations yet</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and
SurfSense drafts the automation for your approval.
</p>
{canCreate ? (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<FileJson className="mr-2 h-4 w-4" />
Create via JSON
</Link>
</Button>
</div>
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { FileJson, MessageSquarePlus } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsHeaderProps {
searchSpaceId: number;
total: number;
loading: boolean;
canCreate: boolean;
/**
* Render the header's Create CTA. Defaults to true; the empty state owns
* the primary CTA on its own card, so the orchestrator turns this off
* there to avoid a duplicate button.
*/
showCreateCta?: boolean;
}
/**
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
* (the create_automation tool runs inside chat with a HITL approval card), so
* the CTA links to a new chat rather than opening a form.
*/
export function AutomationsHeader({
searchSpaceId,
total,
loading,
canCreate,
showCreateCta = true,
}: AutomationsHeaderProps) {
return (
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-baseline gap-3">
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Automations</h1>
{!loading && (
<span className="text-sm text-muted-foreground">
{total} {total === 1 ? "automation" : "automations"}
</span>
)}
</div>
{canCreate && showCreateCta && (
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
<FileJson className="mr-2 h-4 w-4" />
Create via JSON
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Create via chat
</Link>
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import { TableCell, TableRow } from "@/components/ui/table";
const ROW_KEYS = ["sk-1", "sk-2", "sk-3"];
/**
* Skeleton rows for the automations table. Number of rows is fixed since
* we don't know the count ahead of time and three placeholders is enough
* to communicate "loading" without flashing too much chrome.
*/
export function AutomationsLoadingRows() {
return (
<>
{ROW_KEYS.map((key) => (
<TableRow key={key} className="border-b border-border/60 hover:bg-transparent">
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
</TableCell>
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
<Skeleton className="h-5 w-16 rounded-md" />
</TableCell>
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40">
<Skeleton className="h-3 w-20" />
</TableCell>
<TableCell className="px-4 md:px-6 py-3 w-16">
<Skeleton className="h-8 w-8 rounded-md ml-auto" />
</TableCell>
</TableRow>
))}
</>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { Activity, CalendarDays, Workflow } from "lucide-react";
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AutomationSummary } from "@/contracts/types/automation.types";
import { AutomationRow } from "./automation-row";
import { AutomationsLoadingRows } from "./automations-loading";
interface AutomationsTableProps {
automations: AutomationSummary[];
searchSpaceId: number;
loading: boolean;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Table shell + header. Rows render below loading state renders skeleton
* rows in the same shell so the layout doesn't shift on data arrival.
*/
export function AutomationsTable({
automations,
searchSpaceId,
loading,
canUpdate,
canDelete,
}: AutomationsTableProps) {
return (
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/60">
<TableHead className="px-4 md:px-6 border-r border-border/60">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<Workflow size={14} className="opacity-60 text-muted-foreground" />
Name
</span>
</TableHead>
<TableHead className="border-r border-border/60 w-32">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<Activity size={14} className="opacity-60 text-muted-foreground" />
Status
</span>
</TableHead>
<TableHead className="hidden md:table-cell border-r border-border/60 w-40">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<CalendarDays size={14} className="opacity-60 text-muted-foreground" />
Updated
</span>
</TableHead>
<TableHead className="px-4 md:px-6 w-16">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<AutomationsLoadingRows />
) : (
automations.map((automation) => (
<AutomationRow
key={automation.id}
automation={automation}
searchSpaceId={searchSpaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { deleteAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
interface DeleteAutomationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
automationId: number;
automationName: string;
searchSpaceId: number;
/**
* Fired after a successful delete, before the dialog closes. The detail
* page uses this to navigate back to the list (the row simply vanishes
* on the list page so no callback is needed there).
*/
onDeleted?: () => void;
}
/**
* Confirm + delete one automation. FK cascade on the backend wipes attached
* triggers and runs, so we mention it explicitly. List re-fetch is handled
* by the mutation atom's onSuccess.
*/
export function DeleteAutomationDialog({
open,
onOpenChange,
automationId,
automationName,
searchSpaceId,
onDeleted,
}: DeleteAutomationDialogProps) {
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
try {
await deleteAutomation({ automationId, searchSpaceId });
onDeleted?.();
onOpenChange(false);
} finally {
setSubmitting(false);
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this automation?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium text-foreground">{automationName}</span> and all of its
triggers and run history will be removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={submitting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<Spinner size="xs" />
Deleting
</span>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms";
/**
* Centralized RBAC gates for the automations slice. Co-located with the
* route so adding/removing surfaces stays a one-file change. Backed by
* the same ``myAccessAtom`` the rest of the app uses; owners short-circuit
* to ``true`` for every action.
*
* Mirrors backend permissions in ``app.db.permissions`` (automations:*).
*/
export interface AutomationPermissions {
loading: boolean;
canCreate: boolean;
canRead: boolean;
canUpdate: boolean;
canDelete: boolean;
canExecute: boolean;
}
export function useAutomationPermissions(): AutomationPermissions {
const { data: access, isLoading } = useAtomValue(myAccessAtom);
return useMemo(
() => ({
loading: isLoading,
canCreate: canPerform(access, "automations:create"),
canRead: canPerform(access, "automations:read"),
canUpdate: canPerform(access, "automations:update"),
canDelete: canPerform(access, "automations:delete"),
canExecute: canPerform(access, "automations:execute"),
}),
[access, isLoading]
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationJsonForm } from "./components/automation-json-form";
import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps {
searchSpaceId: number;
}
/**
* 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.
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
if (perms.loading) {
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
}
if (!perms.canCreate) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to create automations in this search space.
</p>
</div>
);
}
return (
<>
<AutomationNewHeader searchSpaceId={searchSpaceId} />
<AutomationJsonForm searchSpaceId={searchSpaceId} />
</>
);
}

View file

@ -0,0 +1,98 @@
"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

@ -0,0 +1,42 @@
"use client";
import { ArrowLeft, MessageSquarePlus } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationNewHeaderProps {
searchSpaceId: number;
}
export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps) {
return (
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link
href={`/dashboard/${searchSpaceId}/automations`}
className="text-xs text-muted-foreground"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
Back to automations
</Link>
</Button>
<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>
<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.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
<MessageSquarePlus className="mr-2 h-4 w-4" />
Switch to chat
</Link>
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import { AutomationNewContent } from "./automation-new-content";
export default async function NewAutomationPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationNewContent searchSpaceId={Number(search_space_id)} />
</div>
);
}

View file

@ -0,0 +1,15 @@
import { AutomationsContent } from "./automations-content";
export default async function AutomationsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationsContent searchSpaceId={Number(search_space_id)} />
</div>
);
}

View file

@ -0,0 +1,127 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
AutomationCreateRequest,
AutomationUpdateRequest,
TriggerCreateRequest,
TriggerUpdateRequest,
} from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
// Cache invalidation strategy:
// - Automation writes invalidate the search-space list + the touched detail.
// - Trigger writes only invalidate the parent automation detail (triggers
// come back inline in AutomationDetail).
// We deliberately invalidate the whole "automations" prefix on the list side
// because list is keyed by (searchSpaceId, limit, offset) and we don't track
// the active pagination in this layer.
function invalidateList(searchSpaceId: number) {
queryClient.invalidateQueries({ queryKey: ["automations", "list", searchSpaceId] });
}
function invalidateDetail(automationId: number) {
queryClient.invalidateQueries({
queryKey: cacheKeys.automations.detail(automationId),
});
}
export const createAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request);
},
onSuccess: (_, variables) => {
invalidateList(variables.search_space_id);
toast.success("Automation created");
},
onError: (error: Error) => {
console.error("Error creating automation:", error);
toast.error("Failed to create automation");
},
}));
export const updateAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; patch: AutomationUpdateRequest }) => {
return automationsApiService.updateAutomation(vars.automationId, vars.patch);
},
onSuccess: (automation, vars) => {
invalidateDetail(vars.automationId);
invalidateList(automation.search_space_id);
toast.success("Automation updated");
},
onError: (error: Error) => {
console.error("Error updating automation:", error);
toast.error("Failed to update automation");
},
}));
export const deleteAutomationMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; searchSpaceId: number }) => {
await automationsApiService.deleteAutomation(vars.automationId);
return vars;
},
onSuccess: (vars) => {
invalidateList(vars.searchSpaceId);
invalidateDetail(vars.automationId);
toast.success("Automation deleted");
},
onError: (error: Error) => {
console.error("Error deleting automation:", error);
toast.error("Failed to delete automation");
},
}));
export const addTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload);
},
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger added");
},
onError: (error: Error) => {
console.error("Error adding trigger:", error);
toast.error("Failed to add trigger");
},
}));
export const updateTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: {
automationId: number;
triggerId: number;
patch: TriggerUpdateRequest;
}) => {
return automationsApiService.updateTrigger(vars.automationId, vars.triggerId, vars.patch);
},
onSuccess: (_, vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger updated");
},
onError: (error: Error) => {
console.error("Error updating trigger:", error);
toast.error("Failed to update trigger");
},
}));
export const removeTriggerMutationAtom = atomWithMutation(() => ({
meta: { suppressGlobalErrorToast: true },
mutationFn: async (vars: { automationId: number; triggerId: number }) => {
await automationsApiService.removeTrigger(vars.automationId, vars.triggerId);
return vars;
},
onSuccess: (vars) => {
invalidateDetail(vars.automationId);
toast.success("Trigger removed");
},
onError: (error: Error) => {
console.error("Error removing trigger:", error);
toast.error("Failed to remove trigger");
},
}));

View file

@ -0,0 +1,31 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// First page of the active search space's automations.
// Detail + paginated/parameterized reads live in hooks (see use-automation.ts,
// use-automation-runs.ts) so atoms stay tied to "current scope" and don't
// proliferate atom families for every (id, limit, offset) tuple.
const DEFAULT_LIMIT = 50;
const DEFAULT_OFFSET = 0;
export const automationsListAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.automations.list(Number(searchSpaceId ?? 0), DEFAULT_LIMIT, DEFAULT_OFFSET),
enabled: !!searchSpaceId,
staleTime: 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
return { items: [], total: 0 };
}
return automationsApiService.listAutomations({
search_space_id: Number(searchSpaceId),
limit: DEFAULT_LIMIT,
offset: DEFAULT_OFFSET,
});
},
};
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,427 @@
"use client";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { AlertCircle, CornerDownLeftIcon, ExternalLink, Pencil, Workflow } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { JsonView } from "@/components/json-view";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { automationCreateRequest } from "@/contracts/types/automation.types";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
import { AutomationDraftPreview } from "./automation-draft-preview";
const editArgsSchema = automationCreateRequest.omit({ search_space_id: true });
// ----------------------------------------------------------------------------
// Result discrimination — mirrors the backend return shapes in
// app/agents/multi_agent_chat/main_agent/tools/automation/create.py.
// ----------------------------------------------------------------------------
type AutomationCreateContext = {
search_space_id?: number;
};
interface SavedResult {
status: "saved";
automation_id: number;
name: string;
}
interface RejectedResult {
status: "rejected";
message?: string;
}
interface InvalidResult {
status: "invalid";
issues: string[];
raw?: unknown;
}
interface ErrorResult {
status: "error";
message: string;
}
type CreateAutomationResult =
| InterruptResult<AutomationCreateContext>
| SavedResult
| RejectedResult
| InvalidResult
| ErrorResult;
function hasStatus(value: unknown, status: string): boolean {
return (
typeof value === "object" &&
value !== null &&
"status" in value &&
(value as { status: unknown }).status === status
);
}
// ----------------------------------------------------------------------------
// Approval card — pending → processing → complete / rejected.
//
// Edit toggle reuses the same primitives as the Create-via-JSON page: raw
// textarea, Format, Zod validation against ``AutomationCreate`` (minus the
// ``search_space_id`` field, which the backend injects). Approve dispatches
// an ``edit`` decision with the parsed args when edits are pending, otherwise
// a plain ``approve``. Multi-turn chat refinement still works as a fallback.
// ----------------------------------------------------------------------------
interface ApprovalCardProps {
args: Record<string, unknown>;
interruptData: InterruptResult<AutomationCreateContext>;
onDecision: (decision: HitlDecision) => void;
}
function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const reviewConfig = interruptData.review_configs[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canApprove = allowedDecisions.includes("approve");
const canReject = allowedDecisions.includes("reject");
const canEdit = allowedDecisions.includes("edit");
const [pendingEdits, setPendingEdits] = useState<Record<string, unknown> | null>(null);
const [isEditing, setIsEditing] = useState(false);
const effectiveArgs = pendingEdits ?? args;
const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]);
const handleApprove = useCallback(() => {
if (phase !== "pending" || !canApprove || isEditing) return;
setProcessing();
onDecision({
type: pendingEdits ? "edit" : "approve",
edited_action: {
name: interruptData.action_requests[0]?.name ?? "create_automation",
args: pendingEdits ?? args,
},
});
}, [phase, canApprove, isEditing, setProcessing, onDecision, interruptData, args, pendingEdits]);
const handleReject = useCallback(() => {
if (phase !== "pending" || !canReject || isEditing) return;
setRejected();
onDecision({ type: "reject", message: "User rejected the automation draft." });
}, [phase, canReject, isEditing, setRejected, onDecision]);
useEffect(() => {
if (isEditing) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove, isEditing]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-4 select-none">
<div className="flex items-start gap-3 min-w-0">
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? "Automation cancelled"
: phase === "processing"
? "Saving automation"
: phase === "complete"
? "Automation saved"
: "Create automation"}
</p>
{phase === "processing" ? (
<TextShimmerLoader
text={pendingEdits ? "Saving with your edits" : "Saving automation"}
size="sm"
/>
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits
? "Automation saved with your edits"
: "Automation created from this draft"}
</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">
No automation was saved ask in chat to refine and try again.
</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
{pendingEdits
? "Showing your edits. Approve to save, or edit again."
: "Review and approve to save. Edit for fine-tuning, or reply in chat for a redraft."}
</p>
)}
</div>
</div>
{phase === "pending" && canEdit && !isEditing && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2 shrink-0"
onClick={() => setIsEditing(true)}
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
{isEditing ? (
<JsonEditor
initialValue={effectiveArgs}
onSave={(parsed) => {
setPendingEdits(parsed);
setIsEditing(false);
}}
onCancel={() => setIsEditing(false)}
/>
) : (
<AutomationDraftPreview draft={draft} raw={effectiveArgs} />
)}
</div>
{phase === "pending" && !isEditing && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{canApprove && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{canReject && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={handleReject}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
interface JsonEditorProps {
initialValue: Record<string, unknown>;
onSave: (parsed: Record<string, unknown>) => void;
onCancel: () => void;
}
function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
const [value, setValue] = useState<Record<string, unknown>>(initialValue);
const [issues, setIssues] = useState<string[]>([]);
function handleSave() {
setIssues([]);
const result = editArgsSchema.safeParse(value);
if (!result.success) {
setIssues(
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
);
return;
}
onSave(result.data as unknown as Record<string, unknown>);
}
return (
<div className="space-y-3">
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
<JsonView
src={value}
editable
onChange={(next) => setValue(next as Record<string, unknown>)}
collapsed={false}
/>
</div>
{issues.length > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
{issues.length} issue{issues.length === 1 ? "" : "s"}
</div>
<ul className="mt-1.5 space-y-0.5 text-xs text-destructive/90 list-disc list-inside">
{issues.map((issue) => (
<li key={issue} className="font-mono">
{issue}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleSave}>
Save edits
</Button>
</div>
</div>
);
}
// ----------------------------------------------------------------------------
// Terminal result cards.
// ----------------------------------------------------------------------------
function SavedCard({ result }: { result: SavedResult }) {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const detailHref = searchSpaceId
? `/dashboard/${searchSpaceId}/automations/${result.automation_id}`
: null;
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-4">
<Workflow className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" aria-hidden />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">Automation saved</p>
<p className="text-xs text-muted-foreground mt-0.5">{result.name}</p>
</div>
</div>
{detailHref && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3">
<Link
href={detailHref}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
Open automation #{result.automation_id}
</Link>
</div>
</>
)}
</div>
);
}
function InvalidCard({ result }: { result: InvalidResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Couldn't draft this automation</p>
<p className="text-xs text-muted-foreground mt-0.5">
The drafter produced output that didn't validate. I'll refine and retry.
</p>
</div>
{result.issues.length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<ul className="px-5 py-3 space-y-1 text-xs text-muted-foreground list-disc list-inside">
{result.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
</>
)}
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">Failed to create automation</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
// ----------------------------------------------------------------------------
// Entry — dispatches between the approval card and terminal result cards.
//
// Rejection is special: we hide the standalone "rejected" card because the
// approval card itself already transitions to a "rejected" phase inline. A
// second message in the timeline would be noisy.
// ----------------------------------------------------------------------------
export const CreateAutomationToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ intent: string }, CreateAutomationResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args as unknown as Record<string, unknown>}
interruptData={result as InterruptResult<AutomationCreateContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}
if (hasStatus(result, "rejected")) return null;
if (hasStatus(result, "saved")) return <SavedCard result={result as SavedResult} />;
if (hasStatus(result, "invalid")) return <InvalidCard result={result as InvalidResult} />;
if (hasStatus(result, "error")) return <ErrorCard result={result as ErrorResult} />;
return null;
};
// ----------------------------------------------------------------------------
// Helpers.
// ----------------------------------------------------------------------------
/**
* Project raw args into the shape ``AutomationDraftPreview`` expects.
*
* The args dict is the full ``AutomationCreate`` payload (minus
* ``search_space_id`` which is injected server-side), so we trust the
* top-level fields but defend against missing nested defaults.
*/
function extractDraft(args: Record<string, unknown>) {
const definition = (args.definition ?? {}) as Record<string, unknown>;
const planSteps = Array.isArray(definition.plan)
? (definition.plan as Array<Record<string, unknown>>).map((step) => ({
step_id: String(step.step_id ?? "(unnamed)"),
action: String(step.action ?? ""),
when: typeof step.when === "string" ? step.when : null,
}))
: [];
const triggers = Array.isArray(args.triggers)
? (args.triggers as Array<Record<string, unknown>>).map((trigger) => ({
type: String(trigger.type ?? "schedule"),
params: (trigger.params ?? {}) as Record<string, unknown>,
static_inputs: (trigger.static_inputs ?? {}) as Record<string, unknown>,
enabled: trigger.enabled !== false,
}))
: [];
return {
name: String(args.name ?? "(unnamed automation)"),
description: typeof args.description === "string" ? args.description : null,
definition: {
goal: typeof definition.goal === "string" ? definition.goal : null,
plan: planSteps,
},
triggers,
};
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,193 @@
import { z } from "zod";
// =============================================================================
// Enums — mirror app/automations/persistence/enums/*
// =============================================================================
export const automationStatus = z.enum(["active", "paused", "archived"]);
export type AutomationStatus = z.infer<typeof automationStatus>;
export const triggerType = z.enum(["schedule", "manual"]);
export type TriggerType = z.infer<typeof triggerType>;
export const runStatus = z.enum([
"pending",
"running",
"succeeded",
"failed",
"cancelled",
"timed_out",
]);
export type RunStatus = z.infer<typeof runStatus>;
// =============================================================================
// Definition envelope — mirror app/automations/schemas/definition/*
// =============================================================================
export const planStep = z.object({
step_id: z.string().min(1),
action: z.string().min(1),
when: z.string().nullable().optional(),
params: z.record(z.string(), z.any()).default({}),
output_as: z.string().nullable().optional(),
max_retries: z.number().int().min(0).nullable().optional(),
timeout_seconds: z.number().int().positive().nullable().optional(),
});
export type PlanStep = z.infer<typeof planStep>;
export const definitionTriggerSpec = z.object({
type: z.string().min(1),
params: z.record(z.string(), z.any()).default({}),
});
export type DefinitionTriggerSpec = z.infer<typeof definitionTriggerSpec>;
export const execution = z.object({
timeout_seconds: z.number().int().positive().default(600),
max_retries: z.number().int().min(0).default(2),
retry_backoff: z.enum(["exponential", "linear", "none"]).default("exponential"),
concurrency: z.enum(["drop_if_running", "queue", "always"]).default("drop_if_running"),
on_failure: z.array(planStep).default([]),
});
export type Execution = z.infer<typeof execution>;
// Backend ``Metadata`` is ``extra="allow"`` — keep ``tags`` typed, accept arbitrary keys.
export const metadata = z.object({ tags: z.array(z.string()).default([]) }).catchall(z.any());
export type Metadata = z.infer<typeof metadata>;
// Backend ``Inputs`` serializes its ``schema_`` field as ``schema`` (alias).
export const inputs = z.object({
schema: z.record(z.string(), z.any()),
});
export type Inputs = z.infer<typeof inputs>;
export const automationDefinition = z.object({
schema_version: z.string().default("1.0"),
name: z.string().min(1).max(200),
goal: z.string().nullable().optional(),
inputs: inputs.nullable().optional(),
triggers: z.array(definitionTriggerSpec).default([]),
plan: z.array(planStep).min(1),
execution: execution.default(execution.parse({})),
metadata: metadata.default(metadata.parse({})),
});
export type AutomationDefinition = z.infer<typeof automationDefinition>;
// =============================================================================
// Triggers (sub-resource) — mirror app/automations/schemas/api/trigger.py
// =============================================================================
export const triggerCreateRequest = z.object({
type: triggerType,
params: z.record(z.string(), z.any()).default({}),
static_inputs: z.record(z.string(), z.any()).default({}),
enabled: z.boolean().default(true),
});
export type TriggerCreateRequest = z.infer<typeof triggerCreateRequest>;
export const triggerUpdateRequest = z.object({
enabled: z.boolean().nullable().optional(),
params: z.record(z.string(), z.any()).nullable().optional(),
static_inputs: z.record(z.string(), z.any()).nullable().optional(),
});
export type TriggerUpdateRequest = z.infer<typeof triggerUpdateRequest>;
export const trigger = z.object({
id: z.number(),
type: triggerType,
params: z.record(z.string(), z.any()),
static_inputs: z.record(z.string(), z.any()),
enabled: z.boolean(),
last_fired_at: z.string().nullable().optional(),
next_fire_at: z.string().nullable().optional(),
created_at: z.string(),
});
export type Trigger = z.infer<typeof trigger>;
// =============================================================================
// Automations — mirror app/automations/schemas/api/automation.py
// =============================================================================
export const automationCreateRequest = z.object({
search_space_id: z.number(),
name: z.string().min(1).max(200),
description: z.string().nullable().optional(),
definition: automationDefinition,
triggers: z.array(triggerCreateRequest).default([]),
});
export type AutomationCreateRequest = z.infer<typeof automationCreateRequest>;
export const automationUpdateRequest = z.object({
name: z.string().min(1).max(200).nullable().optional(),
description: z.string().nullable().optional(),
status: automationStatus.nullable().optional(),
definition: automationDefinition.nullable().optional(),
});
export type AutomationUpdateRequest = z.infer<typeof automationUpdateRequest>;
export const automationSummary = z.object({
id: z.number(),
search_space_id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
status: automationStatus,
version: z.number(),
created_at: z.string(),
updated_at: z.string(),
});
export type AutomationSummary = z.infer<typeof automationSummary>;
export const automation = automationSummary.extend({
definition: automationDefinition,
triggers: z.array(trigger).default([]),
});
export type Automation = z.infer<typeof automation>;
export const automationListResponse = z.object({
items: z.array(automationSummary),
total: z.number(),
});
export type AutomationListResponse = z.infer<typeof automationListResponse>;
export const automationListParams = z.object({
search_space_id: z.number(),
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type AutomationListParams = z.infer<typeof automationListParams>;
// =============================================================================
// Runs (sub-resource) — mirror app/automations/schemas/api/run.py
// =============================================================================
export const runSummary = z.object({
id: z.number(),
automation_id: z.number(),
trigger_id: z.number().nullable().optional(),
status: runStatus,
started_at: z.string().nullable().optional(),
finished_at: z.string().nullable().optional(),
created_at: z.string(),
});
export type RunSummary = z.infer<typeof runSummary>;
export const run = runSummary.extend({
definition_snapshot: z.record(z.string(), z.any()),
inputs: z.record(z.string(), z.any()),
step_results: z.array(z.record(z.string(), z.any())),
output: z.record(z.string(), z.any()).nullable().optional(),
artifacts: z.array(z.record(z.string(), z.any())),
error: z.record(z.string(), z.any()).nullable().optional(),
});
export type Run = z.infer<typeof run>;
export const runListResponse = z.object({
items: z.array(runSummary),
total: z.number(),
});
export type RunListResponse = z.infer<typeof runListResponse>;
export const runListParams = z.object({
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type RunListParams = z.infer<typeof runListParams>;

View file

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

View file

@ -0,0 +1,42 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { Run, RunListResponse } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
const DEFAULT_LIMIT = 50;
const DEFAULT_OFFSET = 0;
export interface UseAutomationRunsOptions {
limit?: number;
offset?: number;
enabled?: boolean;
}
/** Paginated run history for one automation. Newest-first per backend. */
export function useAutomationRuns(
automationId: number | undefined,
{ limit = DEFAULT_LIMIT, offset = DEFAULT_OFFSET, enabled = true }: UseAutomationRunsOptions = {}
) {
return useQuery<RunListResponse, Error>({
queryKey: cacheKeys.automations.runs(automationId ?? 0, limit, offset),
queryFn: () => automationsApiService.listRuns(automationId as number, { limit, offset }),
enabled: enabled && !!automationId,
staleTime: 30_000,
});
}
/** Single run with the full snapshot, step results, output and artifacts. */
export function useAutomationRun(
automationId: number | undefined,
runId: number | undefined,
options: { enabled?: boolean } = {}
) {
const { enabled = true } = options;
return useQuery<Run, Error>({
queryKey: cacheKeys.automations.run(automationId ?? 0, runId ?? 0),
queryFn: () => automationsApiService.getRun(automationId as number, runId as number),
enabled: enabled && !!automationId && !!runId,
staleTime: 30_000,
});
}

View file

@ -0,0 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { Automation } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
/**
* Fetch a single automation with its definition and triggers.
* Lives outside the jotai atom layer because it's keyed by id, not by the
* "current scope" the atom layer assumes.
*/
export function useAutomation(automationId: number | undefined) {
return useQuery<Automation, Error>({
queryKey: cacheKeys.automations.detail(automationId ?? 0),
queryFn: () => automationsApiService.getAutomation(automationId as number),
enabled: !!automationId,
staleTime: 60_000,
});
}

View file

@ -0,0 +1,24 @@
"use client";
import { useAtomValue } from "jotai";
import { automationsListAtom } from "@/atoms/automations/automations-query.atoms";
/**
* List automations in the active search space (first page).
* Pagination knobs live in detail/list hooks below; v1 surfaces only the
* first page since automation counts are expected to be small.
*/
export function useAutomations() {
const { data, isLoading, error, refetch } = useAutomationsRaw();
return {
automations: data?.items ?? [],
total: data?.total ?? 0,
loading: isLoading,
error,
refresh: refetch,
};
}
// Exposed for callers that prefer the raw react-query result shape.
export function useAutomationsRaw() {
return useAtomValue(automationsListAtom);
}

View file

@ -0,0 +1,102 @@
import {
type AutomationCreateRequest,
type AutomationListParams,
type AutomationUpdateRequest,
automation,
automationCreateRequest,
automationListResponse,
automationUpdateRequest,
type RunListParams,
run,
runListResponse,
type TriggerCreateRequest,
type TriggerUpdateRequest,
trigger,
triggerCreateRequest,
triggerUpdateRequest,
} from "@/contracts/types/automation.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/automations";
function rejectIfInvalid<T>(
parsed: { success: true; data: T } | { success: false; error: { issues: { message: string }[] } }
): T {
if (!parsed.success) {
throw new ValidationError(
`Invalid request: ${parsed.error.issues.map((i) => i.message).join(", ")}`
);
}
return parsed.data;
}
class AutomationsApiService {
// ---- Automations ---------------------------------------------------------
listAutomations = async (params: AutomationListParams) => {
const qs = new URLSearchParams({
search_space_id: String(params.search_space_id),
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}?${qs.toString()}`, automationListResponse);
};
getAutomation = async (automationId: number) => {
return baseApiService.get(`${BASE}/${automationId}`, automation);
};
createAutomation = async (request: AutomationCreateRequest) => {
const data = rejectIfInvalid(automationCreateRequest.safeParse(request));
return baseApiService.post(BASE, automation, { body: data });
};
updateAutomation = async (automationId: number, request: AutomationUpdateRequest) => {
const data = rejectIfInvalid(automationUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}`, automation, { body: data });
};
// Server returns 204; baseApiService now resolves to null and skips schema validation.
deleteAutomation = async (automationId: number) => {
return baseApiService.delete(`${BASE}/${automationId}`);
};
// ---- Triggers (sub-resource) --------------------------------------------
addTrigger = async (automationId: number, request: TriggerCreateRequest) => {
const data = rejectIfInvalid(triggerCreateRequest.safeParse(request));
return baseApiService.post(`${BASE}/${automationId}/triggers`, trigger, { body: data });
};
updateTrigger = async (
automationId: number,
triggerId: number,
request: TriggerUpdateRequest
) => {
const data = rejectIfInvalid(triggerUpdateRequest.safeParse(request));
return baseApiService.patch(`${BASE}/${automationId}/triggers/${triggerId}`, trigger, {
body: data,
});
};
removeTrigger = async (automationId: number, triggerId: number) => {
return baseApiService.delete(`${BASE}/${automationId}/triggers/${triggerId}`);
};
// ---- Runs (sub-resource, read-only) -------------------------------------
listRuns = async (automationId: number, params: RunListParams) => {
const qs = new URLSearchParams({
limit: String(params.limit),
offset: String(params.offset),
});
return baseApiService.get(`${BASE}/${automationId}/runs?${qs.toString()}`, runListResponse);
};
getRun = async (automationId: number, runId: number) => {
return baseApiService.get(`${BASE}/${automationId}/runs/${runId}`, run);
};
}
export const automationsApiService = new AutomationsApiService();

View file

@ -1,4 +1,5 @@
import type { ZodType } from "zod";
import { BACKEND_URL } from "@/lib/env-config";
import { getClientPlatform } from "../agent-filesystem";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import {
@ -9,7 +10,7 @@ import {
NetworkError,
NotFoundError,
} from "../error";
import { BACKEND_URL } from "@/lib/env-config";
enum ResponseType {
JSON = "json",
TEXT = "text",
@ -122,8 +123,9 @@ class BaseApiService {
if (contentType === "application/json" && typeof mergedOptions.body === "object") {
fetchOptions.body = JSON.stringify(mergedOptions.body);
} else {
// Pass body as-is for other content types (e.g., form data, already stringified)
fetchOptions.body = mergedOptions.body;
// Pass body as-is for other content types (form data, already stringified).
// Caller is responsible for passing a real BodyInit when Content-Type is not JSON.
fetchOptions.body = mergedOptions.body as BodyInit;
}
}
@ -210,32 +212,39 @@ class BaseApiService {
let data;
const responseType = mergedOptions.responseType;
try {
switch (responseType) {
case ResponseType.JSON:
data = await response.json();
break;
case ResponseType.TEXT:
data = await response.text();
break;
case ResponseType.BLOB:
data = await response.blob();
break;
case ResponseType.ARRAY_BUFFER:
data = await response.arrayBuffer();
break;
// Add more cases as needed
default:
data = await response.json();
if (response.status === 204) {
// 204 No Content has no body; .json() would throw SyntaxError.
// Leave data as null and skip schema validation below so endpoints
// that opt out of bodies (REST-style DELETE) don't error on success.
data = null;
} else {
try {
switch (responseType) {
case ResponseType.JSON:
data = await response.json();
break;
case ResponseType.TEXT:
data = await response.text();
break;
case ResponseType.BLOB:
data = await response.blob();
break;
case ResponseType.ARRAY_BUFFER:
data = await response.arrayBuffer();
break;
// Add more cases as needed
default:
data = await response.json();
}
} catch (error) {
console.error("Failed to parse response as JSON:", error);
throw new AppError("Failed to parse response", response.status, response.statusText);
}
} catch (error) {
console.error("Failed to parse response as JSON:", error);
throw new AppError("Failed to parse response", response.status, response.statusText);
}
// Validate response
if (responseType === ResponseType.JSON) {
if (!responseSchema) {
if (!responseSchema || response.status === 204) {
return data;
}
const parsedData = responseSchema.safeParse(data);

View file

@ -0,0 +1,44 @@
/**
* 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,67 @@
/**
* Minimal cron describer for the 5-field patterns the SurfSense drafter LLM
* actually produces (daily, weekdays, weekly, monthly, hourly). Falls back
* to the raw expression when unrecognized so the user still sees something
* honest instead of a guess.
*
* Lives under ``lib/automations/`` because both the dashboard slice and the
* chat ``create_automation`` approval card render schedule descriptions
* keeping the helper outside either feature avoids a layering violation.
*/
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function describeCron(cron: string): string {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return cron;
const [minute, hour, dom, month, dow] = parts;
// Daily at H:MM ("0 9 * * *")
if (month === "*" && dom === "*" && dow === "*" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
return `Daily at ${formatTime(hour, minute)}`;
}
// Weekdays at H:MM ("0 9 * * 1-5")
if (month === "*" && dom === "*" && dow === "1-5" && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
return `MonFri at ${formatTime(hour, minute)}`;
}
// Specific weekday(s) ("0 9 * * 1" or "0 9 * * 1,3,5")
if (
month === "*" &&
dom === "*" &&
/^\d+$/.test(minute) &&
/^\d+$/.test(hour) &&
/^[\d,]+$/.test(dow)
) {
const days = dow
.split(",")
.map((d) => DAY_NAMES[Number(d) % 7])
.filter(Boolean)
.join(", ");
if (days) return `${days} at ${formatTime(hour, minute)}`;
}
// Monthly on day N ("0 9 1 * *")
if (
month === "*" &&
dow === "*" &&
/^\d+$/.test(dom) &&
/^\d+$/.test(hour) &&
/^\d+$/.test(minute)
) {
return `Day ${dom} of each month at ${formatTime(hour, minute)}`;
}
// Hourly ("0 * * * *")
if (month === "*" && dom === "*" && dow === "*" && hour === "*" && /^\d+$/.test(minute)) {
return minute === "0" ? "Every hour" : `Every hour at :${minute.padStart(2, "0")}`;
}
return cron;
}
function formatTime(hour: string, minute: string): string {
return `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
}

View file

@ -1,4 +1,12 @@
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import {
differenceInDays,
differenceInMinutes,
format,
isThisYear,
isToday,
isTomorrow,
isYesterday,
} from "date-fns";
/**
* Format a date string as a human-readable relative time
@ -23,6 +31,36 @@ export function formatRelativeDate(dateString: string): string {
return format(date, "MMM d, yyyy");
}
/**
* Format a future date string as a human-readable countdown.
* - < 1 min: "Any moment"
* - < 60 min: "in 15m"
* - Today: "Today, 2:30 PM"
* - Tomorrow: "Tomorrow, 2:30 PM"
* - < 7 days: "in 3d"
* - This year: "May 30, 2:30 PM"
* - Older: "Jan 15, 2027"
*
* Mirrors {@link formatRelativeDate} but for moments strictly after now.
* Falls back to the past-relative formatter if the timestamp is not in
* the future (defensive guards against stale "next_fire_at" values).
*/
export function formatRelativeFutureDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAhead = differenceInMinutes(date, now);
const daysAhead = differenceInDays(date, now);
if (minutesAhead <= 0) return formatRelativeDate(dateString);
if (minutesAhead < 1) return "Any moment";
if (minutesAhead < 60) return `in ${minutesAhead}m`;
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
if (isTomorrow(date)) return `Tomorrow, ${format(date, "h:mm a")}`;
if (daysAhead < 7) return `in ${daysAhead}d`;
if (isThisYear(date)) return format(date, "MMM d, h:mm a");
return format(date, "MMM d, yyyy");
}
/**
* Format a thread's last-updated timestamp for the chats sidebars.
* Example: "Mar 23, 2026 at 4:30 PM"

View file

@ -126,4 +126,14 @@ export const cacheKeys = {
batchUnreadCounts: (searchSpaceId: number | null) =>
["notifications", "unread-counts-batch", searchSpaceId] as const,
},
automations: {
// list endpoint is keyed by pagination too so distinct pages don't collide
list: (searchSpaceId: number, limit: number, offset: number) =>
["automations", "list", searchSpaceId, limit, offset] as const,
detail: (automationId: number) => ["automations", "detail", automationId] as const,
runs: (automationId: number, limit: number, offset: number) =>
["automations", "runs", automationId, limit, offset] as const,
run: (automationId: number, runId: number) =>
["automations", "runs", automationId, runId] as const,
},
};

View file

@ -36,6 +36,7 @@
"@babel/standalone": "^7.29.2",
"@hookform/resolvers": "^5.2.2",
"@marsidev/react-turnstile": "^1.5.0",
"@microlink/react-json-view": "^1.31.20",
"@monaco-editor/react": "^4.7.0",
"@number-flow/react": "^0.5.10",
"@platejs/autoformat": "^52.0.11",
@ -134,7 +135,6 @@
"react-dom": "^19.2.3",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.61.1",
"react-json-view-lite": "^2.4.1",
"react-syntax-highlighter": "^15.6.1",
"react-wrap-balancer": "^1.1.1",
"rehype-katex": "^7.0.1",

View file

@ -29,6 +29,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.5.0
version: 1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@microlink/react-json-view':
specifier: ^1.31.20
version: 1.31.20(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -323,9 +326,6 @@ importers:
react-hook-form:
specifier: ^7.61.1
version: 7.71.2(react@19.2.4)
react-json-view-lite:
specifier: ^2.4.1
version: 2.5.0(react@19.2.4)
react-syntax-highlighter:
specifier: ^15.6.1
version: 15.6.6(react@19.2.4)
@ -1143,24 +1143,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.4.6':
resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.6':
resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.4.6':
resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.6':
resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==}
@ -1836,89 +1840,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@ -1989,6 +2009,13 @@ packages:
peerDependencies:
mediabunny: ^1.0.0
'@microlink/react-json-view@1.31.20':
resolution: {integrity: sha512-gNLkGvjFDeAqVGvK3H7lfoDqetn/9lW2ugiYiJhchc7jQU1ZaKsZnt97ANluXWFfd/wifoA9TrVOTsUXwXCJwA==}
engines: {node: '>=17'}
peerDependencies:
react: '>= 15'
react-dom: '>= 15'
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
@ -2028,30 +2055,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.97':
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
@ -2095,24 +2127,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.6':
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.6':
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.6':
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.6':
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
@ -2768,48 +2804,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.45.0':
resolution: {integrity: sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.45.0':
resolution: {integrity: sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.45.0':
resolution: {integrity: sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.45.0':
resolution: {integrity: sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.45.0':
resolution: {integrity: sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.45.0':
resolution: {integrity: sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.45.0':
resolution: {integrity: sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.45.0':
resolution: {integrity: sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg==}
@ -2864,36 +2908,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@ -4222,66 +4272,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@ -4472,24 +4535,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.13':
resolution: {integrity: sha512-SmZ9m+XqCB35NddHCctvHFLqPZDAs5j8IgD36GoutufDJmeq2VNfgk5rQoqNqKmAK3Y7iFdEmI76QoHIWiCLyw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.15.13':
resolution: {integrity: sha512-5rij+vB9a29aNkHq72EXI2ihDZPszJb4zlApJY4aCC/q6utgqFA6CkrfTfIb+O8hxtG3zP5KERETz8mfFK6A0A==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.13':
resolution: {integrity: sha512-OlSlaOK9JplQ5qn07WiBLibkOw7iml2++ojEXhhR3rbWrNEKCD7sd8+6wSavsInyFdw4PhLA+Hy6YyDBIE23Yw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.13':
resolution: {integrity: sha512-zwQii5YVdsfG8Ti9gIKgBKZg8qMkRZxl+OlYWUT5D93Jl4NuNBRausP20tfEkQdAPSRrMCSUZBM6FhW7izAZRg==}
@ -4573,24 +4640,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
@ -4732,6 +4803,9 @@ packages:
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@ -4915,41 +4989,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -5323,6 +5405,13 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
@ -6471,6 +6560,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-async-function@2.1.1:
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
engines: {node: '>= 0.4'}
@ -6838,24 +6930,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@ -6880,6 +6976,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@ -7673,6 +7772,9 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-base16-styling@0.10.0:
resolution: {integrity: sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==}
react-compiler-runtime@1.0.0:
resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==}
peerDependencies:
@ -7729,11 +7831,8 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-json-view-lite@2.5.0:
resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==}
engines: {node: '>=18'}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-lifecycles-compat@3.0.4:
resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
@ -8121,6 +8220,9 @@ packages:
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
slate-dom@0.119.0:
resolution: {integrity: sha512-foc8a2NkE+1SldDIYaoqjhVKupt8RSuvHI868rfYOcypD4we5TT7qunjRKJ852EIRh/Ql8sSTepXgXKOUJnt1w==}
peerDependencies:
@ -10316,6 +10418,16 @@ snapshots:
dependencies:
mediabunny: 1.39.2
'@microlink/react-json-view@1.31.20(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-base16-styling: 0.10.0
react-dom: 19.2.4(react@19.2.4)
react-lifecycles-compat: 3.0.4
react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
@ -13283,6 +13395,8 @@ snapshots:
'@types/katex@0.16.8': {}
'@types/lodash@4.17.24': {}
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@ -13905,6 +14019,16 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
comma-separated-tokens@1.0.8: {}
comma-separated-tokens@2.0.3: {}
@ -15327,6 +15451,8 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@ -15662,6 +15788,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash-es@4.18.1: {}
lodash.camelcase@4.3.0: {}
lodash.debounce@4.0.8: {}
@ -16843,6 +16971,13 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-base16-styling@0.10.0:
dependencies:
'@types/lodash': 4.17.24
color: 4.2.3
csstype: 3.2.3
lodash-es: 4.18.1
react-compiler-runtime@1.0.0(react@19.2.4):
dependencies:
react: 19.2.4
@ -16894,9 +17029,7 @@ snapshots:
react-is@16.13.1: {}
react-json-view-lite@2.5.0(react@19.2.4):
dependencies:
react: 19.2.4
react-lifecycles-compat@3.0.4: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
@ -17458,6 +17591,10 @@ snapshots:
once: 1.4.0
simple-concat: 1.0.1
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
slate-dom@0.119.0(slate@0.120.0):
dependencies:
'@juggle/resize-observer': 3.4.0