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:
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 { 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() {
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Assign Task</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Run Heartbeat</span>
|
||||
</Button>
|
||||
{agent.status === "paused" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => agentAction.mutate("resume")}
|
||||
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>
|
||||
)}
|
||||
label="Run Heartbeat"
|
||||
/>
|
||||
<PauseResumeButton
|
||||
isPaused={agent.status === "paused"}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
/>
|
||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||
{mobileLiveRun && (
|
||||
<Link
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user