Files
multica/packages/views/autopilots/components/trigger-config.tsx
Jiayuan Zhang d88fe2608e feat(autopilot): scheduled/triggered automations for AI agents (#1028)
* feat(autopilot): add scheduled/triggered automation for AI agents

Introduce the Autopilot feature — recurring automations that assign work
to AI agents on a schedule or manual trigger. Supports two execution
modes: create_issue (creates an issue for the agent to work on) and
run_only (directly enqueues an agent task without issue pollution).

Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService
with concurrency policies (skip/queue/replace), HTTP CRUD + trigger
endpoints, background cron scheduler (30s tick), event listeners for
issue→run and task→run status sync.

Frontend: types, API client methods, TanStack Query hooks with optimistic
mutations, realtime cache invalidation, list page with create dialog,
detail page with trigger management and run history, sidebar nav + routes
for both web and desktop apps.

* feat(autopilot): improve UX — trigger config, edit dialog, template gallery

- Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone
- Fix Select components showing UUIDs instead of names (Base UI render function pattern)
- Add Edit button on detail page opening a unified edit dialog
- Remove project/concurrency/issue-title-template from create/edit (simplify for users)
- Add trigger configuration inline during autopilot creation
- Add template gallery on empty state (6 step-by-step workflow templates)
- Rename "Description" to "Prompt" throughout UI
- Inject autopilot run timestamp into issue description for agent date awareness
- Treat issue status "in_review" as run completion (fixes skip on next trigger)
- Make migration idempotent with IF NOT EXISTS clauses
2026-04-15 04:54:37 +08:00

285 lines
9.2 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { cn } from "@multica/ui/lib/utils";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "custom";
export interface TriggerConfig {
frequency: TriggerFrequency;
time: string; // HH:MM
dayOfWeek: number; // 0=Sun … 6=Sat
cronExpression: string; // only used when frequency === "custom"
timezone: string; // IANA
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
{ value: "hourly", label: "Hourly" },
{ value: "daily", label: "Daily" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "custom", label: "Custom" },
];
const DAYS_OF_WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Sao_Paulo",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Seoul",
"Australia/Sydney",
"Pacific/Auckland",
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function getLocalTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "UTC";
}
}
function getTimezoneOffset(tz: string): string {
if (tz === "UTC") return "UTC";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? tz;
} catch {
return tz;
}
}
function getTimezoneLabel(tz: string): string {
if (tz === "UTC") return "UTC";
const city = tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
return `${city} (${getTimezoneOffset(tz)})`;
}
function formatTime12h(time: string): string {
const [h, m] = time.split(":");
const hour = parseInt(h ?? "9", 10);
const min = parseInt(m ?? "0", 10);
const ampm = hour >= 12 ? "PM" : "AM";
return `${hour % 12 || 12}:${min.toString().padStart(2, "0")} ${ampm}`;
}
// ---------------------------------------------------------------------------
// Public helpers
// ---------------------------------------------------------------------------
export function getDefaultTriggerConfig(): TriggerConfig {
return {
frequency: "daily",
time: "09:00",
dayOfWeek: 1,
cronExpression: "0 9 * * 1-5",
timezone: getLocalTimezone(),
};
}
export function toCronExpression(cfg: TriggerConfig): string {
const [h, m] = cfg.time.split(":");
const hour = parseInt(h ?? "9", 10);
const min = parseInt(m ?? "0", 10);
switch (cfg.frequency) {
case "hourly":
return `${min} * * * *`;
case "daily":
return `${min} ${hour} * * *`;
case "weekdays":
return `${min} ${hour} * * 1-5`;
case "weekly":
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
case "custom":
return cfg.cronExpression;
}
}
export function describeTrigger(cfg: TriggerConfig): string {
const offset = getTimezoneOffset(cfg.timezone);
switch (cfg.frequency) {
case "hourly": {
const min = parseInt(cfg.time.split(":")[1] ?? "0", 10);
return `Runs every hour at :${min.toString().padStart(2, "0")}`;
}
case "daily":
return `Runs daily at ${formatTime12h(cfg.time)} ${offset}`;
case "weekdays":
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
case "weekly":
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
case "custom":
return `Custom schedule: ${cfg.cronExpression}`;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function TriggerConfigSection({
config,
onChange,
}: {
config: TriggerConfig;
onChange: (config: TriggerConfig) => void;
}) {
const timezones = useMemo(() => {
const local = getLocalTimezone();
const set = new Set(COMMON_TIMEZONES);
return set.has(local) ? COMMON_TIMEZONES : [local, ...COMMON_TIMEZONES];
}, []);
return (
<div className="space-y-3">
{/* Frequency tabs */}
<div className="flex flex-wrap gap-1">
{FREQUENCIES.map((f) => (
<button
key={f.value}
type="button"
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
config.frequency === f.value
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, frequency: f.value })}
>
{f.label}
</button>
))}
</div>
{config.frequency === "custom" ? (
/* Custom cron input */
<div>
<label className="text-xs text-muted-foreground">Cron Expression</label>
<input
type="text"
value={config.cronExpression}
onChange={(e) => onChange({ ...config, cronExpression: e.target.value })}
placeholder="0 9 * * 1-5"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground mt-1">
Standard 5-field cron (min hour dom month dow)
</p>
</div>
) : (
<>
{/* Time + Timezone row */}
<div className="flex gap-3">
{config.frequency === "hourly" ? (
<div className="w-24">
<label className="text-xs text-muted-foreground">Minute</label>
<input
type="number"
min={0}
max={59}
value={parseInt(config.time.split(":")[1] ?? "0", 10)}
onChange={(e) => {
const min = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
onChange({ ...config, time: `00:${min.toString().padStart(2, "0")}` });
}}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
</div>
) : (
<>
<div className="w-28">
<label className="text-xs text-muted-foreground">Time</label>
<input
type="time"
value={config.time}
onChange={(e) => onChange({ ...config, time: e.target.value || config.time })}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex-1 min-w-0">
<label className="text-xs text-muted-foreground">Timezone</label>
<Select
value={config.timezone}
onValueChange={(v) => v && onChange({ ...config, timezone: v })}
>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{() => getTimezoneLabel(config.timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{timezones.map((tz) => (
<SelectItem key={tz} value={tz}>
{getTimezoneLabel(tz)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
{/* Day-of-week selector for weekly */}
{config.frequency === "weekly" && (
<div>
<label className="text-xs text-muted-foreground">Day</label>
<div className="flex gap-1 mt-1">
{DAYS_OF_WEEK.map((day, i) => (
<button
key={day}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
config.dayOfWeek === i
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, dayOfWeek: i })}
>
{day}
</button>
))}
</div>
</div>
)}
</>
)}
{/* Human-readable preview */}
<p className="text-xs text-muted-foreground">{describeTrigger(config)}</p>
</div>
);
}