feat(frontend/library): Add preset/trigger support to Library v3 - EOD: 80%

This commit is contained in:
Reinier van der Leer
2025-11-27 21:34:34 +01:00
parent 3d08c22dd5
commit a0604b1a06
9 changed files with 883 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ import { RunAgentModal } from "./components/RunAgentModal/RunAgentModal";
import { RunsSidebar } from "./components/RunsSidebar/RunsSidebar";
import { SelectedRunView } from "./components/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/SelectedScheduleView/SelectedScheduleView";
import { SelectedTemplateView } from "./components/SelectedTemplateView/SelectedTemplateView";
import { useAgentRunsView } from "./useAgentRunsView";
export function AgentRunsView() {
@@ -99,6 +100,23 @@ export function AgentRunsView() {
scheduleId={selectedRun.replace("schedule:", "")}
onClearSelectedRun={handleClearSelectedRun}
/>
) : selectedRun.startsWith("preset:") ? (
<SelectedTemplateView
agent={agent}
presetID={selectedRun.replace("preset:", "")}
onDelete={(templateId) => {
// TODO: Implement template deletion
console.log("Delete template:", templateId);
}}
onRun={(templateId) => {
// TODO: Implement template execution
console.log("Run template:", templateId);
}}
onCreateSchedule={(templateId) => {
// TODO: Implement schedule creation from template
console.log("Create schedule from template:", templateId);
}}
/>
) : (
<SelectedRunView
agent={agent}

View File

@@ -11,6 +11,7 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useRunsSidebar } from "./useRunsSidebar";
import { RunListItem } from "./components/RunListItem";
import { ScheduleListItem } from "./components/ScheduleListItem";
import { TemplateListItem } from "./components/TemplateListItem";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
@@ -23,6 +24,7 @@ interface RunsSidebarProps {
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
presetsCount: number;
loading?: boolean;
}) => void;
}
@@ -36,8 +38,11 @@ export function RunsSidebar({
const {
runs,
schedules,
presets,
runsCount,
schedulesCount,
triggersCount,
regularPresetsCount,
error,
loading,
fetchMoreRuns,
@@ -69,13 +74,15 @@ export function RunsSidebar({
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled";
const value = v as "runs" | "scheduled" | "templates";
setTabValue(value);
if (value === "runs") {
if (runs && runs.length) onSelectRun(runs[0].id);
} else {
} else if (value === "scheduled") {
if (schedules && schedules.length)
onSelectRun(`schedule:${schedules[0].id}`);
} else if (value === "templates") {
if (presets && presets.length) onSelectRun(`preset:${presets[0].id}`);
}
}}
className="min-w-0 overflow-hidden"
@@ -87,6 +94,18 @@ export function RunsSidebar({
<TabsLineTrigger value="scheduled">
Scheduled <span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="templates">
Templates{" "}
<span className="ml-3 inline-block">
{triggersCount > 0 && regularPresetsCount > 0
? `${triggersCount} + ${regularPresetsCount}`
: triggersCount > 0
? `${triggersCount} trigger${triggersCount !== 1 ? "s" : ""}`
: regularPresetsCount > 0
? `${regularPresetsCount} preset${regularPresetsCount !== 1 ? "s" : ""}`
: "0"}
</span>
</TabsLineTrigger>
</TabsLineList>
<>
@@ -123,6 +142,36 @@ export function RunsSidebar({
))}
</div>
</TabsLineContent>
<TabsLineContent value="templates">
<div className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-3 lg:overflow-x-hidden">
{/* Triggers first */}
{presets
.filter((preset) => !!preset.webhook)
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
.map((preset) => (
<div className="w-[15rem] lg:w-full" key={preset.id}>
<TemplateListItem
preset={preset}
selected={selectedRunId === `preset:${preset.id}`}
onClick={() => onSelectRun(`preset:${preset.id}`)}
/>
</div>
))}
{/* Regular presets second */}
{presets
.filter((preset) => !preset.webhook)
.sort((a, b) => b.updated_at.getTime() - a.updated_at.getTime())
.map((preset) => (
<div className="w-[15rem] lg:w-full" key={preset.id}>
<TemplateListItem
preset={preset}
selected={selectedRunId === `preset:${preset.id}`}
onClick={() => onSelectRun(`preset:${preset.id}`)}
/>
</div>
))}
</div>
</TabsLineContent>
</>
</TabsLine>
);

View File

@@ -8,6 +8,7 @@ interface RunListItemProps {
title: string;
description?: string;
icon?: React.ReactNode;
statusBadge?: React.ReactNode;
selected?: boolean;
onClick?: () => void;
}
@@ -16,6 +17,7 @@ export function RunSidebarCard({
title,
description,
icon,
statusBadge,
selected,
onClick,
}: RunListItemProps) {
@@ -30,12 +32,15 @@ export function RunSidebarCard({
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
<div className="flex min-w-0 flex-1 flex-col items-start justify-between">
<Text
variant="body-medium"
className="block w-full truncate text-ellipsis"
>
{title}
</Text>
<div className="flex w-full items-center justify-between">
<Text
variant="body-medium"
className="block truncate text-ellipsis"
>
{title}
</Text>
{statusBadge}
</div>
<Text variant="small" className="!text-zinc-500">
{description}
</Text>

View File

@@ -0,0 +1,69 @@
"use client";
import React from "react";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { LinkIcon, PushPinIcon } from "@phosphor-icons/react";
interface TemplateListItemProps {
preset: LibraryAgentPreset;
selected?: boolean;
onClick?: () => void;
}
export function TemplateListItem({
preset,
selected,
onClick,
}: TemplateListItemProps) {
const isTrigger = !!preset.webhook;
const isActive = preset.is_active ?? false;
return (
<RunSidebarCard
title={preset.name}
description={preset.description || "No description"}
onClick={onClick}
selected={selected}
icon={
<IconWrapper
className={
isTrigger
? isActive
? "border-green-50 bg-green-50"
: "border-gray-50 bg-gray-50"
: "border-blue-50 bg-blue-50"
}
>
{isTrigger ? (
<LinkIcon
size={16}
className={isActive ? "text-green-700" : "text-gray-700"}
weight="bold"
/>
) : (
<PushPinIcon size={16} className="text-blue-700" weight="bold" />
)}
</IconWrapper>
}
statusBadge={
isTrigger ? (
<span
className={`rounded-full px-2 py-0.5 text-xs ${
isActive
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
) : (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-800">
Preset
</span>
)
}
/>
);
}

View File

@@ -4,7 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { useGetV2ListPresets } from "@/app/api/__generated__/endpoints/presets/presets";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import type { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { useSearchParams } from "next/navigation";
import { okData } from "@/app/api/helpers";
import {
@@ -20,6 +23,7 @@ type Args = {
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
presetsCount: number;
loading?: boolean;
}) => void;
};
@@ -27,7 +31,9 @@ type Args = {
export function useRunsSidebar({ graphId, onSelectRun, onCountsChange }: Args) {
const params = useSearchParams();
const existingRunId = params.get("executionId") as string | undefined;
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
const [tabValue, setTabValue] = useState<"runs" | "scheduled" | "templates">(
"runs",
);
const runsQuery = useGetV1ListGraphExecutionsInfinite(
graphId || "",
@@ -54,23 +60,42 @@ export function useRunsSidebar({ graphId, onSelectRun, onCountsChange }: Args) {
},
);
const presetsQuery = useGetV2ListPresets(
{ graph_id: graphId || null, page: 1, page_size: 100 },
{
query: {
enabled: !!graphId,
select: (r) => okData<LibraryAgentPresetResponse>(r)?.presets ?? [],
},
},
);
const runs = useMemo(
() => extractRunsFromPages(runsQuery.data),
[runsQuery.data],
);
const schedules = schedulesQuery.data || [];
const presets = presetsQuery.data || [];
const runsCount = computeRunsCount(runsQuery.data, runs.length);
const schedulesCount = schedules.length;
const loading = !schedulesQuery.isSuccess || !runsQuery.isSuccess;
const presetsCount = presets.length;
const triggersCount = presets.filter((preset) => !!preset.webhook).length;
const regularPresetsCount = presets.filter(
(preset) => !preset.webhook,
).length;
const loading =
!schedulesQuery.isSuccess ||
!runsQuery.isSuccess ||
!presetsQuery.isSuccess;
// Notify parent about counts and loading state
useEffect(() => {
if (onCountsChange) {
onCountsChange({ runsCount, schedulesCount, loading });
onCountsChange({ runsCount, schedulesCount, presetsCount, loading });
}
}, [runsCount, schedulesCount, loading, onCountsChange]);
}, [runsCount, schedulesCount, presetsCount, loading, onCountsChange]);
useEffect(() => {
if (runs.length > 0) {
@@ -85,25 +110,37 @@ export function useRunsSidebar({ graphId, onSelectRun, onCountsChange }: Args) {
useEffect(() => {
if (existingRunId && existingRunId.startsWith("schedule:"))
setTabValue("scheduled");
else if (existingRunId && existingRunId.startsWith("preset:"))
setTabValue("templates");
else setTabValue("runs");
}, [existingRunId]);
// If there are no runs but there are schedules, and nothing is selected, auto-select the first schedule
// If there are no runs but there are schedules or presets, auto-select the first available
useEffect(() => {
if (!existingRunId && runs.length === 0 && schedules.length > 0)
onSelectRun(`schedule:${schedules[0].id}`);
}, [existingRunId, runs.length, schedules, onSelectRun]);
if (!existingRunId && runs.length === 0) {
if (schedules.length > 0) {
onSelectRun(`schedule:${schedules[0].id}`);
} else if (presets.length > 0) {
onSelectRun(`preset:${presets[0].id}`);
}
}
}, [existingRunId, runs.length, schedules, presets, onSelectRun]);
return {
runs,
schedules,
error: schedulesQuery.error || runsQuery.error,
presets,
error: schedulesQuery.error || runsQuery.error || presetsQuery.error,
loading,
runsQuery,
presetsQuery,
tabValue,
setTabValue,
runsCount,
schedulesCount,
presetsCount,
triggersCount,
regularPresetsCount,
fetchMoreRuns: runsQuery.fetchNextPage,
hasMoreRuns: runsQuery.hasNextPage,
isFetchingMoreRuns: runsQuery.isFetchingNextPage,

View File

@@ -0,0 +1,342 @@
"use client";
import React, { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import {
PlayIcon,
PencilIcon,
TrashIcon,
CalendarIcon,
} from "@phosphor-icons/react";
import {
TabsLine,
TabsLineContent,
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { useToast } from "@/components/molecules/Toast/use-toast";
import {
useGetV2ListPresets,
useDeleteV2DeleteAPreset,
usePostV2ExecuteAPreset,
getGetV2ListPresetsQueryKey,
} from "@/app/api/__generated__/endpoints/presets/presets";
import type { LibraryAgentPresetResponse } from "@/app/api/__generated__/models/libraryAgentPresetResponse";
import { getGetV1ListGraphExecutionsInfiniteQueryOptions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { AgentInputsReadOnly } from "../AgentInputsReadOnly/AgentInputsReadOnly";
import { EditTemplateModal } from "./components/EditTemplateModal";
import { okData } from "@/app/api/helpers";
interface Props {
agent: LibraryAgent;
presetID: string;
onDelete?: (presetID: string) => void;
onRun?: (presetID: string) => void;
onCreateSchedule?: (presetID: string) => void;
}
export function SelectedTemplateView({
agent,
presetID,
onDelete,
onRun,
onCreateSchedule,
}: Props) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isDeleting, setIsDeleting] = useState(false);
const [isRunning, setIsRunning] = useState(false);
// Fetch preset data
const presetsQuery = useGetV2ListPresets(
{ graph_id: agent.graph_id, page: 1, page_size: 100 },
{
query: {
enabled: !!agent.graph_id && !!presetID,
},
},
);
const preset = useMemo(() => {
const response = okData<LibraryAgentPresetResponse>(presetsQuery.data);
const presets = response?.presets ?? [];
return presets.find((preset) => preset.id === presetID) || null;
}, [presetsQuery.data, presetID]);
// Delete preset mutation
const deleteTemplateMutation = useDeleteV2DeleteAPreset({
mutation: {
onSuccess: () => {
toast({
title: "Template deleted successfully",
variant: "default",
});
// Invalidate presets list
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
setIsDeleting(false);
},
onError: (error) => {
toast({
title: "Failed to delete template",
description: String(error),
variant: "destructive",
});
setIsDeleting(false);
},
},
});
// Execute preset mutation
const executeTemplateMutation = usePostV2ExecuteAPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template execution started",
variant: "default",
});
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
}
setIsRunning(false);
},
onError: (error) => {
toast({
title: "Failed to run template",
description: String(error),
variant: "destructive",
});
setIsRunning(false);
},
},
});
const handleDeleteTemplate = async () => {
setIsDeleting(true);
deleteTemplateMutation.mutate({ presetId: presetID });
};
const handleRunTemplate = async () => {
setIsRunning(true);
executeTemplateMutation.mutate({
presetId: presetID,
data: {
inputs: {},
credential_inputs: {},
},
});
};
const handleTemplateSaved = () => {
// Invalidate presets list to refresh data
queryClient.invalidateQueries({
queryKey: getGetV2ListPresetsQueryKey({ graph_id: agent.graph_id }),
});
};
const handleCreateSchedule = () => {
// TODO: Implement schedule creation from preset
toast({
title: "Schedule creation",
description:
"Schedule creation from template will be implemented in the next phase",
variant: "default",
});
};
const isLoading = presetsQuery.isLoading;
const error = presetsQuery.error;
if (error) {
return (
<ErrorCard
responseError={
error
? {
message: String(
(error as unknown as { message?: string })?.message ||
"Failed to load template",
),
}
: undefined
}
httpError={
(error as any)?.status
? {
status: (error as any).status,
statusText: (error as any).statusText,
}
: undefined
}
context="template"
/>
);
}
if (isLoading && !preset) {
return (
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<div className="flex flex-col gap-2">
<Text variant="h2" className="!text-2xl font-bold">
{preset?.name || "Loading..."}
</Text>
<Text variant="body-medium" className="!text-zinc-500">
Template {agent.name}
</Text>
</div>
</div>
{preset ? (
<div className="flex gap-2">
<Button
variant="primary"
size="small"
onClick={() => {
handleRunTemplate();
onRun?.(presetID);
}}
disabled={isRunning || isDeleting}
leftIcon={<PlayIcon size={16} />}
>
{isRunning ? "Running..." : "Run Template"}
</Button>
<EditTemplateModal
triggerSlot={
<Button
variant="secondary"
size="small"
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
}
agent={agent}
preset={preset}
onSaved={handleTemplateSaved}
/>
<Button
variant="secondary"
size="small"
onClick={() => {
handleCreateSchedule();
onCreateSchedule?.(presetID);
}}
leftIcon={<CalendarIcon size={16} />}
>
Schedule
</Button>
<Button
variant="destructive"
size="small"
onClick={() => {
handleDeleteTemplate();
onDelete?.(presetID);
}}
disabled={isRunning || isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
) : null}
</div>
</div>
<TabsLine defaultValue="input">
<TabsLineList>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
<TabsLineTrigger value="details">Template details</TabsLineTrigger>
</TabsLineList>
<TabsLineContent value="input">
<RunDetailCard>
<div className="relative">
<AgentInputsReadOnly
agent={agent}
inputs={preset?.inputs}
credentialInputs={preset?.credentials}
/>
</div>
</RunDetailCard>
</TabsLineContent>
<TabsLineContent value="details">
<RunDetailCard>
{isLoading || !preset ? (
<div className="text-neutral-500">Loading</div>
) : (
<div className="relative flex flex-col gap-8">
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Name
</Text>
<p className="text-sm text-zinc-600">{preset.name}</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Description
</Text>
<p className="text-sm text-zinc-600">
{preset.description || "No description provided"}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Created
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Last Updated
</Text>
<p className="text-sm text-zinc-600">
{new Date(preset.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
)}
</RunDetailCard>
</TabsLineContent>
</TabsLine>
</div>
);
}

View File

@@ -0,0 +1,266 @@
"use client";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Button } from "@/components/atoms/Button/Button";
import { useState, useEffect } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePatchV2UpdateAnExistingPreset } from "@/app/api/__generated__/endpoints/presets/presets";
import { ModalHeader } from "../../RunAgentModal/components/ModalHeader/ModalHeader";
import { AgentCostSection } from "../../RunAgentModal/components/AgentCostSection/AgentCostSection";
import { AgentSectionHeader } from "../../RunAgentModal/components/AgentSectionHeader/AgentSectionHeader";
import { ModalRunSection } from "../../RunAgentModal/components/ModalRunSection/ModalRunSection";
import { RunAgentModalContextProvider } from "../../RunAgentModal/context";
import { AgentDetails } from "../../RunAgentModal/components/AgentDetails/AgentDetails";
import { Input } from "@/components/atoms/Input/Input";
interface Props {
triggerSlot: React.ReactNode;
agent: LibraryAgent;
preset: LibraryAgentPreset;
onSaved?: (updatedPreset: LibraryAgentPreset) => void;
}
export function EditTemplateModal({
triggerSlot,
agent,
preset,
onSaved,
}: Props) {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [presetName, setPresetName] = useState(preset.name);
const [presetDescription, setPresetDescription] = useState(
preset.description,
);
const [inputValues, setInputValues] = useState<Record<string, any>>(
preset.inputs || {},
);
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
preset.credentials || {},
);
// Reset form when preset changes
useEffect(() => {
setPresetName(preset.name);
setPresetDescription(preset.description);
setInputValues(preset.inputs || {});
setInputCredentials(preset.credentials || {});
}, [preset]);
// Update preset mutation
const updatePresetMutation = usePatchV2UpdateAnExistingPreset({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Template updated successfully",
variant: "default",
});
setIsOpen(false);
onSaved?.(response.data as unknown as LibraryAgentPreset);
}
},
onError: (error: any) => {
toast({
title: "Failed to update template",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation (reusing logic from useAgentRunModal)
const agentInputSchema = agent.input_schema || {
properties: {},
required: [],
};
const agentInputFields = (() => {
if (
!agentInputSchema ||
typeof agentInputSchema !== "object" ||
!("properties" in agentInputSchema) ||
!agentInputSchema.properties
) {
return {};
}
const properties = agentInputSchema.properties as Record<string, any>;
return Object.fromEntries(
Object.entries(properties).filter(
([_, subSchema]: [string, any]) => !subSchema.hidden,
),
);
})();
const agentCredentialsInputFields = (() => {
if (
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties
) {
return {} as Record<string, any>;
}
return agent.credentials_input_schema.properties as Record<string, any>;
})();
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
Object.keys(agentCredentialsInputFields || {}).length > 0;
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
[key]: value,
}));
}
function handleCredentialsChange(key: string, value: any | undefined) {
setInputCredentials((prev) => {
const next = { ...prev } as Record<string, any>;
if (value === undefined) {
delete next[key];
return next;
}
next[key] = value;
return next;
});
}
function handleSetOpen(open: boolean) {
setIsOpen(open);
}
function handleSave() {
updatePresetMutation.mutate({
presetId: preset.id,
data: {
name: presetName,
description: presetDescription,
inputs: inputValues,
credentials: inputCredentials,
},
});
}
const hasChanges =
presetName !== preset.name ||
presetDescription !== preset.description ||
JSON.stringify(inputValues) !== JSON.stringify(preset.inputs || {}) ||
JSON.stringify(inputCredentials) !==
JSON.stringify(preset.credentials || {});
return (
<Dialog
controlled={{ isOpen, set: handleSetOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div className="flex h-full flex-col pb-4">
{/* Header */}
<div className="flex-shrink-0">
<ModalHeader agent={agent} />
<AgentCostSection flowId={agent.graph_id} />
</div>
{/* Scrollable content */}
<div className="flex-1 pr-1" style={{ scrollbarGutter: "stable" }}>
{/* Template Info Section */}
<div className="mt-10">
<AgentSectionHeader title="Template Information" />
<div className="mb-10 mt-4 space-y-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Template Name</label>
<Input
id="template_name"
label="Template Name"
size="small"
hideLabel
value={presetName}
placeholder="Enter template name"
onChange={(e) => setPresetName(e.target.value)}
/>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
Template Description
</label>
<Input
id="template_description"
label="Template Description"
size="small"
hideLabel
value={presetDescription}
placeholder="Enter template description"
onChange={(e) => setPresetDescription(e.target.value)}
/>
</div>
</div>
</div>
{/* Setup Section */}
{hasAnySetupFields ? (
<div className="mt-8">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType: "manual", // Always manual for templates
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<>
<AgentSectionHeader title="Template Setup" />
<ModalRunSection />
</>
</RunAgentModalContextProvider>
</div>
) : null}
{/* Agent Details Section */}
<div className="mt-8">
<AgentSectionHeader title="Agent Details" />
<AgentDetails agent={agent} />
</div>
</div>
</div>
<Dialog.Footer
className="fixed bottom-1 left-0 z-10 w-full bg-white p-4"
style={{ boxShadow: "0px -8px 10px white" }}
>
<div className="flex items-center justify-end gap-3">
<Button
variant="secondary"
onClick={() => setIsOpen(false)}
disabled={updatePresetMutation.isPending}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={
!hasChanges ||
updatePresetMutation.isPending ||
!presetName.trim()
}
>
{updatePresetMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
PlayIcon,
PencilIcon,
TrashIcon,
CalendarIcon,
} from "@phosphor-icons/react";
interface Props {
onEdit?: () => void;
onDelete?: () => void;
onRun?: () => void;
onCreateSchedule?: () => void;
isRunning?: boolean;
isDeleting?: boolean;
}
export function TemplateActions({
onEdit,
onDelete,
onRun,
onCreateSchedule,
isRunning = false,
isDeleting = false,
}: Props) {
return (
<div className="flex gap-2">
{onRun && (
<Button
variant="primary"
size="small"
onClick={onRun}
disabled={isRunning || isDeleting}
leftIcon={<PlayIcon size={16} />}
>
{isRunning ? "Running..." : "Run Template"}
</Button>
)}
{onEdit && (
<Button
variant="secondary"
size="small"
onClick={onEdit}
leftIcon={<PencilIcon size={16} />}
>
Edit
</Button>
)}
{onCreateSchedule && (
<Button
variant="secondary"
size="small"
onClick={onCreateSchedule}
leftIcon={<CalendarIcon size={16} />}
>
Schedule
</Button>
)}
{onDelete && (
<Button
variant="destructive"
size="small"
onClick={onDelete}
disabled={isRunning || isDeleting}
leftIcon={<TrashIcon size={16} />}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
)}
</div>
);
}

View File

@@ -24,13 +24,15 @@ export function useAgentRunsView() {
const [sidebarCounts, setSidebarCounts] = useState({
runsCount: 0,
schedulesCount: 0,
presetsCount: 0,
});
const [sidebarLoading, setSidebarLoading] = useState(true);
const hasAnyItems = useMemo(
() =>
(sidebarCounts.runsCount ?? 0) > 0 ||
(sidebarCounts.schedulesCount ?? 0) > 0,
(sidebarCounts.schedulesCount ?? 0) > 0 ||
(sidebarCounts.presetsCount ?? 0) > 0,
[sidebarCounts],
);
@@ -49,11 +51,13 @@ export function useAgentRunsView() {
(counts: {
runsCount: number;
schedulesCount: number;
presetsCount: number;
loading?: boolean;
}) => {
setSidebarCounts({
runsCount: counts.runsCount,
schedulesCount: counts.schedulesCount,
presetsCount: counts.presetsCount,
});
if (counts.loading !== undefined) {
setSidebarLoading(counts.loading);