Redesign routines UI to match issue page design language
- Remove Card wrappers and gray backgrounds from routine detail - Use max-w-2xl container layout like issue detail page - Add icons to tabs (Clock, Play, ListTree, Activity) matching issue tabs - Make activity tab compact (single-line items with space-y-1.5) - Create shared RunButton and PauseResumeButton components - Build user-friendly ScheduleEditor with presets (hourly, daily, weekdays, weekly, monthly) - Auto-detect timezone via Intl API instead of manual timezone input - Use shared action buttons in both AgentDetail and RoutineDetail - Replace bordered run history cards with compact divided list Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
301437e169
commit
6a7e2d3fce
4 changed files with 853 additions and 527 deletions
51
ui/src/components/AgentActionButtons.tsx
Normal file
51
ui/src/components/AgentActionButtons.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function RunButton({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
label = "Run now",
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onClick} disabled={disabled}>
|
||||||
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PauseResumeButton({
|
||||||
|
isPaused,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
disabled,
|
||||||
|
size = "sm",
|
||||||
|
}: {
|
||||||
|
isPaused: boolean;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
if (isPaused) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
|
||||||
|
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Resume</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size={size} onClick={onPause} disabled={disabled}>
|
||||||
|
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Pause</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
ui/src/components/ScheduleEditor.tsx
Normal file
333
ui/src/components/ScheduleEditor.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
type SchedulePreset = "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom";
|
||||||
|
|
||||||
|
const PRESETS: { value: SchedulePreset; label: string }[] = [
|
||||||
|
{ value: "every_hour", label: "Every hour" },
|
||||||
|
{ value: "every_day", label: "Every day" },
|
||||||
|
{ value: "weekdays", label: "Weekdays" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "monthly", label: "Monthly" },
|
||||||
|
{ value: "custom", label: "Custom (cron)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
value: String(i),
|
||||||
|
label: i === 0 ? "12:00 AM" : i < 12 ? `${i}:00 AM` : i === 12 ? "12:00 PM" : `${i - 12}:00 PM`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const MINUTES = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
value: String(i * 5),
|
||||||
|
label: String(i * 5).padStart(2, "0"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: "1", label: "Mon" },
|
||||||
|
{ value: "2", label: "Tue" },
|
||||||
|
{ value: "3", label: "Wed" },
|
||||||
|
{ value: "4", label: "Thu" },
|
||||||
|
{ value: "5", label: "Fri" },
|
||||||
|
{ value: "6", label: "Sat" },
|
||||||
|
{ value: "0", label: "Sun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({
|
||||||
|
value: String(i + 1),
|
||||||
|
label: String(i + 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function parseCronToPreset(cron: string): {
|
||||||
|
preset: SchedulePreset;
|
||||||
|
hour: string;
|
||||||
|
minute: string;
|
||||||
|
dayOfWeek: string;
|
||||||
|
dayOfMonth: string;
|
||||||
|
} {
|
||||||
|
const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" };
|
||||||
|
|
||||||
|
if (!cron || !cron.trim()) {
|
||||||
|
return { preset: "every_day", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = cron.trim().split(/\s+/);
|
||||||
|
if (parts.length !== 5) {
|
||||||
|
return { preset: "custom", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [min, hr, dom, , dow] = parts;
|
||||||
|
|
||||||
|
// Every hour: "0 * * * *"
|
||||||
|
if (hr === "*" && dom === "*" && dow === "*") {
|
||||||
|
return { preset: "every_hour", ...defaults, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every day: "M H * * *"
|
||||||
|
if (dom === "*" && dow === "*" && hr !== "*") {
|
||||||
|
return { preset: "every_day", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekdays: "M H * * 1-5"
|
||||||
|
if (dom === "*" && dow === "1-5" && hr !== "*") {
|
||||||
|
return { preset: "weekdays", ...defaults, hour: hr, minute: min === "*" ? "0" : min };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly: "M H * * D" (single day)
|
||||||
|
if (dom === "*" && /^\d$/.test(dow) && hr !== "*") {
|
||||||
|
return { preset: "weekly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfWeek: dow };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly: "M H D * *"
|
||||||
|
if (/^\d{1,2}$/.test(dom) && dow === "*" && hr !== "*") {
|
||||||
|
return { preset: "monthly", ...defaults, hour: hr, minute: min === "*" ? "0" : min, dayOfMonth: dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preset: "custom", ...defaults };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string {
|
||||||
|
switch (preset) {
|
||||||
|
case "every_hour":
|
||||||
|
return `${minute} * * * *`;
|
||||||
|
case "every_day":
|
||||||
|
return `${minute} ${hour} * * *`;
|
||||||
|
case "weekdays":
|
||||||
|
return `${minute} ${hour} * * 1-5`;
|
||||||
|
case "weekly":
|
||||||
|
return `${minute} ${hour} * * ${dayOfWeek}`;
|
||||||
|
case "monthly":
|
||||||
|
return `${minute} ${hour} ${dayOfMonth} * *`;
|
||||||
|
case "custom":
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSchedule(cron: string): string {
|
||||||
|
const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron);
|
||||||
|
const timeStr = HOURS.find((h) => h.value === hour)?.label?.replace(":00", `:${minute.padStart(2, "0")}`) ?? `${hour}:${minute.padStart(2, "0")}`;
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "every_hour":
|
||||||
|
return `Every hour at :${minute.padStart(2, "0")}`;
|
||||||
|
case "every_day":
|
||||||
|
return `Every day at ${timeStr}`;
|
||||||
|
case "weekdays":
|
||||||
|
return `Weekdays at ${timeStr}`;
|
||||||
|
case "weekly": {
|
||||||
|
const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek;
|
||||||
|
return `Every ${day} at ${timeStr}`;
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`;
|
||||||
|
case "custom":
|
||||||
|
return cron || "No schedule set";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinalSuffix(n: number): string {
|
||||||
|
const s = ["th", "st", "nd", "rd"];
|
||||||
|
const v = n % 100;
|
||||||
|
return s[(v - 20) % 10] || s[v] || s[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { describeSchedule };
|
||||||
|
|
||||||
|
export function ScheduleEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (cron: string) => void;
|
||||||
|
}) {
|
||||||
|
const parsed = useMemo(() => parseCronToPreset(value), [value]);
|
||||||
|
const [preset, setPreset] = useState<SchedulePreset>(parsed.preset);
|
||||||
|
const [hour, setHour] = useState(parsed.hour);
|
||||||
|
const [minute, setMinute] = useState(parsed.minute);
|
||||||
|
const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek);
|
||||||
|
const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth);
|
||||||
|
const [customCron, setCustomCron] = useState(preset === "custom" ? value : "");
|
||||||
|
|
||||||
|
// Sync from external value changes
|
||||||
|
useEffect(() => {
|
||||||
|
const p = parseCronToPreset(value);
|
||||||
|
setPreset(p.preset);
|
||||||
|
setHour(p.hour);
|
||||||
|
setMinute(p.minute);
|
||||||
|
setDayOfWeek(p.dayOfWeek);
|
||||||
|
setDayOfMonth(p.dayOfMonth);
|
||||||
|
if (p.preset === "custom") setCustomCron(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const emitChange = useCallback(
|
||||||
|
(p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => {
|
||||||
|
if (p === "custom") {
|
||||||
|
onChange(custom);
|
||||||
|
} else {
|
||||||
|
onChange(buildCron(p, h, m, dow, dom));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePresetChange = (newPreset: SchedulePreset) => {
|
||||||
|
setPreset(newPreset);
|
||||||
|
if (newPreset === "custom") {
|
||||||
|
setCustomCron(value);
|
||||||
|
} else {
|
||||||
|
emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select value={preset} onValueChange={(v) => handlePresetChange(v as SchedulePreset)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Choose frequency..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{preset === "custom" ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Input
|
||||||
|
value={customCron}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomCron(e.target.value);
|
||||||
|
emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="0 10 * * *"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Five fields: minute hour day-of-month month day-of-week
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{preset !== "every_hour" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">at</span>
|
||||||
|
<Select
|
||||||
|
value={hour}
|
||||||
|
onValueChange={(h) => {
|
||||||
|
setHour(h);
|
||||||
|
emitChange(preset, h, minute, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HOURS.map((h) => (
|
||||||
|
<SelectItem key={h.value} value={h.value}>
|
||||||
|
{h.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground">:</span>
|
||||||
|
<Select
|
||||||
|
value={minute}
|
||||||
|
onValueChange={(m) => {
|
||||||
|
setMinute(m);
|
||||||
|
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "every_hour" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">at minute</span>
|
||||||
|
<Select
|
||||||
|
value={minute}
|
||||||
|
onValueChange={(m) => {
|
||||||
|
setMinute(m);
|
||||||
|
emitChange(preset, hour, m, dayOfWeek, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
:{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "weekly" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">on</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DAYS_OF_WEEK.map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d.value}
|
||||||
|
type="button"
|
||||||
|
variant={dayOfWeek === d.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDayOfWeek(d.value);
|
||||||
|
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preset === "monthly" && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-muted-foreground">on day</span>
|
||||||
|
<Select
|
||||||
|
value={dayOfMonth}
|
||||||
|
onValueChange={(dom) => {
|
||||||
|
setDayOfMonth(dom);
|
||||||
|
emitChange(preset, hour, minute, dayOfWeek, dom, customCron);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_MONTH.map((d) => (
|
||||||
|
<SelectItem key={d.value} value={d.value}>
|
||||||
|
{d.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import { CopyText } from "../components/CopyText";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
|
|
@ -44,8 +45,6 @@ import {
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -801,36 +800,17 @@ export function AgentDetail() {
|
||||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">Assign Task</span>
|
<span className="hidden sm:inline">Assign Task</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<RunButton
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("invoke")}
|
onClick={() => agentAction.mutate("invoke")}
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
disabled={agentAction.isPending || isPendingApproval}
|
||||||
>
|
label="Run Heartbeat"
|
||||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
/>
|
||||||
<span className="hidden sm:inline">Run Heartbeat</span>
|
<PauseResumeButton
|
||||||
</Button>
|
isPaused={agent.status === "paused"}
|
||||||
{agent.status === "paused" ? (
|
onPause={() => agentAction.mutate("pause")}
|
||||||
<Button
|
onResume={() => agentAction.mutate("resume")}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("resume")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
disabled={agentAction.isPending || isPendingApproval}
|
||||||
>
|
/>
|
||||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
|
||||||
<span className="hidden sm:inline">Resume</span>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => agentAction.mutate("pause")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
|
||||||
>
|
|
||||||
<Pause className="h-3.5 w-3.5 sm:mr-1" />
|
|
||||||
<span className="hidden sm:inline">Pause</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||||
{mobileLiveRun && (
|
{mobileLiveRun && (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Activity as ActivityIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
Copy,
|
Copy,
|
||||||
|
ListTree,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
|
@ -21,18 +23,21 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
||||||
|
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -42,7 +47,6 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
|
||||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
import type { RoutineTrigger } from "@paperclipai/shared";
|
||||||
|
|
||||||
const priorities = ["critical", "high", "medium", "low"];
|
const priorities = ["critical", "high", "medium", "low"];
|
||||||
|
|
@ -101,6 +105,14 @@ function formatActivityDetailValue(value: unknown): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalTimezone(): string {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch {
|
||||||
|
return "UTC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function TriggerEditor({
|
function TriggerEditor({
|
||||||
trigger,
|
trigger,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -114,7 +126,6 @@ function TriggerEditor({
|
||||||
label: trigger.label ?? "",
|
label: trigger.label ?? "",
|
||||||
enabled: trigger.enabled ? "true" : "false",
|
enabled: trigger.enabled ? "true" : "false",
|
||||||
cronExpression: trigger.cronExpression ?? "",
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
timezone: trigger.timezone ?? "UTC",
|
|
||||||
signingMode: trigger.signingMode ?? "bearer",
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
});
|
});
|
||||||
|
|
@ -124,37 +135,37 @@ function TriggerEditor({
|
||||||
label: trigger.label ?? "",
|
label: trigger.label ?? "",
|
||||||
enabled: trigger.enabled ? "true" : "false",
|
enabled: trigger.enabled ? "true" : "false",
|
||||||
cronExpression: trigger.cronExpression ?? "",
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
timezone: trigger.timezone ?? "UTC",
|
|
||||||
signingMode: trigger.signingMode ?? "bearer",
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
});
|
});
|
||||||
}, [trigger]);
|
}, [trigger]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="rounded-lg border border-border p-4 space-y-4">
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
{trigger.kind === "schedule" ? <Clock3 className="h-4 w-4" /> : trigger.kind === "webhook" ? <Webhook className="h-4 w-4" /> : <Zap className="h-4 w-4" />}
|
{trigger.kind === "schedule" ? <Clock3 className="h-3.5 w-3.5" /> : trigger.kind === "webhook" ? <Webhook className="h-3.5 w-3.5" /> : <Zap className="h-3.5 w-3.5" />}
|
||||||
{trigger.label ?? trigger.kind}
|
{trigger.label ?? trigger.kind}
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>
|
<span className="text-xs text-muted-foreground">
|
||||||
{trigger.kind === "schedule" && trigger.nextRunAt
|
{trigger.kind === "schedule" && trigger.nextRunAt
|
||||||
? `Next run ${new Date(trigger.nextRunAt).toLocaleString()}`
|
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
||||||
: trigger.kind === "webhook"
|
: trigger.kind === "webhook"
|
||||||
? "Public webhook trigger"
|
? "Webhook"
|
||||||
: "Authenticated API/manual trigger"}
|
: "API"}
|
||||||
</CardDescription>
|
</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<Label>Label</Label>
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Label</Label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.label}
|
value={draft.label}
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Enabled</Label>
|
<Label className="text-xs">Enabled</Label>
|
||||||
<Select value={draft.enabled} onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}>
|
<Select value={draft.enabled} onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -166,29 +177,18 @@ function TriggerEditor({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{trigger.kind === "schedule" && (
|
{trigger.kind === "schedule" && (
|
||||||
<>
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
<div className="space-y-2">
|
<Label className="text-xs">Schedule</Label>
|
||||||
<Label>Cron</Label>
|
<ScheduleEditor
|
||||||
<Input
|
|
||||||
value={draft.cronExpression}
|
value={draft.cronExpression}
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, cronExpression: event.target.value }))}
|
onChange={(cronExpression) => setDraft((current) => ({ ...current, cronExpression }))}
|
||||||
placeholder="0 10 * * *"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Timezone</Label>
|
|
||||||
<Input
|
|
||||||
value={draft.timezone}
|
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, timezone: event.target.value }))}
|
|
||||||
placeholder="America/Chicago"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{trigger.kind === "webhook" && (
|
{trigger.kind === "webhook" && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Signing mode</Label>
|
<Label className="text-xs">Signing mode</Label>
|
||||||
<Select
|
<Select
|
||||||
value={draft.signingMode}
|
value={draft.signingMode}
|
||||||
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
|
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
|
||||||
|
|
@ -203,8 +203,8 @@ function TriggerEditor({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Replay window seconds</Label>
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.replayWindowSec}
|
value={draft.replayWindowSec}
|
||||||
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
||||||
|
|
@ -212,15 +212,18 @@ function TriggerEditor({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="md:col-span-2 flex flex-wrap items-center gap-2">
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSave(trigger.id, {
|
onSave(trigger.id, {
|
||||||
label: draft.label.trim() || null,
|
label: draft.label.trim() || null,
|
||||||
enabled: draft.enabled === "true",
|
enabled: draft.enabled === "true",
|
||||||
...(trigger.kind === "schedule"
|
...(trigger.kind === "schedule"
|
||||||
? { cronExpression: draft.cronExpression.trim(), timezone: draft.timezone.trim() }
|
? { cronExpression: draft.cronExpression.trim(), timezone: getLocalTimezone() }
|
||||||
: {}),
|
: {}),
|
||||||
...(trigger.kind === "webhook"
|
...(trigger.kind === "webhook"
|
||||||
? {
|
? {
|
||||||
|
|
@ -231,19 +234,18 @@ function TriggerEditor({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Save trigger
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
{trigger.kind === "webhook" && (
|
{trigger.kind === "webhook" && (
|
||||||
<Button variant="outline" onClick={() => onRotate(trigger.id)}>
|
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Rotate secret
|
Rotate secret
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{trigger.lastResult && <span className="text-sm text-muted-foreground">Last result: {trigger.lastResult}</span>}
|
{trigger.lastResult && <span className="text-xs text-muted-foreground">Last: {trigger.lastResult}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +268,6 @@ export function RoutineDetail() {
|
||||||
kind: "schedule",
|
kind: "schedule",
|
||||||
label: "",
|
label: "",
|
||||||
cronExpression: "0 10 * * *",
|
cronExpression: "0 10 * * *",
|
||||||
timezone: "UTC",
|
|
||||||
signingMode: "bearer",
|
signingMode: "bearer",
|
||||||
replayWindowSec: "300",
|
replayWindowSec: "300",
|
||||||
});
|
});
|
||||||
|
|
@ -447,13 +448,30 @@ export function RoutineDetail() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateRoutineStatus = useMutation({
|
||||||
|
mutationFn: (status: string) => routinesApi.update(routineId!, { status }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Failed to update routine",
|
||||||
|
body: error instanceof Error ? error.message : "Paperclip could not update the routine.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const createTrigger = useMutation({
|
const createTrigger = useMutation({
|
||||||
mutationFn: async (): Promise<RoutineTriggerResponse> =>
|
mutationFn: async (): Promise<RoutineTriggerResponse> =>
|
||||||
routinesApi.createTrigger(routineId!, {
|
routinesApi.createTrigger(routineId!, {
|
||||||
kind: newTrigger.kind,
|
kind: newTrigger.kind,
|
||||||
label: newTrigger.label.trim() || null,
|
label: newTrigger.label.trim() || null,
|
||||||
...(newTrigger.kind === "schedule"
|
...(newTrigger.kind === "schedule"
|
||||||
? { cronExpression: newTrigger.cronExpression.trim(), timezone: newTrigger.timezone.trim() }
|
? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() }
|
||||||
: {}),
|
: {}),
|
||||||
...(newTrigger.kind === "webhook"
|
...(newTrigger.kind === "webhook"
|
||||||
? {
|
? {
|
||||||
|
|
@ -568,72 +586,68 @@ export function RoutineDetail() {
|
||||||
|
|
||||||
if (error || !routine) {
|
if (error || !routine) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<p className="pt-6 text-sm text-destructive">
|
||||||
<CardContent className="pt-6 text-sm text-destructive">
|
|
||||||
{error instanceof Error ? error.message : "Routine not found"}
|
{error instanceof Error ? error.message : "Routine not found"}
|
||||||
</CardContent>
|
</p>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
{secretMessage && (
|
{/* Header: status + actions */}
|
||||||
<Card className="border-blue-500/30 bg-blue-500/5">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{secretMessage.title}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Save this now. Paperclip will not show the secret value again.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 text-sm">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Webhook URL</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input value={secretMessage.webhookUrl} readOnly />
|
|
||||||
<Button variant="outline" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy URL
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Secret</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input value={secretMessage.webhookSecret} readOnly />
|
|
||||||
<Button variant="outline" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy secret
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">Routine definition</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Keep the work definition primary. Triggers, runs, and audit history branch off this source object.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
|
<Badge variant={routine.status === "active" ? "default" : "secondary"}>
|
||||||
{routine.status.replaceAll("_", " ")}
|
{routine.status.replaceAll("_", " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending}>
|
{routine.activeIssue && (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Link
|
||||||
Run now
|
to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`}
|
||||||
</Button>
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<RunButton onClick={() => runRoutine.mutate()} disabled={runRoutine.isPending} />
|
||||||
|
<PauseResumeButton
|
||||||
|
isPaused={routine.status === "paused"}
|
||||||
|
onPause={() => updateRoutineStatus.mutate("paused")}
|
||||||
|
onResume={() => updateRoutineStatus.mutate("active")}
|
||||||
|
disabled={updateRoutineStatus.isPending || routine.status === "archived"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 pt-5 pb-3">
|
{/* Secret message banner */}
|
||||||
|
{secretMessage && (
|
||||||
|
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{secretMessage.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Save this now. Paperclip will not show the secret value again.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={secretMessage.webhookUrl} readOnly className="flex-1" />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
|
||||||
|
<Copy className="h-3.5 w-3.5 mr-1" />
|
||||||
|
URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={secretMessage.webhookSecret} readOnly className="flex-1" />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
|
||||||
|
<Copy className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Secret
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
<textarea
|
<textarea
|
||||||
ref={titleInputRef}
|
ref={titleInputRef}
|
||||||
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
|
className="w-full resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
|
||||||
placeholder="Routine title"
|
placeholder="Routine title"
|
||||||
rows={1}
|
rows={1}
|
||||||
value={editDraft.title}
|
value={editDraft.title}
|
||||||
|
|
@ -661,9 +675,8 @@ export function RoutineDetail() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 pb-3">
|
{/* Assignment row */}
|
||||||
<div className="overflow-x-auto overscroll-x-contain">
|
<div className="overflow-x-auto overscroll-x-contain">
|
||||||
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
||||||
<span>For</span>
|
<span>For</span>
|
||||||
|
|
@ -751,31 +764,26 @@ export function RoutineDetail() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 px-5 py-4">
|
{/* Instructions */}
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref={descriptionEditorRef}
|
ref={descriptionEditorRef}
|
||||||
value={editDraft.description}
|
value={editDraft.description}
|
||||||
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
||||||
placeholder="Add instructions..."
|
placeholder="Add instructions..."
|
||||||
bordered={false}
|
bordered={false}
|
||||||
contentClassName="min-h-[180px] text-sm text-muted-foreground"
|
contentClassName="min-h-[120px] text-[15px] leading-7"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
|
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
|
||||||
saveRoutine.mutate();
|
saveRoutine.mutate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border/60 px-5 py-3">
|
{/* Advanced delivery settings */}
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
|
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
|
||||||
<div>
|
<span className="text-sm font-medium">Advanced delivery settings</span>
|
||||||
<p className="text-sm font-medium">Advanced delivery settings</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Status and execution policy stay secondary to the work definition.</p>
|
|
||||||
</div>
|
|
||||||
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="pt-3">
|
<CollapsibleContent className="pt-3">
|
||||||
|
|
@ -843,25 +851,14 @@ export function RoutineDetail() {
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 md:flex-row md:items-center md:justify-between">
|
{/* Save bar */}
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
{routine.activeIssue ? (
|
|
||||||
<span>
|
|
||||||
Active issue:{" "}
|
|
||||||
<Link to={`/issues/${routine.activeIssue.identifier ?? routine.activeIssue.id}`} className="hover:underline">
|
|
||||||
{routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"No active execution issue."
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 md:items-end">
|
|
||||||
{isEditDirty ? (
|
{isEditDirty ? (
|
||||||
<span className="text-xs text-amber-600">Unsaved routine edits stay local until you save.</span>
|
<span className="text-xs text-amber-600">Unsaved changes</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveRoutine.mutate()}
|
onClick={() => saveRoutine.mutate()}
|
||||||
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId}
|
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId}
|
||||||
|
|
@ -870,28 +867,37 @@ export function RoutineDetail() {
|
||||||
Save routine
|
Save routine
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||||
<TabsTrigger value="triggers">Triggers</TabsTrigger>
|
<TabsTrigger value="triggers" className="gap-1.5">
|
||||||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
<TabsTrigger value="issues">Execution Issues</TabsTrigger>
|
Triggers
|
||||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="runs" className="gap-1.5">
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
Runs
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="issues" className="gap-1.5">
|
||||||
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
|
Issues
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="gap-1.5">
|
||||||
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
|
Activity
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="triggers" className="space-y-4">
|
<TabsContent value="triggers" className="space-y-4">
|
||||||
<Card>
|
{/* Add trigger form */}
|
||||||
<CardHeader>
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||||
<CardTitle>Add Trigger</CardTitle>
|
<p className="text-sm font-medium">Add trigger</p>
|
||||||
<CardDescription>
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
Schedules, public webhooks, or authenticated internal runs all flow into the same routine run log.
|
<div className="space-y-1.5">
|
||||||
</CardDescription>
|
<Label className="text-xs">Kind</Label>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Trigger kind</Label>
|
|
||||||
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
|
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -903,32 +909,23 @@ export function RoutineDetail() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Label</Label>
|
<Label className="text-xs">Label</Label>
|
||||||
<Input value={newTrigger.label} onChange={(event) => setNewTrigger((current) => ({ ...current, label: event.target.value }))} />
|
<Input value={newTrigger.label} onChange={(event) => setNewTrigger((current) => ({ ...current, label: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
{newTrigger.kind === "schedule" && (
|
{newTrigger.kind === "schedule" && (
|
||||||
<>
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
<div className="space-y-2">
|
<Label className="text-xs">Schedule</Label>
|
||||||
<Label>Cron</Label>
|
<ScheduleEditor
|
||||||
<Input value={newTrigger.cronExpression} onChange={(event) => setNewTrigger((current) => ({ ...current, cronExpression: event.target.value }))} />
|
value={newTrigger.cronExpression}
|
||||||
<p className="text-xs text-muted-foreground">
|
onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression }))}
|
||||||
Five fields, minute first. Example: <code>0 10 * * 1-5</code> runs at 10:00 on weekdays.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Timezone</Label>
|
|
||||||
<Input value={newTrigger.timezone} onChange={(event) => setNewTrigger((current) => ({ ...current, timezone: event.target.value }))} />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Use an IANA timezone such as <code>America/Chicago</code> so schedules follow local time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{newTrigger.kind === "webhook" && (
|
{newTrigger.kind === "webhook" && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Signing mode</Label>
|
<Label className="text-xs">Signing mode</Label>
|
||||||
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
|
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -939,136 +936,101 @@ export function RoutineDetail() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
||||||
{signingModeDescriptions[newTrigger.signingMode]}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<Label>Replay window seconds</Label>
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Reject webhook requests that arrive too late. A common starting point is 300 seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="md:col-span-2 flex items-center justify-between">
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center justify-end">
|
||||||
Webhook triggers return a one-time URL and secret. Copy them immediately.
|
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
||||||
</p>
|
|
||||||
<Button onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
|
||||||
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
{/* Existing triggers */}
|
||||||
{routine.triggers.length === 0 ? (
|
{routine.triggers.length === 0 ? (
|
||||||
<EmptyState icon={Repeat} message="No triggers configured yet. Add the first trigger above to make this routine run." />
|
<p className="text-xs text-muted-foreground">No triggers configured yet.</p>
|
||||||
) : (
|
) : (
|
||||||
routine.triggers.map((trigger) => (
|
<div className="space-y-3">
|
||||||
|
{routine.triggers.map((trigger) => (
|
||||||
<TriggerEditor
|
<TriggerEditor
|
||||||
key={trigger.id}
|
key={trigger.id}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
||||||
onRotate={(id) => rotateTrigger.mutate(id)}
|
onRotate={(id) => rotateTrigger.mutate(id)}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="runs">
|
<TabsContent value="runs">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Run History</CardTitle>
|
|
||||||
<CardDescription>Every trigger occurrence is captured here, whether it created work or was coalesced.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{(routineRuns ?? []).length === 0 ? (
|
{(routineRuns ?? []).length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No runs yet.</p>
|
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||||
) : (
|
) : (
|
||||||
(routineRuns ?? []).map((run) => (
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
<div key={run.id} className="rounded-lg border border-border p-3">
|
{(routineRuns ?? []).map((run) => (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
<Badge variant="outline">{run.source}</Badge>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"}>
|
<Badge variant="outline" className="shrink-0">{run.source}</Badge>
|
||||||
|
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="shrink-0">
|
||||||
{run.status.replaceAll("_", " ")}
|
{run.status.replaceAll("_", " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
|
||||||
</div>
|
|
||||||
{run.trigger && (
|
{run.trigger && (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||||
Trigger: {run.trigger.label ?? run.trigger.kind}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{run.linkedIssue && (
|
{run.linkedIssue && (
|
||||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="mt-2 block text-sm hover:underline">
|
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)} · {run.linkedIssue.title}
|
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{run.failureReason && (
|
|
||||||
<p className="mt-2 text-sm text-destructive">{run.failureReason}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">{timeAgo(run.triggeredAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="issues">
|
<TabsContent value="issues">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Execution Issues</CardTitle>
|
|
||||||
<CardDescription>These are the actual issue records created from the routine.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{(executionIssues ?? []).length === 0 ? (
|
{(executionIssues ?? []).length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No execution issues yet.</p>
|
<p className="text-xs text-muted-foreground">No execution issues yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
{(executionIssues ?? []).map((issue) => (
|
{(executionIssues ?? []).map((issue) => (
|
||||||
<IssueRow key={issue.id} issue={issue} />
|
<IssueRow key={issue.id} issue={issue} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="activity">
|
<TabsContent value="activity">
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Activity</CardTitle>
|
|
||||||
<CardDescription>Routine-level mutations and operator actions.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{(activity ?? []).length === 0 ? (
|
{(activity ?? []).length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||||
) : (
|
) : (
|
||||||
(activity ?? []).map((event) => (
|
<div className="space-y-1.5">
|
||||||
<div key={event.id} className="rounded-lg border border-border p-3 text-sm">
|
{(activity ?? []).map((event) => (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div key={event.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span className="font-medium">{event.action.replaceAll(".", " ")}</span>
|
<span className="font-medium text-foreground/80">{event.action.replaceAll(".", " ")}</span>
|
||||||
<span className="text-muted-foreground">{timeAgo(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
{event.details && Object.keys(event.details).length > 0 && (
|
{event.details && Object.keys(event.details).length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<>
|
||||||
{Object.entries(event.details).map(([key, value]) => (
|
{Object.entries(event.details).slice(0, 3).map(([key, value]) => (
|
||||||
<span key={key} className="rounded-full border border-border bg-muted/40 px-2 py-1">
|
<span key={key} className="rounded-full border border-border px-1.5 py-0.5">
|
||||||
<span className="font-medium text-foreground/80">{key.replaceAll("_", " ")}:</span>{" "}
|
{key.replaceAll("_", " ")}: {formatActivityDetailValue(value)}
|
||||||
{formatActivityDetailValue(value)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto shrink-0">{timeAgo(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue