feat(frontend): new run agent modal (1/2) (#10696)

## Changes 🏗️

<img width="600" height="624" alt="Screenshot 2025-08-25 at 23 22 24"
src="https://github.com/user-attachments/assets/a66b0a02-cb7a-47f3-8759-e955fb76f865"
/>

<img width="600" height="748" alt="Screenshot 2025-08-25 at 23 22 40"
src="https://github.com/user-attachments/assets/0357bd0b-9875-41a4-8752-d7dbc7a82ff6"
/>

The new **Agent Run Modal**, to be used when running agents. This is PR
1/2 ( _as I learned there is so much into running agents_ 🔮 ). The first
part sets up "the easy things":
- the run view
- the schedule run view
- the switch between them
- the agent details

On the next PR, I will add support for the current agent run inputs (
[and all their
types...](https://github.com/Significant-Gravitas/AutoGPT/blob/dev/autogpt_platform/frontend/src/components/type-based-input.tsx)
😆 ) + webhook triggers...

## Checklist 📋

### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] with the flag ON ( is now OFF in dev but ON local )
  - [x] clicking `New Run` on the new library page shows the new modal
  - [x] Details are shown on the modal header
  - [x] Agent details are shown
  - [x] You can schedule runs 
   
### For configuration changes:

None

---------

Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
Ubbe
2025-08-27 19:38:12 +09:00
committed by GitHub
parent b713093276
commit 0f477e2392
26 changed files with 1574 additions and 9 deletions

View File

@@ -5,17 +5,16 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { useAgentRunsView } from "./useAgentRunsView";
import { AgentRunsLoading } from "./components/AgentRunsLoading";
import { Button } from "@/components/atoms/Button/Button";
import { Plus } from "@phosphor-icons/react";
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/RunAgentModal";
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
export function AgentRunsView() {
const { response, ready, error, agentId } = useAgentRunsView();
// Handle loading state
if (!ready) {
return <AgentRunsLoading />;
}
// Handle errors - check for query error first, then response errors
if (error || (response && response.status !== 200)) {
return (
<ErrorCard
@@ -53,9 +52,15 @@ export function AgentRunsView() {
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
{/* Left Sidebar - 30% */}
<div className="bg-gray-50 p-4">
<Button variant="primary" size="large" className="w-full">
<Plus size={20} /> New Run
</Button>
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
}
agent={agent}
agentId={agent.id.toString()}
/>
</div>
{/* Main Content - 70% */}

View File

@@ -0,0 +1,156 @@
"use client";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useState } from "react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentRunModal } from "./useAgentRunModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";
import { AgentSectionHeader } from "./components/AgentSectionHeader/AgentSectionHeader";
import { DefaultRunView } from "./components/DefaultRunView/DefaultRunView";
import { ScheduleView } from "./components/ScheduleView/ScheduleView";
import { AgentDetails } from "./components/AgentDetails/AgentDetails";
import { RunActions } from "./components/RunActions/RunActions";
import { ScheduleActions } from "./components/ScheduleActions/ScheduleActions";
interface Props {
triggerSlot: React.ReactNode;
agent: LibraryAgent;
agentId: string;
agentVersion?: number;
}
export function RunAgentModal({ triggerSlot, agent }: Props) {
const {
isOpen,
setIsOpen,
showScheduleView,
defaultRunType,
inputValues,
setInputValues,
scheduleName,
cronExpression,
allRequiredInputsAreSet,
// agentInputFields, // Available if needed for future use
hasInputFields,
isExecuting,
isCreatingSchedule,
isSettingUpTrigger,
handleRun,
handleSchedule,
handleShowSchedule,
handleGoBack,
handleSetScheduleName,
handleSetCronExpression,
} = useAgentRunModal(agent);
const [isScheduleFormValid, setIsScheduleFormValid] = useState(true);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
[key]: value,
}));
}
function handleSetOpen(open: boolean) {
setIsOpen(open);
// Always reset to Run view when opening/closing
if (open || !open) handleGoBack();
}
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">
{/* Header */}
<div className="flex-shrink-0">
<ModalHeader agent={agent} />
<AgentCostSection flowId={agent.graph_id} />
</div>
{/* Scrollable content */}
<div
className="flex-1 overflow-y-auto overflow-x-hidden pr-1"
style={{ scrollbarGutter: "stable" }}
>
{/* Setup Section */}
<div className="mt-10">
{showScheduleView ? (
<>
<AgentSectionHeader title="Schedule Setup" />
<div>
<ScheduleView
agent={agent}
scheduleName={scheduleName}
cronExpression={cronExpression}
inputValues={inputValues}
onScheduleNameChange={handleSetScheduleName}
onCronExpressionChange={handleSetCronExpression}
onInputChange={handleInputChange}
onValidityChange={setIsScheduleFormValid}
/>
</div>
</>
) : hasInputFields ? (
<>
<AgentSectionHeader
title={
defaultRunType === "automatic-trigger"
? "Trigger Setup"
: "Agent Setup"
}
/>
<div>
<DefaultRunView
agent={agent}
defaultRunType={defaultRunType}
inputValues={inputValues}
onInputChange={handleInputChange}
/>
</div>
</>
) : null}
</div>
{/* Agent Details Section */}
<div className="mt-8">
<AgentSectionHeader title="Agent Details" />
<AgentDetails agent={agent} />
</div>
</div>
{/* Fixed Actions - sticky inside dialog scroll */}
<Dialog.Footer className="sticky bottom-0 z-10 bg-white">
{!showScheduleView ? (
<RunActions
hasExternalTrigger={agent.has_external_trigger}
defaultRunType={defaultRunType}
onShowSchedule={handleShowSchedule}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
allRequiredInputsAreSet={allRequiredInputsAreSet}
/>
) : (
<ScheduleActions
onGoBack={handleGoBack}
onSchedule={handleSchedule}
isCreatingSchedule={isCreatingSchedule}
allRequiredInputsAreSet={
allRequiredInputsAreSet &&
!!scheduleName.trim() &&
isScheduleFormValid
}
/>
)}
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,31 @@
import { Button } from "@/components/atoms/Button/Button";
interface Props {
flowId: string;
}
export function AgentCostSection({ flowId }: Props) {
return (
<div className="mt-6 flex items-center justify-between">
{/* TODO: enable once we have an API to show estimated cost for an agent run */}
{/* <div className="flex items-center gap-2">
<Text variant="body-medium">Cost</Text>
<Text variant="body">{cost}</Text>
</div> */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="small"
as="NextLink"
href={`/build?flowID=${flowId}`}
>
Open in builder
</Button>
{/* TODO: enable once we can easily link to the agent listing page from the library agent response */}
{/* <Button variant="outline" size="small">
View listing <ArrowSquareOutIcon size={16} />
</Button> */}
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { Badge } from "@/components/atoms/Badge/Badge";
import { formatAgentStatus, getStatusColor } from "./helpers";
import { formatDate } from "@/lib/utils/time";
interface Props {
agent: LibraryAgent;
}
export function AgentDetails({ agent }: Props) {
return (
<div className="mt-4 flex flex-col gap-5">
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Current Status
</Text>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(agent.status)}`}
/>
<Text variant="body" className="!text-zinc-700">
{formatAgentStatus(agent.status)}
</Text>
</div>
</div>
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Version
</Text>
<div className="flex items-center gap-2">
<Text variant="body" className="!text-zinc-700">
v{agent.graph_version}
</Text>
{agent.is_latest_version && (
<Badge variant="success" size="small">
Latest
</Badge>
)}
</div>
</div>
<div>
<Text variant="body-medium" className="mb-1 !text-black">
Last Updated
</Text>
<Text variant="body" className="!text-zinc-700">
{formatDate(agent.updated_at)}
</Text>
</div>
{agent.has_external_trigger && (
<div>
<Text variant="body-medium" className="mb-1">
Trigger Type
</Text>
<Text variant="body" className="!text-neutral-700">
External Webhook
</Text>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { LibraryAgentStatus } from "@/app/api/__generated__/models/libraryAgentStatus";
export function formatAgentStatus(status: LibraryAgentStatus) {
const statusMap: Record<string, string> = {
COMPLETED: "Ready",
HEALTHY: "Running",
WAITING: "Run Queued",
ERROR: "Failed Run",
};
return statusMap[status];
}
export function getStatusColor(status: LibraryAgentStatus): string {
const colorMap: Record<LibraryAgentStatus, string> = {
COMPLETED: "bg-blue-300",
HEALTHY: "bg-green-300",
WAITING: "bg-amber-300",
ERROR: "bg-red-300",
};
return colorMap[status] || "bg-gray-300";
}

View File

@@ -0,0 +1,51 @@
import { Input } from "@/components/atoms/Input/Input";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
interface Props {
agent: LibraryAgent;
inputValues: Record<string, any>;
onInputChange: (key: string, value: string) => void;
variant?: "default" | "schedule";
}
export function AgentInputFields({
agent,
inputValues,
onInputChange,
variant = "default",
}: Props) {
const hasInputFields =
agent.input_schema &&
typeof agent.input_schema === "object" &&
"properties" in agent.input_schema;
if (!hasInputFields) {
const emptyStateClass =
variant === "schedule"
? "rounded-lg bg-neutral-50 p-4 text-sm text-neutral-500"
: "p-4 text-sm text-neutral-500";
return (
<div className={emptyStateClass}>
No input fields required for this agent
</div>
);
}
return (
<>
{Object.entries((agent.input_schema as any).properties || {}).map(
([key, schema]: [string, any]) => (
<Input
key={key}
id={key}
label={schema.title || key}
value={inputValues[key] || ""}
onChange={(e) => onInputChange(key, e.target.value)}
placeholder={schema.description}
/>
),
)}
</>
);
}

View File

@@ -0,0 +1,15 @@
import { Text } from "@/components/atoms/Text/Text";
interface Props {
title: string;
}
export function AgentSectionHeader({ title }: Props) {
return (
<div className="border-t border-zinc-400 px-0 py-2">
<Text variant="label" className="!text-zinc-700">
{title}
</Text>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { RunVariant } from "../../useAgentRunModal";
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
import { AgentInputFields } from "../AgentInputFields/AgentInputFields";
interface Props {
agent: LibraryAgent;
defaultRunType: RunVariant;
inputValues: Record<string, any>;
onInputChange: (key: string, value: string) => void;
}
export function DefaultRunView({
agent,
defaultRunType,
inputValues,
onInputChange,
}: Props) {
return (
<div className="mt-6">
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
<AgentInputFields
agent={agent}
inputValues={inputValues}
onInputChange={onInputChange}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Badge } from "@/components/atoms/Badge/Badge";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
interface ModalHeaderProps {
agent: LibraryAgent;
}
export function ModalHeader({ agent }: ModalHeaderProps) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Badge variant="info">New Run</Badge>
</div>
<div>
<Text variant="h3">{agent.name}</Text>
<Text variant="body-medium">
by {agent.creator_name === "Unknown" ? "" : agent.creator_name}
</Text>
<ShowMoreText
previewLimit={80}
variant="small"
className="mt-4 !text-zinc-700"
>
{agent.description}
</ShowMoreText>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Button } from "@/components/atoms/Button/Button";
import { RunVariant } from "../../useAgentRunModal";
interface Props {
hasExternalTrigger: boolean;
defaultRunType: RunVariant;
onShowSchedule: () => void;
onRun: () => void;
isExecuting?: boolean;
isSettingUpTrigger?: boolean;
allRequiredInputsAreSet?: boolean;
}
export function RunActions({
hasExternalTrigger,
defaultRunType,
onShowSchedule,
onRun,
isExecuting = false,
isSettingUpTrigger = false,
allRequiredInputsAreSet = true,
}: Props) {
return (
<div className="flex justify-end gap-3">
{!hasExternalTrigger && (
<Button variant="secondary" onClick={onShowSchedule}>
Schedule Run
</Button>
)}
<Button
variant="primary"
onClick={onRun}
disabled={!allRequiredInputsAreSet || isExecuting || isSettingUpTrigger}
loading={isExecuting || isSettingUpTrigger}
>
{defaultRunType === "automatic-trigger"
? "Set up Trigger"
: "Run Agent"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Button } from "@/components/atoms/Button/Button";
interface Props {
onGoBack: () => void;
onSchedule: () => void;
isCreatingSchedule?: boolean;
allRequiredInputsAreSet?: boolean;
}
export function ScheduleActions({
onGoBack,
onSchedule,
isCreatingSchedule = false,
allRequiredInputsAreSet = true,
}: Props) {
return (
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onGoBack}>
Go Back
</Button>
<Button
variant="primary"
onClick={onSchedule}
disabled={!allRequiredInputsAreSet || isCreatingSchedule}
loading={isCreatingSchedule}
>
Create Schedule
</Button>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { Input } from "@/components/atoms/Input/Input";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AgentInputFields } from "../AgentInputFields/AgentInputFields";
import { Text } from "@/components/atoms/Text/Text";
import { Select } from "@/components/atoms/Select/Select";
import { useScheduleView } from "./useScheduleView";
import { useCallback, useState } from "react";
import { validateSchedule } from "./helpers";
interface Props {
agent: LibraryAgent;
scheduleName: string;
cronExpression: string;
inputValues: Record<string, any>;
onScheduleNameChange: (name: string) => void;
onCronExpressionChange: (expression: string) => void;
onInputChange: (key: string, value: string) => void;
onValidityChange?: (valid: boolean) => void;
}
export function ScheduleView({
agent,
scheduleName,
cronExpression: _cronExpression,
inputValues,
onScheduleNameChange,
onCronExpressionChange,
onInputChange,
onValidityChange,
}: Props) {
const {
repeat,
selectedDays,
time,
repeatOptions,
dayItems,
setSelectedDays,
handleRepeatChange,
handleTimeChange,
handleSelectAll,
handleWeekdays,
handleWeekends,
} = useScheduleView({ onCronExpressionChange });
function handleScheduleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
onScheduleNameChange(e.target.value);
}
const [errors, setErrors] = useState<{
scheduleName?: string;
time?: string;
}>({});
const validateNow = useCallback(
(partial: { scheduleName?: string; time?: string }) => {
const fieldErrors = validateSchedule({
scheduleName,
time,
...partial,
});
setErrors(fieldErrors);
if (onValidityChange)
onValidityChange(Object.keys(fieldErrors).length === 0);
},
[scheduleName, time, onValidityChange],
);
return (
<div className="mt-6">
<Input
id="schedule-name"
label="Schedule Name"
value={scheduleName}
onChange={(e) => {
handleScheduleNameChange(e);
validateNow({ scheduleName: e.target.value });
}}
placeholder="Enter a name for this schedule"
error={errors.scheduleName}
/>
<Select
id="repeat"
label="Repeats"
value={repeat}
onValueChange={handleRepeatChange}
options={repeatOptions}
/>
{repeat === "weekly" && (
<div className="mb-8 space-y-3">
<Text variant="body-medium" as="span" className="text-black">
Repeats on
</Text>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
onClick={handleSelectAll}
>
Select all
</button>
<button
type="button"
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
onClick={handleWeekdays}
>
Weekdays
</button>
<button
type="button"
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
onClick={handleWeekends}
>
Weekends
</button>
</div>
<MultiToggle
items={dayItems}
selectedValues={selectedDays}
onChange={setSelectedDays}
aria-label="Select days of week"
/>
</div>
)}
<Input
id="schedule-time"
label="At"
value={time}
onChange={(e) => {
const value = e.target.value.trim();
handleTimeChange({ ...e, target: { ...e.target, value } } as any);
validateNow({ time: value });
}}
placeholder="00:00"
error={errors.time}
/>
<AgentInputFields
agent={agent}
inputValues={inputValues}
onInputChange={onInputChange}
variant="schedule"
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const timeRegex = /^([01]?\d|2[0-3]):([0-5]\d)$/;
export const scheduleFormSchema = z.object({
scheduleName: z.string().trim().min(1, "Schedule name is required"),
time: z.string().trim().regex(timeRegex, "Use HH:MM (24h)"),
});
export type ScheduleFormValues = z.infer<typeof scheduleFormSchema>;
export function validateSchedule(
values: Partial<ScheduleFormValues>,
): Partial<Record<keyof ScheduleFormValues, string>> {
const result = scheduleFormSchema.safeParse({
scheduleName: values.scheduleName ?? "",
time: values.time ?? "",
});
if (result.success) return {};
const fieldErrors: Partial<Record<keyof ScheduleFormValues, string>> = {};
for (const issue of result.error.issues) {
const path = issue.path[0] as keyof ScheduleFormValues | undefined;
if (path && !fieldErrors[path]) fieldErrors[path] = issue.message;
}
return fieldErrors;
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useMemo, useState } from "react";
interface UseScheduleViewOptions {
onCronExpressionChange: (expression: string) => void;
}
export function useScheduleView({
onCronExpressionChange,
}: UseScheduleViewOptions) {
const repeatOptions = useMemo(
() => [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
],
[],
);
const dayItems = useMemo(
() => [
{ value: "0", label: "Su" },
{ value: "1", label: "Mo" },
{ value: "2", label: "Tu" },
{ value: "3", label: "We" },
{ value: "4", label: "Th" },
{ value: "5", label: "Fr" },
{ value: "6", label: "Sa" },
],
[],
);
const [repeat, setRepeat] = useState<string>("weekly");
const [selectedDays, setSelectedDays] = useState<string[]>([]);
const [time, setTime] = useState<string>("00:00");
function handleRepeatChange(value: string) {
setRepeat(value);
}
function handleTimeChange(e: React.ChangeEvent<HTMLInputElement>) {
setTime(e.target.value.trim());
}
function parseTimeToHM(value: string): { h: number; m: number } {
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value || "");
if (!match) return { h: 0, m: 0 };
return { h: Number(match[1]), m: Number(match[2]) };
}
// Helpful default: when switching to Weekly with no days picked, preselect Monday
useEffect(() => {
if (repeat === "weekly" && selectedDays.length === 0)
setSelectedDays(["1"]);
}, [repeat, selectedDays]);
// Build cron string any time repeat/days/time change
useEffect(() => {
const { h, m } = parseTimeToHM(time);
const minute = String(m);
const hour = String(h);
if (repeat === "daily") {
onCronExpressionChange(`${minute} ${hour} * * *`);
return;
}
const dow = selectedDays.length ? selectedDays.join(",") : "*";
onCronExpressionChange(`${minute} ${hour} * * ${dow}`);
}, [repeat, selectedDays, time, onCronExpressionChange]);
function handleSelectAll() {
setSelectedDays(["0", "1", "2", "3", "4", "5", "6"]);
}
function handleWeekdays() {
setSelectedDays(["1", "2", "3", "4", "5"]);
}
function handleWeekends() {
setSelectedDays(["0", "6"]);
}
return {
// state
repeat,
selectedDays,
time,
// derived/static
repeatOptions,
dayItems,
// handlers
setSelectedDays,
handleRepeatChange,
handleTimeChange,
handleSelectAll,
handleWeekdays,
handleWeekends,
};
}

View File

@@ -0,0 +1,30 @@
export function WebhookTriggerBanner() {
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">Webhook Trigger</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
This will create a webhook endpoint that automatically runs your
agent when triggered by external events.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { isEmpty } from "@/lib/utils";
export function validateInputs(
inputSchema: any,
values: Record<string, any>,
): Record<string, string> {
const errors: Record<string, string> = {};
if (!inputSchema?.properties) return errors;
const requiredFields = inputSchema.required || [];
for (const fieldName of requiredFields) {
const fieldSchema = inputSchema.properties[fieldName];
if (!fieldSchema?.hidden && isEmpty(values[fieldName])) {
errors[fieldName] = `${fieldSchema?.title || fieldName} is required`;
}
}
return errors;
}
export function validateCredentials(
credentialsSchema: any,
values: Record<string, CredentialsMetaInput>,
): Record<string, string> {
const errors: Record<string, string> = {};
if (!credentialsSchema?.properties) return errors;
const credentialFields = Object.keys(credentialsSchema.properties);
for (const fieldName of credentialFields) {
if (!values[fieldName]) {
errors[fieldName] = `${fieldName} credentials are required`;
}
}
return errors;
}
export function formatCronExpression(cron: string): string {
// Basic cron expression formatting/validation
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
"Cron expression must have exactly 5 parts: minute hour day month weekday",
);
}
return parts.join(" ");
}
export function parseCronDescription(cron: string): string {
// Simple cron description parser
const parts = cron.split(" ");
if (parts.length !== 5) return cron;
// Handle some common patterns
if (cron === "0 * * * *") return "Every hour";
if (cron === "0 9 * * *") return "Daily at 9:00 AM";
if (cron === "0 9 * * 1") return "Every Monday at 9:00 AM";
if (cron === "0 9 * * 1-5") return "Weekdays at 9:00 AM";
if (cron === "0 9 1 * *") return "Monthly on the 1st at 9:00 AM";
return cron; // Fallback to showing the raw cron
}

View File

@@ -0,0 +1,296 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useState, useCallback, useMemo } from "react";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { usePostV1CreateExecutionSchedule as useCreateSchedule } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
import { ExecuteGraphResponse } from "@/app/api/__generated__/models/executeGraphResponse";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
export type RunVariant =
| "manual"
| "schedule"
| "automatic-trigger"
| "manual-trigger";
interface UseAgentRunModalCallbacks {
onRun?: (execution: ExecuteGraphResponse) => void;
onCreateSchedule?: (schedule: GraphExecutionJobInfo) => void;
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
}
export function useAgentRunModal(
agent: LibraryAgent,
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [showScheduleView, setShowScheduleView] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>({});
const defaultScheduleName = useMemo(() => `Run ${agent.name}`, [agent.name]);
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
const [cronExpression, setCronExpression] = useState("0 9 * * 1");
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.has_external_trigger
? "automatic-trigger"
: "manual";
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "✅ Agent execution started",
description: "Your agent is now running.",
});
callbacks?.onRun?.(response.data);
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to execute agent",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const createScheduleMutation = useCreateSchedule({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "✅ Schedule created",
description: `Agent scheduled to run: ${scheduleName}`,
});
callbacks?.onCreateSchedule?.(response.data);
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to create schedule",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
const setupTriggerMutation = usePostV2SetupTrigger({
mutation: {
onSuccess: (response: any) => {
if (response.status === 200) {
toast({
title: "✅ Trigger setup complete",
description: "Your webhook trigger is now active.",
});
callbacks?.onSetupTrigger?.(response.data);
setIsOpen(false);
}
},
onError: (error: any) => {
toast({
title: "❌ Failed to setup trigger",
description: error.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
// Input schema validation
const agentInputSchema = useMemo(
() => agent.input_schema || { properties: {}, required: [] },
[agent.input_schema],
);
const agentInputFields = useMemo(() => {
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,
),
);
}, [agentInputSchema]);
// Validation logic
const [allRequiredInputsAreSet, missingInputs] = useMemo(() => {
const nonEmptyInputs = new Set(
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
);
const requiredInputs = new Set(
(agentInputSchema.required as string[]) || [],
);
const missing = [...requiredInputs].filter(
(input) => !nonEmptyInputs.has(input),
);
return [missing.length === 0, missing];
}, [agentInputSchema.required, inputValues]);
const notifyMissingInputs = useCallback(
(needScheduleName: boolean = false) => {
const allMissingFields = (
needScheduleName && !scheduleName ? ["schedule_name"] : []
).concat(missingInputs);
toast({
title: "⚠️ Missing required inputs",
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
variant: "destructive",
});
},
[missingInputs, scheduleName, toast],
);
// Action handlers
const handleRun = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingInputs();
return;
}
if (defaultRunType === "automatic-trigger") {
// Setup trigger
if (!scheduleName.trim()) {
toast({
title: "⚠️ Trigger name required",
description: "Please provide a name for your trigger.",
variant: "destructive",
});
return;
}
setupTriggerMutation.mutate({
data: {
name: scheduleName,
description: `Trigger for ${agent.name}`,
graph_id: agent.graph_id,
graph_version: agent.graph_version,
trigger_config: inputValues,
agent_credentials: {}, // TODO: Add credentials handling if needed
},
});
} else {
// Manual execution
executeGraphMutation.mutate({
graphId: agent.graph_id,
graphVersion: agent.graph_version,
data: {
inputs: inputValues,
credentials_inputs: {}, // TODO: Add credentials handling if needed
},
});
}
}, [
allRequiredInputsAreSet,
defaultRunType,
scheduleName,
inputValues,
agent,
notifyMissingInputs,
setupTriggerMutation,
executeGraphMutation,
toast,
]);
const handleSchedule = useCallback(() => {
if (!allRequiredInputsAreSet) {
notifyMissingInputs(true);
return;
}
if (!scheduleName.trim()) {
toast({
title: "⚠️ Schedule name required",
description: "Please provide a name for your schedule.",
variant: "destructive",
});
return;
}
createScheduleMutation.mutate({
graphId: agent.graph_id,
data: {
name: scheduleName,
cron: cronExpression,
inputs: inputValues,
graph_version: agent.graph_version,
credentials: {}, // TODO: Add credentials handling if needed
},
});
}, [
allRequiredInputsAreSet,
scheduleName,
cronExpression,
inputValues,
agent,
notifyMissingInputs,
createScheduleMutation,
toast,
]);
function handleShowSchedule() {
// Initialize with sensible defaults when entering schedule view
setScheduleName((prev) => prev || defaultScheduleName);
setCronExpression((prev) => prev || "0 9 * * 1");
setShowScheduleView(true);
}
function handleGoBack() {
setShowScheduleView(false);
// Reset schedule fields on exit
setScheduleName(defaultScheduleName);
setCronExpression("0 9 * * 1");
}
function handleSetScheduleName(name: string) {
setScheduleName(name);
}
function handleSetCronExpression(expression: string) {
setCronExpression(expression);
}
const hasInputFields = useMemo(() => {
return Object.keys(agentInputFields).length > 0;
}, [agentInputFields]);
return {
isOpen,
setIsOpen,
showScheduleView,
defaultRunType,
inputValues,
setInputValues,
scheduleName,
cronExpression,
allRequiredInputsAreSet,
missingInputs,
agentInputFields,
hasInputFields,
isExecuting: executeGraphMutation.isPending,
isCreatingSchedule: createScheduleMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
handleRun,
handleSchedule,
handleShowSchedule,
handleGoBack,
handleSetScheduleName,
handleSetCronExpression,
};
}

View File

@@ -1,9 +1,11 @@
import { cn } from "@/lib/utils";
type BadgeVariant = "success" | "error" | "info";
type BadgeSize = "small" | "medium";
interface BadgeProps {
variant: BadgeVariant;
size?: BadgeSize;
children: React.ReactNode;
className?: string;
}
@@ -14,16 +16,28 @@ const badgeVariants: Record<BadgeVariant, string> = {
info: "bg-slate-50 text-black",
};
export function Badge({ variant, children, className }: BadgeProps) {
const badgeSizes: Record<BadgeSize, string> = {
small: "px-[6px] py-[3px] text-[0.55rem] leading-4 tracking-widest",
medium: "px-[9px] py-[3px] text-[0.6785rem] leading-5 tracking-wider",
};
export function Badge({
variant,
size = "medium",
children,
className,
}: BadgeProps) {
return (
<span
className={cn(
// Base styles from Figma
"inline-flex items-center gap-2 rounded-[45px] px-[9px] py-[3px]",
"inline-flex items-center gap-2 rounded-[45px]",
// Text styles
"font-sans text-[0.6785rem] font-medium uppercase leading-5 tracking-wider",
"font-sans font-medium uppercase",
// Text overflow handling
"overflow-hidden text-ellipsis",
// Size styles
badgeSizes[size],
// Variant styles
badgeVariants[variant],
className,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -32,6 +32,10 @@ export const variants = {
"font-sans text-[0.75rem] font-normal leading-[1.125rem] text-zinc-800",
"small-medium":
"font-sans text-[0.75rem] font-medium leading-[1.125rem] text-zinc-800",
// Label Text
label:
"font-sans text-[0.6785rem] font-medium uppercase leading-[1.25rem] tracking-[0.06785rem] text-zinc-800",
} as const;
export type Variant = keyof typeof variants;
@@ -49,4 +53,5 @@ export const variantElementMap: Record<Variant, As> = {
"body-medium": "p",
small: "p",
"small-medium": "p",
label: "span",
};

View File

@@ -0,0 +1,286 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ShowMore } from "./ShowMore";
const meta: Meta<typeof ShowMore> = {
title: "Molecules/ShowMore",
component: ShowMore,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## ShowMore Component
A simplified text truncation component that shows a preview of text content with an expand/collapse toggle functionality.
### ✨ Features
- **String content only** - Simplified to only accept string content
- **Text variant integration** - Uses Text component variants for consistent styling
- **Adaptive toggle sizing** - Toggle icons automatically size to match text variant
- **Smart truncation** - Automatically truncates text based on character limit
- **No heading variants** - Only supports body text variants (lead, large, body, small)
- **Inline toggle** - Toggle appears inline at the end of the text
- **TypeScript support** - Full TypeScript interface support
### 🎯 Usage
\`\`\`tsx
<ShowMore
variant="body"
previewLimit={150}
>
This is a long piece of text that will be truncated at the specified
character limit and show a "more" button to expand the full content.
</ShowMore>
\`\`\`
### Props
- **children**: String content to show/truncate
- **previewLimit**: Character limit for preview (default: 100)
- **variant**: Text variant to use (excludes heading variants)
- **className**: Additional classes for root container
- **previewClassName**: Additional classes applied in preview mode
- **expandedClassName**: Additional classes applied in expanded mode
- **toggleClassName**: Additional classes for the toggle button
- **defaultExpanded**: Whether to start in expanded state (default: false)
### Supported Text Variants
- **lead** - Large leading text
- **large**, **large-medium**, **large-semibold** - Large body text variants
- **body**, **body-medium** - Standard body text variants
- **small**, **small-medium** - Small text variants
`,
},
},
},
tags: ["autodocs"],
argTypes: {
children: {
control: "text",
description: "String content to show with truncation",
},
previewLimit: {
control: "number",
description: "Character limit for preview text",
table: {
defaultValue: { summary: "100" },
},
},
variant: {
control: "select",
options: [
"lead",
"large",
"large-medium",
"large-semibold",
"body",
"body-medium",
"small",
"small-medium",
],
description: "Text variant to use for styling",
table: {
defaultValue: { summary: "body" },
},
},
className: {
control: "text",
description: "Additional CSS classes for root container",
},
toggleClassName: {
control: "text",
description: "Additional CSS classes for the toggle button",
},
defaultExpanded: {
control: "boolean",
description: "Whether to start in expanded state",
table: {
defaultValue: { summary: "false" },
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Basic text truncation with default body variant and 100 character limit.
*/
export const Default: Story = {
args: {
children:
"This is a longer piece of text that will be truncated at the preview limit. When you click 'more', you'll see the full content. This demonstrates the basic functionality of the ShowMore component with plain text content.",
variant: "body",
previewLimit: 100,
},
};
/**
* Short text that doesn't need truncation - toggle won't appear.
*/
export const ShortText: Story = {
args: {
children: "This text is short enough that no truncation is needed.",
variant: "body",
previewLimit: 100,
},
};
/**
* Large leading text variant with custom preview limit.
*/
export const LeadVariant: Story = {
args: {
children:
"This example uses the lead text variant which is larger and more prominent. The toggle icons automatically scale to match the text size for a cohesive design.",
variant: "lead",
previewLimit: 80,
},
};
/**
* Large text variants demonstration.
*/
export const LargeVariants: Story = {
render: () => (
<div className="max-w-2xl space-y-4">
<ShowMore variant="large" previewLimit={60}>
Large variant: This demonstrates how the ShowMore component works with
the large text variant and how the toggle scales appropriately.
</ShowMore>
<ShowMore variant="large-medium" previewLimit={60}>
Large medium variant: This shows the medium weight version of the large
text variant with proper toggle sizing.
</ShowMore>
<ShowMore variant="large-semibold" previewLimit={60}>
Large semibold variant: This demonstrates the semibold version with
heavier font weight and matching toggle.
</ShowMore>
</div>
),
};
/**
* Body text variants demonstration.
*/
export const BodyVariants: Story = {
render: () => (
<div className="max-w-xl space-y-4">
<ShowMore variant="body" previewLimit={70}>
Body variant: This is the default text variant used for most content. It
provides good readability and spacing.
</ShowMore>
<ShowMore variant="body-medium" previewLimit={70}>
Body medium variant: This uses medium font weight for slightly more
emphasis while maintaining readability.
</ShowMore>
</div>
),
};
/**
* Small text variants demonstration.
*/
export const SmallVariants: Story = {
render: () => (
<div className="max-w-lg space-y-4">
<ShowMore variant="small" previewLimit={80}>
Small variant: This demonstrates the small text variant which is useful
for secondary information, captions, or footnotes where space is
limited.
</ShowMore>
<ShowMore variant="small-medium" previewLimit={80}>
Small medium variant: This uses the small size with medium font weight
for small text that needs slightly more emphasis.
</ShowMore>
</div>
),
};
/**
* Custom preview limit of 50 characters.
*/
export const CustomLimit: Story = {
args: {
children:
"This example shows how you can customize the preview limit to show more or less text in the initial preview before truncation occurs.",
variant: "body",
previewLimit: 50,
},
};
/**
* Component that starts in expanded state.
*/
export const DefaultExpanded: Story = {
args: {
children:
"This ShowMore component starts in the expanded state by default. You can click 'less' to collapse it to the preview mode. This is useful when you want to show the full content initially but still provide the option to collapse it.",
variant: "body",
previewLimit: 80,
defaultExpanded: true,
},
};
/**
* Custom styling for different states.
*/
export const CustomStyling: Story = {
args: {
children:
"This example demonstrates custom styling options. The preview state has a different background color, the expanded state has different padding, and the toggle button has custom styling to match your design system.",
variant: "body-medium",
previewLimit: 80,
className: "max-w-md",
toggleClassName: "text-blue-600 hover:text-blue-800",
},
};
/**
* Very long content to demonstrate with different text sizes.
*/
export const LongContent: Story = {
render: () => (
<div className="max-w-2xl space-y-6">
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Lead Text</h3>
<ShowMore variant="lead" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMore>
</div>
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Body Text</h3>
<ShowMore variant="body" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMore>
</div>
<div>
<h3 className="mb-2 text-sm font-medium text-gray-500">Small Text</h3>
<ShowMore variant="small" previewLimit={120}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
pariatur.
</ShowMore>
</div>
</div>
),
};

View File

@@ -0,0 +1,79 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { getIconSize, ShowMoreTextVariant } from "./helpers";
interface ShowMoreProps {
children: string;
previewLimit?: number;
variant?: ShowMoreTextVariant;
className?: string;
toggleClassName?: string;
defaultExpanded?: boolean;
}
export function ShowMore({
children,
previewLimit = 100,
variant = "body",
className,
toggleClassName,
defaultExpanded = false,
}: ShowMoreProps) {
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
const shouldTruncate = children.length > previewLimit;
const previewText = shouldTruncate
? children.slice(0, previewLimit)
: children;
const displayText = isExpanded ? children : previewText;
const iconSize = getIconSize(variant);
if (!shouldTruncate) {
return (
<Text variant={variant} className={cn(className)}>
{children}
</Text>
);
}
return (
<Text
variant={variant}
className={cn(
isExpanded
? "flex-end flex flex-wrap items-center"
: "flex-start flex flex-wrap items-center",
className,
)}
>
{displayText}
{!isExpanded && "..."}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"ml-1 inline-flex items-center gap-1 font-medium text-black",
toggleClassName,
)}
type="button"
>
{isExpanded ? (
<>
<CaretUpIcon size={iconSize} weight="bold" />
<span>less</span>
</>
) : (
<>
<CaretDownIcon size={iconSize} weight="bold" />
<span>more</span>
</>
)}
</button>
</Text>
);
}
export default ShowMore;

View File

@@ -0,0 +1,25 @@
import { TextVariant } from "@/components/atoms/Text/Text";
export type ShowMoreTextVariant = Exclude<
TextVariant,
"h1" | "h2" | "h3" | "h4"
>;
export function getIconSize(variant: ShowMoreTextVariant): number {
switch (variant) {
case "lead":
return 20;
case "large":
case "large-medium":
case "large-semibold":
return 16;
case "body":
case "body-medium":
return 14;
case "small":
case "small-medium":
return 12;
default:
return 14;
}
}

View File

@@ -17,3 +17,13 @@ export function formatTimeAgo(dateStr: string): string {
const diffDays = Math.floor(diffHours / HOURS_PER_DAY);
return `${diffDays}d ago`;
}
export function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}