feat(frontend): run agent page improvements (#10879)

## Changes 🏗️

- Add all the cron scheduling options ( _yearly, monthly, weekly,
custom, etc..._ ) using the new Design System components
- Add missing agent/run actions: export agent + delete agent

## 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] Run the app locally with `new-agent-runs` enabled
  - [x] Test the above 

### For configuration changes:

None
This commit is contained in:
Ubbe
2025-09-10 16:44:51 +09:00
committed by GitHub
parent f89717153f
commit 986245ec43
17 changed files with 844 additions and 136 deletions

View File

@@ -112,6 +112,7 @@ function renderImage(
return (
<div className="group relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={altText}

View File

@@ -358,6 +358,7 @@ function renderMarkdown(
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
@@ -410,8 +411,8 @@ function getCopyContentMarkdown(
return {
mimeType: "text/markdown",
data: markdownText,
alternativeMimeTypes: ["text/plain"],
fallbackText: markdownText,
alternativeMimeTypes: ["text/plain"],
};
}

View File

@@ -163,11 +163,11 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
</div>
{/* Schedule Section - always visible */}
<div className="mt-8">
<div className="mt-4">
<AgentSectionHeader title="Schedule Setup" />
{showScheduleView ? (
<>
<div className="mb-3 flex justify-start">
<div className="my-4 flex justify-start">
<Button
variant="secondary"
size="small"
@@ -187,7 +187,7 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
/>
</>
) : (
<div className="flex flex-col items-start gap-2">
<div className="mt-2 flex flex-col items-start gap-2">
<Text variant="body" className="mb-3 !text-zinc-500">
No schedule configured. Create a schedule to run this
agent automatically at a specific time.{" "}

View File

@@ -0,0 +1,267 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { makeCronExpression } from "@/lib/cron-expression-utils";
import { FrequencySelect } from "./FrequencySelect";
import { WeeklyPicker } from "./WeeklyPicker";
import { MonthlyPicker } from "./MonthlyPicker";
import { YearlyPicker } from "./YearlyPicker";
import { CustomInterval } from "./CustomInterval";
import { TimeAt } from "./TimeAt";
export type CronFrequency =
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "custom"
| "every minute";
type Props = {
onCronExpressionChange: (cron: string) => void;
initialCronExpression?: string;
};
export function CronScheduler({
onCronExpressionChange,
initialCronExpression,
}: Props) {
const [frequency, setFrequency] = useState<CronFrequency>("daily");
const [selectedMinute, setSelectedMinute] = useState<string>("0");
const [selectedTime, setSelectedTime] = useState<string>("09:00");
const [selectedWeekDays, setSelectedWeekDays] = useState<number[]>([]);
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>([]);
const [selectedMonths, setSelectedMonths] = useState<number[]>([]);
const [customInterval, setCustomInterval] = useState<{
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
// Parse provided cron only once to avoid feedback loops
const parsedOnceRef = useRef(false);
useEffect(() => {
if (parsedOnceRef.current) return;
parsedOnceRef.current = true;
if (!initialCronExpression) {
setFrequency("daily");
setSelectedWeekDays([]);
setSelectedMonthDays([]);
setSelectedMonths([]);
return;
}
const parts = initialCronExpression.trim().split(/\s+/);
if (parts.length !== 5) return;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
setSelectedWeekDays([]);
setSelectedMonthDays([]);
setSelectedMonths([]);
if (
minute === "*" &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("every minute");
return;
}
if (
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/")
) {
setFrequency("hourly");
setSelectedMinute(minute);
return;
}
if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("custom");
const interval = parseInt(minute.substring(2));
if (!isNaN(interval))
setCustomInterval({ value: interval, unit: "minutes" });
return;
}
if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("custom");
const interval = parseInt(hour.substring(2));
if (!isNaN(interval))
setCustomInterval({ value: interval, unit: "hours" });
return;
}
if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
setFrequency("custom");
const interval = parseInt(dayOfMonth.substring(2));
if (!isNaN(interval)) {
setCustomInterval({ value: interval, unit: "days" });
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum))
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
return;
}
if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
setFrequency("daily");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum))
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
return;
}
if (dayOfWeek !== "*" && dayOfMonth === "*" && month === "*") {
setFrequency("weekly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum))
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
const days = dayOfWeek
.split(",")
.map((d) => parseInt(d))
.filter((d) => !isNaN(d));
setSelectedWeekDays(days);
return;
}
if (dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") {
setFrequency("monthly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum))
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
const days = dayOfMonth
.split(",")
.map((d) => parseInt(d))
.filter((d) => !isNaN(d) && d >= 1 && d <= 31);
setSelectedMonthDays(days);
return;
}
if (dayOfMonth !== "*" && month !== "*" && dayOfWeek === "*") {
setFrequency("yearly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum))
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
const months = month
.split(",")
.map((m) => parseInt(m))
.filter((m) => !isNaN(m) && m >= 1 && m <= 12);
setSelectedMonths(months);
}
}, [initialCronExpression]);
const cronExpression = useMemo(
() =>
makeCronExpression({
frequency,
minute:
frequency === "hourly"
? parseInt(selectedMinute)
: parseInt(selectedTime.split(":")[1]),
hour: parseInt(selectedTime.split(":")[0]),
days:
frequency === "weekly"
? selectedWeekDays
: frequency === "monthly"
? selectedMonthDays
: [],
months: frequency === "yearly" ? selectedMonths : [],
customInterval:
frequency === "custom"
? customInterval
: { unit: "minutes", value: 1 },
}),
[
frequency,
selectedMinute,
selectedTime,
selectedWeekDays,
selectedMonthDays,
selectedMonths,
customInterval,
],
);
useEffect(() => {
onCronExpressionChange(cronExpression);
}, [cronExpression, onCronExpressionChange]);
return (
<div>
<FrequencySelect
value={frequency}
onChange={setFrequency}
selectedMinute={selectedMinute}
onMinuteChange={setSelectedMinute}
/>
{frequency === "custom" && (
<CustomInterval value={customInterval} onChange={setCustomInterval} />
)}
{frequency === "weekly" && (
<WeeklyPicker
values={selectedWeekDays}
onChange={setSelectedWeekDays}
/>
)}
{frequency === "monthly" && (
<MonthlyPicker
values={selectedMonthDays}
onChange={setSelectedMonthDays}
/>
)}
{frequency === "yearly" && (
<YearlyPicker values={selectedMonths} onChange={setSelectedMonths} />
)}
{frequency !== "hourly" && (
<TimeAt value={selectedTime} onChange={setSelectedTime} />
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import React from "react";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
export function CustomInterval({
value,
onChange,
}: {
value: { value: number; unit: "minutes" | "hours" | "days" };
onChange: (v: { value: number; unit: "minutes" | "hours" | "days" }) => void;
}) {
return (
<div className="flex items-end gap-3">
<Input
id="custom-interval-value"
label="Every"
type="number"
min={1}
value={value.value}
onChange={(e) =>
onChange({ ...value, value: parseInt(e.target.value || "1") })
}
className="max-w-24"
size="small"
/>
<Select
id="custom-interval-unit"
label="Interval"
size="small"
value={value.unit}
onValueChange={(v) => onChange({ ...value, unit: v as any })}
options={[
{ label: "Minutes", value: "minutes" },
{ label: "Hours", value: "hours" },
{ label: "Days", value: "days" },
]}
className="max-w-40"
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import React from "react";
import { Select } from "@/components/atoms/Select/Select";
type CronFrequency =
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "custom"
| "every minute";
export function FrequencySelect({
value,
onChange,
selectedMinute,
onMinuteChange,
}: {
value: CronFrequency;
onChange: (v: CronFrequency) => void;
selectedMinute: string;
onMinuteChange: (v: string) => void;
}) {
return (
<>
<Select
id="repeat"
label="Repeats"
size="small"
value={value}
onValueChange={(v) => onChange(v as CronFrequency)}
options={[
{ label: "Every Hour", value: "hourly" },
{ label: "Daily", value: "daily" },
{ label: "Weekly", value: "weekly" },
{ label: "Monthly", value: "monthly" },
{ label: "Yearly", value: "yearly" },
{ label: "Custom", value: "custom" },
]}
className="max-w-80"
/>
{value === "hourly" && (
<Select
id="at-minute"
label="At minute"
size="small"
value={selectedMinute}
onValueChange={(v) => onMinuteChange(v)}
options={["0", "15", "30", "45"].map((m) => ({ label: m, value: m }))}
className="max-w-32"
/>
)}
</>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
export function MonthlyPicker({
values,
onChange,
}: {
values: number[];
onChange: (v: number[]) => void;
}) {
function allDays() {
onChange(Array.from({ length: 31 }, (_, i) => i + 1));
}
function customize() {
onChange([]);
}
const items = Array.from({ length: 31 }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}));
const selected = values.map((v) => String(v));
return (
<div className="mb-6 space-y-2">
<Text variant="body-medium" as="span" className="text-black">
Days of Month
</Text>
<div className="flex 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={allDays}
>
All Days
</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={customize}
>
Customize
</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={() => onChange([15])}
>
15th
</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={() => onChange([31])}
>
Last Day
</button>
</div>
{values.length < 31 && (
<MultiToggle
items={items}
selectedValues={selected}
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
aria-label="Select days of month"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Select } from "@/components/atoms/Select/Select";
export function TimeAt({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [hour12, setHour12] = useState<string>("9");
const [minute, setMinute] = useState<string>("00");
const [ampm, setAmPm] = useState<"AM" | "PM">("AM");
// Parse incoming 24h value → local 12h selects
useEffect(() => {
const [hStr, mStr] = (value || "09:00").split(":");
const h24 = Math.max(0, Math.min(23, parseInt(hStr || "9", 10) || 9));
const m = Math.max(0, Math.min(59, parseInt(mStr || "0", 10) || 0));
const isPm = h24 >= 12;
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
setHour12(String(h12));
setMinute(m.toString().padStart(2, "0"));
setAmPm(isPm ? "PM" : "AM");
}, [value]);
const hourOptions = useMemo(
() => Array.from({ length: 12 }, (_, i) => String(i + 1)),
[],
);
const minuteOptions = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, "0")),
[],
);
function emit(h12Str: string, mStr: string, meridiem: "AM" | "PM") {
const h12Num = Math.max(
1,
Math.min(12, parseInt(h12Str || "12", 10) || 12),
);
const mNum = Math.max(0, Math.min(59, parseInt(mStr || "0", 10) || 0));
let h24 = h12Num % 12;
if (meridiem === "PM") h24 += 12;
const next = `${h24.toString().padStart(2, "0")}:${mNum
.toString()
.padStart(2, "0")}`;
onChange(next);
}
return (
<div className="flex items-end gap-2">
<div className="relative">
<label className="mb-1 block text-xs font-medium text-zinc-700">
At
</label>
<div className="flex items-center gap-2">
<Select
id="time-hour"
label=""
size="small"
value={hour12}
onValueChange={(v) => {
setHour12(v);
emit(v, minute, ampm);
}}
options={hourOptions.map((h) => ({ label: h, value: h }))}
className="max-w-20"
/>
<Select
id="time-minute"
label=""
size="small"
value={minute}
onValueChange={(v) => {
setMinute(v);
emit(hour12, v, ampm);
}}
options={minuteOptions.map((m) => ({ label: m, value: m }))}
className="max-w-24"
/>
<Select
id="time-meridiem"
label=""
size="small"
value={ampm}
onValueChange={(v) => {
const mer = (v as "AM" | "PM") || "AM";
setAmPm(mer);
emit(hour12, minute, mer);
}}
options={[
{ label: "AM", value: "AM" },
{ label: "PM", value: "PM" },
]}
className="max-w-24"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
const weekDays = [
{ label: "Su", value: 0 },
{ label: "Mo", value: 1 },
{ label: "Tu", value: 2 },
{ label: "We", value: 3 },
{ label: "Th", value: 4 },
{ label: "Fr", value: 5 },
{ label: "Sa", value: 6 },
];
export function WeeklyPicker({
values,
onChange,
}: {
values: number[];
onChange: (v: number[]) => void;
}) {
function toggleAll() {
if (values.length === weekDays.length) onChange([]);
else onChange(weekDays.map((d) => d.value));
}
function setWeekdays() {
onChange([1, 2, 3, 4, 5]);
}
function setWeekends() {
onChange([0, 6]);
}
const items = weekDays.map((d) => ({
value: String(d.value),
label: d.label,
}));
const selectedValues = values.map((v) => String(v));
return (
<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={toggleAll}
>
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={setWeekdays}
>
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={setWeekends}
>
Weekends
</button>
</div>
<MultiToggle
items={items}
selectedValues={selectedValues}
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
aria-label="Select days of week"
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
const months = [
{ label: "Jan", value: 1 },
{ label: "Feb", value: 2 },
{ label: "Mar", value: 3 },
{ label: "Apr", value: 4 },
{ label: "May", value: 5 },
{ label: "Jun", value: 6 },
{ label: "Jul", value: 7 },
{ label: "Aug", value: 8 },
{ label: "Sep", value: 9 },
{ label: "Oct", value: 10 },
{ label: "Nov", value: 11 },
{ label: "Dec", value: 12 },
];
export function YearlyPicker({
values,
onChange,
}: {
values: number[];
onChange: (v: number[]) => void;
}) {
function toggleAll() {
if (values.length === months.length) onChange([]);
else onChange(months.map((m) => m.value));
}
const items = months.map((m) => ({ value: String(m.value), label: m.label }));
const selected = values.map((v) => String(v));
return (
<div className="mb-6 space-y-2">
<Text variant="body-medium" as="span" className="text-black">
Months
</Text>
<div className="flex 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={toggleAll}
>
{values.length === months.length ? "Deselect All" : "Select All"}
</button>
</div>
<MultiToggle
items={items}
selectedValues={selected}
onChange={(sv) => onChange(sv.map((s) => parseInt(s)))}
aria-label="Select months"
/>
</div>
);
}

View File

@@ -22,7 +22,7 @@ export function DefaultRunView() {
} = useRunAgentModalContext();
return (
<div className="mb-12 mt-6">
<div className="my-4">
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
{/* Preset/Trigger fields */}

View File

@@ -1,11 +1,9 @@
import { Input } from "@/components/atoms/Input/Input";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
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";
import { TimezoneNotice } from "../TimezoneNotice/TimezoneNotice";
import { CronScheduler } from "../CronScheduler/CronScheduler";
interface Props {
scheduleName: string;
@@ -24,41 +22,22 @@ export function ScheduleView({
onCronExpressionChange,
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,
});
(partial: { scheduleName?: string }) => {
const fieldErrors = validateSchedule({ scheduleName, ...partial });
setErrors(fieldErrors);
if (onValidityChange)
onValidityChange(Object.keys(fieldErrors).length === 0);
},
[scheduleName, time, onValidityChange],
[scheduleName, onValidityChange],
);
return (
@@ -67,12 +46,14 @@ export function ScheduleView({
id="schedule-name"
label="Schedule Name"
value={scheduleName}
size="small"
onChange={(e) => {
handleScheduleNameChange(e);
validateNow({ scheduleName: e.target.value });
}}
placeholder="Enter a name for this schedule"
error={errors.scheduleName}
className="max-w-80"
/>
{recommendedScheduleCron && (
@@ -84,65 +65,15 @@ export function ScheduleView({
</div>
)}
<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}
/>
<div className="-mt-4">
<div className="mt-1">
<CronScheduler
onCronExpressionChange={onCronExpressionChange}
initialCronExpression={
_cronExpression || recommendedScheduleCron || undefined
}
/>
</div>
<div className="mt-2 w-fit">
<TimezoneNotice />
</div>
</div>

View File

@@ -2,23 +2,16 @@ import { RunStatusBadge } from "../RunDetails/components/RunStatusBadge";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import {
PencilSimpleIcon,
TrashIcon,
StopIcon,
PlayIcon,
ArrowSquareOut,
ArrowSquareOutIcon,
} from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import moment from "moment";
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { useRunDetailHeader } from "./useRunDetailHeader";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import Link from "next/link";
import { AgentActions } from "./components/AgentActions";
type Props = {
agent: LibraryAgent;
@@ -77,40 +70,28 @@ export function RunDetailHeader({
>
<TrashIcon size={16} /> Delete run
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canStop ? (
<DropdownMenuItem onClick={stopRun} disabled={isStopping}>
<StopIcon size={14} className="mr-2" /> Stop run
</DropdownMenuItem>
) : null}
{openInBuilderHref ? (
<DropdownMenuItem asChild>
<Link
href={openInBuilderHref}
target="_blank"
className="flex items-center gap-2"
>
<ArrowSquareOut size={14} /> Open in builder
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
className="flex items-center gap-2"
>
<PencilSimpleIcon size={16} /> Edit agent
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{openInBuilderHref ? (
<Button
variant="secondary"
size="small"
as="NextLink"
href={openInBuilderHref}
target="_blank"
>
<ArrowSquareOutIcon size={16} /> Open in builder
</Button>
) : null}
{canStop ? (
<Button
variant="destructive"
size="small"
onClick={stopRun}
disabled={isStopping}
>
<StopIcon size={14} /> Stop run
</Button>
) : null}
<AgentActions agent={agent} />
</div>
) : null}
</div>

View File

@@ -0,0 +1,111 @@
"use client";
import React, { useCallback } from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import Link from "next/link";
import {
FileArrowDownIcon,
PencilSimpleIcon,
TrashIcon,
} from "@phosphor-icons/react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useRouter } from "next/navigation";
import { useDeleteV2DeleteLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { getV1GetGraphVersion } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { exportAsJSONFile } from "@/lib/utils";
import { useToast } from "@/components/molecules/Toast/use-toast";
interface AgentActionsProps {
agent: LibraryAgent;
}
export function AgentActions({ agent }: AgentActionsProps) {
const router = useRouter();
const { toast } = useToast();
const deleteMutation = useDeleteV2DeleteLibraryAgent();
const handleExport = useCallback(async () => {
try {
const res = await getV1GetGraphVersion(
agent.graph_id,
agent.graph_version,
{ for_export: true },
);
if (res.status === 200) {
const filename = `${agent.name}_v${agent.graph_version}.json`;
exportAsJSONFile(res.data as any, filename);
toast({ title: "Agent exported" });
} else {
toast({ title: "Failed to export agent", variant: "destructive" });
}
} catch (e: any) {
toast({
title: "Failed to export agent",
description: e?.message,
variant: "destructive",
});
}
}, [agent.graph_id, agent.graph_version, agent.name, toast]);
const handleDelete = useCallback(() => {
if (!agent?.id) return;
const confirmed = window.confirm(
"Are you sure you want to delete this agent? This action cannot be undone.",
);
if (!confirmed) return;
deleteMutation.mutate(
{ libraryAgentId: agent.id },
{
onSuccess: () => {
toast({ title: "Agent deleted" });
router.push("/library");
},
onError: (error: any) =>
toast({
title: "Failed to delete agent",
description: error?.message,
variant: "destructive",
}),
},
);
}, [agent.id, deleteMutation, router, toast]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
className="flex items-center gap-2"
>
<PencilSimpleIcon size={16} /> Edit agent
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleExport}
className="flex items-center gap-2"
>
<FileArrowDownIcon size={16} /> Export agent to file
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="flex items-center gap-2"
>
<TrashIcon size={16} /> Delete agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -83,8 +83,8 @@ export function RunOutputs({ outputs }: RunOutputsProps) {
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-end">
<div className="relative flex flex-col gap-4">
<div className="absolute -top-3 right-0">
<OutputActions
items={items.map((item) => ({
value: item.value,

View File

@@ -17,7 +17,7 @@ type Args = {
export function useRunsSidebar({ graphId, onSelectRun }: Args) {
const params = useSearchParams();
const existingRunId = params.get("run") as string | undefined;
const existingRunId = params.get("executionId") as string | undefined;
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
const runsQuery = useGetV1ListGraphExecutionsInfinite(
@@ -97,6 +97,12 @@ export function useRunsSidebar({ graphId, onSelectRun }: Args) {
const schedules: GraphExecutionJobInfo[] =
schedulesQuery.data?.status === 200 ? schedulesQuery.data.data : [];
// If there are no runs but there are schedules, and nothing is selected, auto-select the first schedule
useEffect(() => {
if (!existingRunId && runs.length === 0 && schedules.length > 0)
onSelectRun(`schedule:${schedules[0].id}`);
}, [existingRunId, runs.length, schedules, onSelectRun]);
return {
runs,
schedules,

View File

@@ -146,7 +146,7 @@ export function ActivityDropdown({
<Text variant="body-medium" className="!text-black">
{searchQuery
? "No matching agents found"
: "Nothing to show yet"}
: "No recent runs to show yet"}
</Text>
<Text variant="body" className="!text-zinc-500">
{searchQuery