diff --git a/apps/app/src/react-app/domains/settings/pages/workflows-panel.tsx b/apps/app/src/react-app/domains/settings/pages/workflows-panel.tsx new file mode 100644 index 000000000..104c3a4a6 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/workflows-panel.tsx @@ -0,0 +1,599 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertCircle, + CheckCircle, + Clock, + Edit2, + Loader2, + Play, + Plus, + Repeat, + Trash2, + Workflow, + X, + Zap, +} from "lucide-react"; + +/* ── Shared styles ───────────────────────────────────────────────────── */ + +const panelCardClass = + "rounded-[20px] border border-dls-border bg-dls-surface p-5 transition-all hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]"; +const pillButtonClass = + "inline-flex items-center justify-center gap-1.5 rounded-full px-4 py-2 text-[13px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.18)] disabled:cursor-not-allowed disabled:opacity-60"; +const pillPrimaryClass = `${pillButtonClass} bg-dls-accent text-white hover:bg-[var(--dls-accent-hover)]`; +const pillSecondaryClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-text hover:bg-dls-hover`; +const pillGhostClass = `${pillButtonClass} border border-dls-border bg-dls-surface text-dls-secondary hover:bg-dls-hover hover:text-dls-text`; +const tagClass = + "inline-flex items-center rounded-md border border-dls-border bg-dls-hover px-2 py-1 text-[11px] text-dls-secondary"; + +/* ── Types ────────────────────────────────────────────────────────────── */ + +export type WorkflowAutomation = { + id: string; + name: string; + description: string; + prompt: string; + schedule: string; + enabled: boolean; + workspaceId: string; + createdAt: string; + updatedAt: string; + lastRunAt: string | null; + lastRunStatus: "pending" | "running" | "success" | "failed" | null; + lastSessionId: string | null; + runCount?: number; +}; + +export type WorkflowsPanelProps = { + serverBaseUrl: string | null; + workspaceId: string | null; + authToken?: string | null; + showToast?: (input: { + title: string; + tone?: "success" | "info" | "warning" | "error"; + description?: string | null; + }) => void; +}; + +/* ── Helpers ──────────────────────────────────────────────────────────── */ + +async function apiFetch( + base: string, + path: string, + options: RequestInit = {}, + authToken?: string | null, +): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record ?? {}), + }; + if (authToken) { + headers["Authorization"] = `Bearer ${authToken}`; + } + const res = await fetch(`${base}${path}`, { ...options, headers }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`API error ${res.status}: ${text}`); + } + return res.json(); +} + +function relativeTime(iso: string | null): string { + if (!iso) return "Never"; + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60_000) return "Just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +function scheduleLabel(schedule: string): string { + if (!schedule || schedule === "manual") return "Manual"; + const num = Number(schedule); + if (Number.isFinite(num) && num > 0) { + if (num < 60) return `Every ${num}s`; + if (num < 3600) return `Every ${Math.round(num / 60)}m`; + return `Every ${Math.round(num / 3600)}h`; + } + return schedule; +} + +const SCHEDULE_OPTIONS = [ + { label: "Manual", value: "manual" }, + { label: "Every 30 seconds (test)", value: "30" }, + { label: "Every 1 minute", value: "60" }, + { label: "Every 5 minutes", value: "300" }, + { label: "Every 15 minutes", value: "900" }, + { label: "Every hour", value: "3600" }, + { label: "Every 6 hours", value: "21600" }, + { label: "Every 24 hours", value: "86400" }, +]; + +const WORKFLOW_PRESETS = [ + { + name: "Open Chrome to Facebook", + description: "Launch Google Chrome and navigate to facebook.com", + prompt: + "Use Google Chrome to open facebook.com. Navigate to the page and confirm it loaded successfully.", + }, + { + name: "Daily standup summary", + description: "Summarize recent activity for a standup meeting", + prompt: + "Review my recent git commits and open tasks, then draft a concise standup summary with what I did, what I'm doing next, and any blockers.", + }, + { + name: "Code review scan", + description: "Scan recent commits and flag risky changes", + prompt: + "Scan recent commits and flag riskier diffs with the most important follow-ups.", + }, + { + name: "Inbox triage", + description: "Summarize unread messages and suggest priorities", + prompt: + "Summarize unread inbox messages, suggest priority order, and draft concise reply options for the top conversations.", + }, +]; + +/* ── Component ────────────────────────────────────────────────────────── */ + +export function WorkflowsPanel(props: WorkflowsPanelProps) { + const { serverBaseUrl, workspaceId, authToken, showToast } = props; + const [automations, setAutomations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [triggerBusy, setTriggerBusy] = useState(null); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formName, setFormName] = useState(""); + const [formDescription, setFormDescription] = useState(""); + const [formPrompt, setFormPrompt] = useState(""); + const [formSchedule, setFormSchedule] = useState("manual"); + const [saveBusy, setSaveBusy] = useState(false); + + const available = Boolean(serverBaseUrl && workspaceId); + + const basePath = useMemo( + () => (serverBaseUrl && workspaceId ? `/workspace/${workspaceId}/automations` : null), + [serverBaseUrl, workspaceId], + ); + + /* ── Data fetching ──────────────────────────────────────────────────── */ + + const refresh = useCallback(async () => { + if (!serverBaseUrl || !basePath) return; + setLoading(true); + setError(null); + try { + const res = await apiFetch<{ items: WorkflowAutomation[] }>( + serverBaseUrl, basePath, {}, authToken, + ); + setAutomations(res?.items ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load workflows"); + } finally { + setLoading(false); + } + }, [serverBaseUrl, basePath, authToken]); + + useEffect(() => { + if (available) void refresh(); + }, [available, refresh]); + + // Auto-refresh every 15s to pick up recurring run status changes + useEffect(() => { + if (!available) return; + const timer = setInterval(() => void refresh(), 15_000); + return () => clearInterval(timer); + }, [available, refresh]); + + /* ── Modal helpers ──────────────────────────────────────────────────── */ + + const openCreate = (preset?: (typeof WORKFLOW_PRESETS)[number]) => { + setEditingId(null); + setFormName(preset?.name ?? ""); + setFormDescription(preset?.description ?? ""); + setFormPrompt(preset?.prompt ?? ""); + setFormSchedule("manual"); + setModalOpen(true); + }; + + const openEdit = (auto: WorkflowAutomation) => { + setEditingId(auto.id); + setFormName(auto.name); + setFormDescription(auto.description); + setFormPrompt(auto.prompt); + setFormSchedule(auto.schedule); + setModalOpen(true); + }; + + const closeModal = () => { + setModalOpen(false); + setEditingId(null); + setSaveBusy(false); + }; + + /* ── Actions ────────────────────────────────────────────────────────── */ + + const handleSave = async () => { + if (!serverBaseUrl || !basePath || !formPrompt.trim()) return; + setSaveBusy(true); + try { + if (editingId) { + // Update existing + await apiFetch( + serverBaseUrl, + `${basePath}/${editingId}`, + { + method: "PATCH", + body: JSON.stringify({ + name: formName.trim() || "Untitled", + description: formDescription.trim(), + prompt: formPrompt.trim(), + schedule: formSchedule, + }), + }, + authToken, + ); + showToast?.({ title: "Workflow updated", tone: "success" }); + } else { + // Create new + await apiFetch( + serverBaseUrl, + basePath, + { + method: "POST", + body: JSON.stringify({ + name: formName.trim() || "Untitled", + description: formDescription.trim(), + prompt: formPrompt.trim(), + schedule: formSchedule, + }), + }, + authToken, + ); + showToast?.({ title: "Workflow created", tone: "success" }); + } + closeModal(); + await refresh(); + } catch (err) { + showToast?.({ + title: editingId ? "Failed to update" : "Failed to create", + tone: "error", + description: err instanceof Error ? err.message : null, + }); + } finally { + setSaveBusy(false); + } + }; + + const handleTrigger = async (id: string) => { + if (!serverBaseUrl || !basePath) return; + setTriggerBusy(id); + try { + await apiFetch( + serverBaseUrl, + `${basePath}/${id}/trigger`, + { method: "POST" }, + authToken, + ); + showToast?.({ title: "Workflow triggered", tone: "success", description: "A new session has been created." }); + await refresh(); + } catch (err) { + showToast?.({ + title: "Trigger failed", + tone: "error", + description: err instanceof Error ? err.message : null, + }); + } finally { + setTriggerBusy(null); + } + }; + + const handleDelete = async (id: string) => { + if (!serverBaseUrl || !basePath) return; + try { + await apiFetch(serverBaseUrl, `${basePath}/${id}`, { method: "DELETE" }, authToken); + showToast?.({ title: "Workflow deleted", tone: "info" }); + setAutomations((prev) => prev.filter((a) => a.id !== id)); + } catch (err) { + showToast?.({ + title: "Delete failed", + tone: "error", + description: err instanceof Error ? err.message : null, + }); + } + }; + + /* ── Render ─────────────────────────────────────────────────────────── */ + + if (!available) { + return ( +
+
+ +

Workflows

+
+
+ Connect to an OpenWork server to use workflows. +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Workflows

+ + Auto + +
+ +
+ + {/* Error */} + {error ? ( +
+ + {error} +
+ ) : null} + + {/* Presets (shown when no automations) */} + {automations.length === 0 && !loading ? ( +
+

+ Quick-start: pick a preset or create your own workflow. +

+
+ {WORKFLOW_PRESETS.map((preset) => ( + + ))} +
+
+ ) : null} + + {/* Loading */} + {loading ? ( +
+ + Loading workflows... +
+ ) : null} + + {/* Workflow list */} + {automations.length > 0 ? ( +
+
+ {automations.map((auto) => ( +
+
+
+ +
+
+
+

+ {auto.name} +

+ {auto.lastRunStatus === "success" ? ( + + + Success + + ) : auto.lastRunStatus === "running" ? ( + + + Running + + ) : auto.lastRunStatus === "failed" ? ( + + + Failed + + ) : null} +
+ {auto.description ? ( +

+ {auto.description} +

+ ) : null} +

+ {auto.prompt} +

+
+ + {auto.schedule !== "manual" ? : } + {scheduleLabel(auto.schedule)} + + {auto.lastRunAt ? ( + Last run: {relativeTime(auto.lastRunAt)} + ) : null} + {(auto.runCount ?? 0) > 0 ? ( + Runs: {auto.runCount} + ) : null} +
+
+
+
+ + + +
+
+ ))} +
+
+ ) : null} + + {/* Create / Edit modal */} + {modalOpen ? ( +
+
+
+
+
+ {editingId ? "Edit Workflow" : "New Workflow"} +
+

+ {editingId + ? "Update the workflow name, prompt, or schedule." + : "Create a workflow that runs a prompt in OpenCode."} +

+
+ +
+ +
+
+ + setFormName(e.currentTarget.value)} + placeholder="e.g. Morning Facebook check" + className="w-full rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-[14px] text-dls-text placeholder:text-dls-secondary/50 focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" + /> +
+ +
+ + setFormDescription(e.currentTarget.value)} + placeholder="A short description of what this does" + className="w-full rounded-xl border border-dls-border bg-dls-surface px-4 py-3 text-[14px] text-dls-text placeholder:text-dls-secondary/50 focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" + /> +
+ +
+ +