Brings the canvas onto the warm-paper design system already shipped to landing, marketplace, and SaaS surfaces, and migrates the build from Tailwind v3 → v4 to match molecule-app. Plumbing: - swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss - delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS) - globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography" - two @theme blocks: warm-paper light defaults + always-dark surface tokens (bg-bg / ink-mute / line-strong) for terminal/console panels - [data-theme="dark"] cascade overrides the warm-paper tokens for dark - React Flow edge stroke + scrollbar + selection colour pull from semantic tokens so they flip with the theme Theme infra (ported from molecule-app, identical contracts): - lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client" so server components can read the constants) - lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with Domain=.moleculesai.app so the preference follows the user across canvas/app/market/landing subdomains AND tenant subdomains - lib/theme.ts: ColorToken union + cssVar() helper - components/ThemeToggle.tsx: 3-way System/Light/Dark picker - layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body Component migration (62 files): - Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white → semantic surface/ink/line tokens via perl negative-lookahead pass (preserves opacity modifiers like /80, /60) - bg-blue-500/600 → bg-accent / bg-accent-strong - text-red-* / amber-* / emerald-* → text-bad / warm / good - Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950 etc.) intentionally left literal — they remain readable on warm-paper in light mode without inventing new state-soft tokens - TerminalTab.tsx skipped — xterm renders to canvas, not DOM - 3 unit-test assertions updated to match new token strings (credits pillTone, AuthGate overlay class, A2AEdge accent) Verification: - pnpm test: 1214/1214 pass - pnpm tsc --noEmit: clean - next build: ✓ Compiled successfully (8 routes) - dev server inspection: html data-theme stamped, body uses bg-surface text-ink, boot script carries nonce, compiled CSS contains both @theme blocks + [data-theme="dark"] override Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useId } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
|
|
interface Schedule {
|
|
id: string;
|
|
workspace_id: string;
|
|
name: string;
|
|
cron_expr: string;
|
|
timezone: string;
|
|
prompt: string;
|
|
enabled: boolean;
|
|
last_run_at: string | null;
|
|
next_run_at: string | null;
|
|
run_count: number;
|
|
last_status: string;
|
|
last_error: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface Props {
|
|
workspaceId: string;
|
|
}
|
|
|
|
function cronToHuman(expr: string): string {
|
|
const parts = expr.trim().split(/\s+/);
|
|
if (parts.length !== 5) return expr;
|
|
const [min, hour, dom, mon, dow] = parts;
|
|
if (min === "*" && hour === "*") return `Every minute`;
|
|
if (min.startsWith("*/")) return `Every ${min.slice(2)} minutes`;
|
|
if (hour.startsWith("*/") && min === "0") return `Every ${hour.slice(2)} hours`;
|
|
if (dom === "*" && mon === "*" && dow === "*" && !hour.startsWith("*/"))
|
|
return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")} UTC`;
|
|
if (dom === "*" && mon === "*" && dow === "1-5" && !hour.startsWith("*/"))
|
|
return `Weekdays at ${hour.padStart(2, "0")}:${min.padStart(2, "0")} UTC`;
|
|
return expr;
|
|
}
|
|
|
|
function relativeTime(iso: string | null): string {
|
|
if (!iso) return "never";
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
if (diff < 0) {
|
|
const future = -diff;
|
|
if (future < 60000) return `in ${Math.round(future / 1000)}s`;
|
|
if (future < 3600000) return `in ${Math.round(future / 60000)}m`;
|
|
if (future < 86400000) return `in ${Math.round(future / 3600000)}h`;
|
|
return `in ${Math.round(future / 86400000)}d`;
|
|
}
|
|
if (diff < 60000) return `${Math.round(diff / 1000)}s ago`;
|
|
if (diff < 3600000) return `${Math.round(diff / 60000)}m ago`;
|
|
if (diff < 86400000) return `${Math.round(diff / 3600000)}h ago`;
|
|
return `${Math.round(diff / 86400000)}d ago`;
|
|
}
|
|
|
|
export function ScheduleTab({ workspaceId }: Props) {
|
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [formName, setFormName] = useState("");
|
|
const [formCron, setFormCron] = useState("0 9 * * *");
|
|
const [formTimezone, setFormTimezone] = useState("UTC");
|
|
const [formPrompt, setFormPrompt] = useState("");
|
|
const [formEnabled, setFormEnabled] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
|
|
|
|
// Stable IDs for label↔input associations (WCAG 1.3.1)
|
|
const cronId = useId();
|
|
const timezoneId = useId();
|
|
const promptId = useId();
|
|
|
|
const fetchSchedules = useCallback(async () => {
|
|
try {
|
|
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
|
|
setSchedules(data);
|
|
} catch {
|
|
setSchedules([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workspaceId]);
|
|
|
|
useEffect(() => {
|
|
fetchSchedules();
|
|
const interval = setInterval(fetchSchedules, 10000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchSchedules]);
|
|
|
|
const resetForm = () => {
|
|
setFormName("");
|
|
setFormCron("0 9 * * *");
|
|
setFormTimezone("UTC");
|
|
setFormPrompt("");
|
|
setFormEnabled(true);
|
|
setEditId(null);
|
|
setShowForm(false);
|
|
setError("");
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
setError("");
|
|
try {
|
|
if (editId) {
|
|
await api.patch(`/workspaces/${workspaceId}/schedules/${editId}`, {
|
|
name: formName,
|
|
cron_expr: formCron,
|
|
timezone: formTimezone,
|
|
prompt: formPrompt,
|
|
enabled: formEnabled,
|
|
});
|
|
} else {
|
|
await api.post(`/workspaces/${workspaceId}/schedules`, {
|
|
name: formName,
|
|
cron_expr: formCron,
|
|
timezone: formTimezone,
|
|
prompt: formPrompt,
|
|
enabled: formEnabled,
|
|
});
|
|
}
|
|
resetForm();
|
|
fetchSchedules();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Failed to save schedule");
|
|
}
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!pendingDelete) return;
|
|
const { id } = pendingDelete;
|
|
setPendingDelete(null);
|
|
try {
|
|
await api.del(`/workspaces/${workspaceId}/schedules/${id}`);
|
|
fetchSchedules();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Failed to delete schedule");
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (sched: Schedule) => {
|
|
try {
|
|
await api.patch(`/workspaces/${workspaceId}/schedules/${sched.id}`, {
|
|
enabled: !sched.enabled,
|
|
});
|
|
fetchSchedules();
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : "Failed to toggle schedule");
|
|
}
|
|
};
|
|
|
|
const handleEdit = (sched: Schedule) => {
|
|
setFormName(sched.name);
|
|
setFormCron(sched.cron_expr);
|
|
setFormTimezone(sched.timezone);
|
|
setFormPrompt(sched.prompt);
|
|
setFormEnabled(sched.enabled);
|
|
setEditId(sched.id);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleRunNow = async (sched: Schedule) => {
|
|
try {
|
|
const result = await api.post<{ prompt: string }>(`/workspaces/${workspaceId}/schedules/${sched.id}/run`, {});
|
|
await api.post(`/workspaces/${workspaceId}/a2a`, {
|
|
method: "message/send",
|
|
params: {
|
|
message: {
|
|
role: "user",
|
|
messageId: `manual-cron-${Date.now()}`,
|
|
parts: [{ kind: "text", text: result.prompt }],
|
|
},
|
|
},
|
|
});
|
|
fetchSchedules();
|
|
} catch {
|
|
setError("Failed to run schedule");
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="p-4 text-[10px] text-ink-soft">Loading schedules...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-line/50">
|
|
<span className="text-[10px] font-semibold text-ink-mid uppercase tracking-wider">
|
|
Schedules
|
|
</span>
|
|
<button
|
|
onClick={() => { resetForm(); setShowForm(true); }}
|
|
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
|
|
>
|
|
+ Add Schedule
|
|
</button>
|
|
</div>
|
|
|
|
{/* Create/Edit Form */}
|
|
{showForm && (
|
|
<div className="p-3 border-b border-line/50 bg-surface-sunken/50 space-y-2">
|
|
<input
|
|
type="text"
|
|
aria-label="Schedule name"
|
|
placeholder="Schedule name (e.g., Daily security scan)"
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<label htmlFor={cronId} className="text-[10px] text-ink-soft block mb-0.5">Cron Expression</label>
|
|
<input
|
|
id={cronId}
|
|
type="text"
|
|
value={formCron}
|
|
onChange={(e) => setFormCron(e.target.value)}
|
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink font-mono"
|
|
/>
|
|
<div className="text-[10px] text-ink-soft mt-0.5">
|
|
{cronToHuman(formCron)}
|
|
</div>
|
|
</div>
|
|
<div className="w-24">
|
|
<label htmlFor={timezoneId} className="text-[10px] text-ink-soft block mb-0.5">Timezone</label>
|
|
<select
|
|
id={timezoneId}
|
|
value={formTimezone}
|
|
onChange={(e) => setFormTimezone(e.target.value)}
|
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-1 py-1 text-ink"
|
|
>
|
|
<option value="UTC">UTC</option>
|
|
<option value="America/New_York">US Eastern</option>
|
|
<option value="America/Chicago">US Central</option>
|
|
<option value="America/Denver">US Mountain</option>
|
|
<option value="America/Los_Angeles">US Pacific</option>
|
|
<option value="Europe/London">London</option>
|
|
<option value="Europe/Berlin">Berlin</option>
|
|
<option value="Asia/Tokyo">Tokyo</option>
|
|
<option value="Asia/Shanghai">Shanghai</option>
|
|
<option value="Australia/Sydney">Sydney</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label htmlFor={promptId} className="text-[10px] text-ink-soft block mb-0.5">Prompt / Task</label>
|
|
<textarea
|
|
id={promptId}
|
|
value={formPrompt}
|
|
onChange={(e) => setFormPrompt(e.target.value)}
|
|
placeholder="What should the agent do on this schedule?"
|
|
rows={3}
|
|
className="w-full text-[10px] bg-surface-card border border-line rounded px-2 py-1 text-ink placeholder:text-ink-soft resize-y"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex items-center gap-1.5 text-[10px] text-ink-mid cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formEnabled}
|
|
onChange={(e) => setFormEnabled(e.target.checked)}
|
|
className="rounded border-line"
|
|
/>
|
|
Enabled
|
|
</label>
|
|
</div>
|
|
{error && <div className="text-[10px] text-bad">{error}</div>}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!formCron || !formPrompt}
|
|
className="text-[11px] px-3 py-1 bg-accent-strong text-ink rounded hover:bg-accent disabled:opacity-40 transition-colors"
|
|
>
|
|
{editId ? "Update" : "Create"}
|
|
</button>
|
|
<button
|
|
onClick={resetForm}
|
|
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-card transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<div className="text-[10px] text-ink-soft space-y-0.5">
|
|
<div>Common patterns:</div>
|
|
<div className="font-mono">{"0 9 * * *"} — Daily at 9:00 AM</div>
|
|
<div className="font-mono">{"*/30 * * * *"} — Every 30 minutes</div>
|
|
<div className="font-mono">{"0 */4 * * *"} — Every 4 hours</div>
|
|
<div className="font-mono">{"0 9 * * 1-5"} — Weekdays at 9:00 AM</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Schedule List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{schedules.length === 0 && !showForm ? (
|
|
<div className="p-6 text-center">
|
|
<div className="text-2xl mb-2">⏲</div>
|
|
<div className="text-[10px] text-ink-mid mb-1">No schedules yet</div>
|
|
<div className="text-[9px] text-ink-soft">
|
|
Add a schedule to run tasks automatically — daily scans, periodic reports, standup reminders.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
schedules.map((sched) => (
|
|
<div
|
|
key={sched.id}
|
|
className={`px-3 py-2 border-b border-line/30 ${
|
|
!sched.enabled ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => handleToggle(sched)}
|
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
|
sched.last_status === "error"
|
|
? "bg-red-400"
|
|
: sched.last_status === "ok"
|
|
? "bg-emerald-400"
|
|
: "bg-zinc-600"
|
|
}`}
|
|
title={sched.enabled ? "Click to disable" : "Click to enable"}
|
|
/>
|
|
<span className="text-[10px] font-medium text-ink truncate">
|
|
{sched.name || "Unnamed schedule"}
|
|
</span>
|
|
</div>
|
|
<div className="text-[9px] text-ink-soft mt-0.5 font-mono">
|
|
{cronToHuman(sched.cron_expr)}
|
|
{sched.timezone !== "UTC" && (
|
|
<span className="text-ink-soft"> ({sched.timezone})</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[9px] text-ink-soft mt-0.5 truncate">
|
|
{sched.prompt.slice(0, 80)}{sched.prompt.length > 80 ? "..." : ""}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-1 text-[8px] text-ink-soft">
|
|
<span>Last: {relativeTime(sched.last_run_at)}</span>
|
|
<span>Next: {relativeTime(sched.next_run_at)}</span>
|
|
<span>Runs: {sched.run_count}</span>
|
|
</div>
|
|
{sched.last_error && (
|
|
<div className="text-[8px] text-bad/70 mt-0.5 truncate">
|
|
Error: {sched.last_error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<button
|
|
onClick={() => handleRunNow(sched)}
|
|
aria-label={`Run schedule ${sched.name} now`}
|
|
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
|
|
title="Run now"
|
|
>
|
|
▶
|
|
</button>
|
|
<button
|
|
onClick={() => handleEdit(sched)}
|
|
aria-label={`Edit schedule ${sched.name}`}
|
|
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
|
|
title="Edit"
|
|
>
|
|
✎
|
|
</button>
|
|
<button
|
|
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
|
|
aria-label={`Delete schedule ${sched.name}`}
|
|
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
|
|
title="Delete"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingDelete}
|
|
title="Delete schedule"
|
|
message={`Delete schedule "${pendingDelete?.name || "Unnamed"}"? This cannot be undone.`}
|
|
confirmLabel="Delete"
|
|
confirmVariant="danger"
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => setPendingDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|