From 6a7e2d3fceb96a651995a46d028c86390932c219 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 19 Mar 2026 15:05:32 -0500 Subject: [PATCH] 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 --- ui/src/components/AgentActionButtons.tsx | 51 ++ ui/src/components/ScheduleEditor.tsx | 333 ++++++++ ui/src/pages/AgentDetail.tsx | 40 +- ui/src/pages/RoutineDetail.tsx | 956 +++++++++++------------ 4 files changed, 853 insertions(+), 527 deletions(-) create mode 100644 ui/src/components/AgentActionButtons.tsx create mode 100644 ui/src/components/ScheduleEditor.tsx diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx new file mode 100644 index 00000000..2d698e47 --- /dev/null +++ b/ui/src/components/AgentActionButtons.tsx @@ -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 ( + + ); +} + +export function PauseResumeButton({ + isPaused, + onPause, + onResume, + disabled, + size = "sm", +}: { + isPaused: boolean; + onPause: () => void; + onResume: () => void; + disabled?: boolean; + size?: "sm" | "default"; +}) { + if (isPaused) { + return ( + + ); + } + + return ( + + ); +} diff --git a/ui/src/components/ScheduleEditor.tsx b/ui/src/components/ScheduleEditor.tsx new file mode 100644 index 00000000..a15d6629 --- /dev/null +++ b/ui/src/components/ScheduleEditor.tsx @@ -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(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 ( +
+ + + {preset === "custom" ? ( +
+ { + setCustomCron(e.target.value); + emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, e.target.value); + }} + placeholder="0 10 * * *" + className="font-mono text-sm" + /> +

+ Five fields: minute hour day-of-month month day-of-week +

+
+ ) : ( +
+ {preset !== "every_hour" && ( + <> + at + + : + + + )} + + {preset === "every_hour" && ( + <> + at minute + + + )} + + {preset === "weekly" && ( + <> + on +
+ {DAYS_OF_WEEK.map((d) => ( + + ))} +
+ + )} + + {preset === "monthly" && ( + <> + on day + + + )} +
+ )} +
+ ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index fa3d05b9..0697e08a 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -28,6 +28,7 @@ import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; +import { RunButton, PauseResumeButton } from "../components/AgentActionButtons"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { PackageFileTree, buildFileTree } from "../components/PackageFileTree"; import { ScrollToBottom } from "../components/ScrollToBottom"; @@ -44,8 +45,6 @@ import { } from "@/components/ui/popover"; import { MoreHorizontal, - Play, - Pause, CheckCircle2, XCircle, Clock, @@ -801,36 +800,17 @@ export function AgentDetail() { Assign Task - - {agent.status === "paused" ? ( - - ) : ( - - )} + label="Run Heartbeat" + /> + agentAction.mutate("pause")} + onResume={() => agentAction.mutate("resume")} + disabled={agentAction.isPending || isPendingApproval} + /> {mobileLiveRun && ( - - - {trigger.kind === "schedule" ? : trigger.kind === "webhook" ? : } +
+
+
+ {trigger.kind === "schedule" ? : trigger.kind === "webhook" ? : } {trigger.label ?? trigger.kind} - - +
+ {trigger.kind === "schedule" && trigger.nextRunAt - ? `Next run ${new Date(trigger.nextRunAt).toLocaleString()}` + ? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}` : trigger.kind === "webhook" - ? "Public webhook trigger" - : "Authenticated API/manual trigger"} - - - -
- + ? "Webhook" + : "API"} + +
+ +
+
+ setDraft((current) => ({ ...current, label: event.target.value }))} />
-
- +
+
{trigger.kind === "schedule" && ( - <> -
- - setDraft((current) => ({ ...current, cronExpression: event.target.value }))} - placeholder="0 10 * * *" - /> -
-
- - setDraft((current) => ({ ...current, timezone: event.target.value }))} - placeholder="America/Chicago" - /> -
- +
+ + setDraft((current) => ({ ...current, cronExpression }))} + /> +
)} {trigger.kind === "webhook" && ( <> -
- +
+
-
- +
+ setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))} @@ -212,38 +212,40 @@ function TriggerEditor({
)} -
-
+ +
+ + {trigger.kind === "webhook" && ( + - {trigger.kind === "webhook" && ( - - )} - {trigger.lastResult && Last result: {trigger.lastResult}} -
- - + )} + {trigger.lastResult && Last: {trigger.lastResult}} +
+
); } @@ -266,7 +268,6 @@ export function RoutineDetail() { kind: "schedule", label: "", cronExpression: "0 10 * * *", - timezone: "UTC", signingMode: "bearer", 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({ mutationFn: async (): Promise => routinesApi.createTrigger(routineId!, { kind: newTrigger.kind, label: newTrigger.label.trim() || null, ...(newTrigger.kind === "schedule" - ? { cronExpression: newTrigger.cronExpression.trim(), timezone: newTrigger.timezone.trim() } + ? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() } : {}), ...(newTrigger.kind === "webhook" ? { @@ -568,330 +586,318 @@ export function RoutineDetail() { if (error || !routine) { return ( - - - {error instanceof Error ? error.message : "Routine not found"} - - +

+ {error instanceof Error ? error.message : "Routine not found"} +

); } return ( -
+
+ {/* Header: status + actions */} +
+ + {routine.status.replaceAll("_", " ")} + + {routine.activeIssue && ( + + {routine.activeIssue.identifier ?? routine.activeIssue.id.slice(0, 8)} + + )} +
+ runRoutine.mutate()} disabled={runRoutine.isPending} /> + updateRoutineStatus.mutate("paused")} + onResume={() => updateRoutineStatus.mutate("active")} + disabled={updateRoutineStatus.isPending || routine.status === "archived"} + /> +
+
+ + {/* Secret message banner */} {secretMessage && ( - - - {secretMessage.title} - - Save this now. Paperclip will not show the secret value again. - - - -
- -
- - -
+
+
+

{secretMessage.title}

+

Save this now. Paperclip will not show the secret value again.

+
+
+
+ +
-
- -
- - -
+
+ +
- - +
+
)} - -
-
-

Routine definition

-

- Keep the work definition primary. Triggers, runs, and audit history branch off this source object. -

-
-
- - {routine.status.replaceAll("_", " ")} - - -
-
- -
-