| 1 | import { AlertTriangle, CalendarClock, Lightbulb, Pencil, Play, Plus, Trash2 } from "lucide-react"; |
| 2 | import { Link, data, useFetcher, useOutletContext } from "react-router"; |
| 3 | |
| 4 | import { type AgentRoutine, type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSuggestion, type WorkspaceAgent } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/routines"; |
| 7 | import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server"; |
| 8 | import { type ActionResult, Confirm } from "../../../components/agents/dialogs"; |
| 9 | import { reposFrom, routineInWords, scheduleFrom, untilLabel } from "../../../components/agents/format"; |
| 10 | import { Quiet } from "../../../components/agents/parts"; |
| 11 | import { type RoutineChannel, RoutineDialog } from "../../../components/agents/routine-dialog"; |
| 12 | import { TimeAgo } from "../../../components/ui"; |
| 13 | import { Button } from "../../../components/ui/button"; |
| 14 | import { Card } from "../../../components/ui/card"; |
| 15 | import { Hint } from "../../../components/ui/hint"; |
| 16 | import { Switch } from "../../../components/ui/switch"; |
| 17 | import { agentDmOf } from "../../../lib/chat"; |
| 18 | import { sidebarOrNull } from "../../../lib/chat.server"; |
| 19 | import { workspaceAgents } from "../../../lib/services.server"; |
| 20 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 21 | |
| 22 | const EVENT_LABELS: Record<string, string> = Object.fromEntries(ROUTINE_EVENTS.map((e) => [e.key, e.label])); |
| 23 | |
| 24 | /** The agent's routines, the ones its responsibilities suggest, and the conversations a routine could post in. */ |
| 25 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 26 | const viewer = requireUser(context, request); |
| 27 | const role = roleIn(viewer, params.owner); |
| 28 | if (!role) throw data(null, { status: 404 }); |
| 29 | const slug = params.owner.toLowerCase(); |
| 30 | const isOwner = role === "owner"; |
| 31 | const [listed, sidebar, agent] = await Promise.all([ |
| 32 | readOrNull(workspaceAgents.routines(slug, params.handle.toLowerCase(), viewer)), |
| 33 | // Only owners set routines up, so only they need the channels. |
| 34 | isOwner ? sidebarOrNull(slug, viewer) : Promise.resolve(null), |
| 35 | readOrNull(workspaceAgents.get(slug, params.handle.toLowerCase(), viewer)), |
| 36 | ]); |
| 37 | const channels: RoutineChannel[] = (sidebar?.entries ?? []) |
| 38 | .filter((entry) => (entry.channel.kind === "channel" && entry.channel.name) || (agent && agentDmOf(entry) === agent.id)) |
| 39 | .map((entry) => ({ id: entry.channel.id, label: entry.channel.kind === "channel" ? `#${entry.channel.name}` : `Your direct message with ${agent?.display_name ?? "it"}` })) |
| 40 | .sort((a, b) => Number(b.label.startsWith("#")) - Number(a.label.startsWith("#")) || a.label.localeCompare(b.label)); |
| 41 | return { routines: listed?.routines ?? null, suggestions: listed?.suggestions ?? [], channels, isOwner }; |
| 42 | } |
| 43 | |
| 44 | /** A routine from the dialog's fields. */ |
| 45 | function routineFrom(form: FormData): { ok: true; input: NewRoutine } | { ok: false; error: string } { |
| 46 | const every = String(form.get("every") ?? "none"); |
| 47 | let schedule = null; |
| 48 | if (every !== "none") { |
| 49 | const time = every === "hour" ? `00:${String(Number(form.get("minute") ?? 0)).padStart(2, "0")}` : String(form.get("time") ?? ""); |
| 50 | schedule = scheduleFrom(every, time, String(form.get("weekday") ?? "")); |
| 51 | if (!schedule) return { ok: false, error: "That schedule isn't one a routine can run on." }; |
| 52 | } |
| 53 | const events = form.getAll("events").map(String) as RoutineEvent[]; |
| 54 | const repos = reposFrom(String(form.get("repos") ?? "")); |
| 55 | if (!repos) return { ok: false, error: "Name repositories as workspace/name, separated by commas." }; |
| 56 | return { |
| 57 | ok: true, |
| 58 | input: { |
| 59 | name: String(form.get("name") ?? ""), |
| 60 | instructions: String(form.get("instructions") ?? ""), |
| 61 | schedule, |
| 62 | events, |
| 63 | repos: events.length > 0 ? repos : [], |
| 64 | channel_id: String(form.get("channel") ?? ""), |
| 65 | enabled: form.get("enabled") !== "false", |
| 66 | }, |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> { |
| 71 | const { viewer, slug, form } = await agentsAction(request, context, params.owner); |
| 72 | const handle = params.handle.toLowerCase(); |
| 73 | const intent = String(form.get("intent") ?? ""); |
| 74 | const id = String(form.get("id") ?? "") || null; |
| 75 | if (intent === "save") { |
| 76 | const read = routineFrom(form); |
| 77 | if (!read.ok) return { ok: false, intent, error: read.error }; |
| 78 | return answer(intent, workspaceAgents.saveRoutine(slug, handle, viewer, read.input, id)); |
| 79 | } |
| 80 | if (intent === "toggle" && id) { |
| 81 | // Turning one on or off saves it as it is, with the switch changed. |
| 82 | const listed = await readOrNull(workspaceAgents.routines(slug, handle, viewer)); |
| 83 | const routine = listed?.routines.find((r) => r.id === id); |
| 84 | if (!routine) return { ok: false, intent, error: "There is no such routine." }; |
| 85 | const { name, instructions, schedule, events, repos, channel_id } = routine; |
| 86 | return answer(intent, workspaceAgents.saveRoutine(slug, handle, viewer, { name, instructions, schedule, events, repos, channel_id, enabled: form.get("enabled") === "true" }, id)); |
| 87 | } |
| 88 | if (intent === "run" && id) return answer(intent, workspaceAgents.runRoutine(slug, handle, viewer, id)); |
| 89 | if (intent === "delete" && id) return answer(intent, workspaceAgents.deleteRoutine(slug, handle, viewer, id)); |
| 90 | return { ok: false, intent, error: "Unknown request." }; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Work the agent does without being asked: on a schedule, when something |
| 95 | * happens, or both. Each run is a session in the routine's channel. |
| 96 | */ |
| 97 | export default function Routines({ loaderData, params }: Route.ComponentProps) { |
| 98 | const agent = useOutletContext<WorkspaceAgent>(); |
| 99 | const { routines, suggestions, channels, isOwner } = loaderData; |
| 100 | if (!routines) return <Quiet title="Routines can't be shown right now">The agents service didn't answer. Reload in a moment.</Quiet>; |
| 101 | return ( |
| 102 | <div className="space-y-8"> |
| 103 | <div className="flex flex-wrap items-start justify-between gap-4"> |
| 104 | <p className="max-w-xl text-sm text-muted"> |
| 105 | Work {agent.display_name} does without being asked: on a schedule (UTC), when something happens, or both. Each run is a session posted in the |
| 106 | routine's channel, paid from {agent.display_name}'s budget, with the access of the owner who set it up. |
| 107 | </p> |
| 108 | {isOwner && ( |
| 109 | <RoutineDialog |
| 110 | agentName={agent.display_name} |
| 111 | draft={{}} |
| 112 | channels={channels} |
| 113 | title="New routine" |
| 114 | trigger={ |
| 115 | <Button type="button" variant="accent"> |
| 116 | <Plus size={15} /> |
| 117 | New routine |
| 118 | </Button> |
| 119 | } |
| 120 | /> |
| 121 | )} |
| 122 | </div> |
| 123 | |
| 124 | {routines.length === 0 ? ( |
| 125 | <Quiet title="No routines yet" icon={<CalendarClock size={20} />}> |
| 126 | {isOwner ? `Add one, or start from a suggestion below.` : `An owner can give ${agent.display_name} work to do on a schedule or when something happens.`} |
| 127 | </Quiet> |
| 128 | ) : ( |
| 129 | <Card asChild className="divide-y divide-line/60 overflow-hidden"> |
| 130 | <ul> |
| 131 | {routines.map((routine) => ( |
| 132 | <RoutineRow key={routine.id} routine={routine} agent={agent} channels={channels} isOwner={isOwner} slug={params.owner} /> |
| 133 | ))} |
| 134 | </ul> |
| 135 | </Card> |
| 136 | )} |
| 137 | |
| 138 | {isOwner && suggestions.length > 0 && <Suggestions agent={agent} suggestions={suggestions} channels={channels} />} |
| 139 | </div> |
| 140 | ); |
| 141 | } |
| 142 | |
| 143 | function RoutineRow({ routine, agent, channels, isOwner, slug }: { routine: AgentRoutine; agent: WorkspaceAgent; channels: RoutineChannel[]; isOwner: boolean; slug: string }) { |
| 144 | const toggle = useFetcher<ActionResult>({ key: `toggle-${routine.id}` }); |
| 145 | const run = useFetcher<ActionResult>({ key: `run-${routine.id}` }); |
| 146 | const enabled = toggle.formData ? toggle.formData.get("enabled") === "true" : routine.enabled; |
| 147 | const failed = [toggle.data, run.data].find((d) => d && !d.ok) as Extract<ActionResult, { ok: false }> | undefined; |
| 148 | return ( |
| 149 | <li className="px-4 py-3.5"> |
| 150 | <div className="flex items-start gap-3"> |
| 151 | <CalendarClock size={16} className={`mt-0.5 shrink-0 ${enabled ? "text-accent" : "text-faint"}`} /> |
| 152 | <div className="min-w-0 grow"> |
| 153 | <p className={`text-sm font-medium ${enabled ? "" : "text-muted"}`}>{routine.name}</p> |
| 154 | <p className="mt-0.5 text-xs text-muted">{routineInWords(routine, EVENT_LABELS)}</p> |
| 155 | <p className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-xs text-faint"> |
| 156 | <span>{routine.channel_name ? `#${routine.channel_name}` : "A direct message"}</span> |
| 157 | {routine.sponsor_username && <span>· runs as @{routine.sponsor_username}</span>} |
| 158 | {enabled && routine.next_run_at && <span suppressHydrationWarning>· next {untilLabel(routine.next_run_at)}</span>} |
| 159 | {routine.last_run_at && ( |
| 160 | <span> |
| 161 | · last{" "} |
| 162 | {routine.last_session_id ? ( |
| 163 | <Link to={`/${slug}/-/agents/${agent.handle}/sessions/${routine.last_session_id}`} className="text-muted hover:text-fg hover:underline"> |
| 164 | <TimeAgo at={routine.last_run_at} /> |
| 165 | </Link> |
| 166 | ) : ( |
| 167 | <TimeAgo at={routine.last_run_at} /> |
| 168 | )} |
| 169 | </span> |
| 170 | )} |
| 171 | <span> |
| 172 | · {routine.runs} {routine.runs === 1 ? "run" : "runs"} |
| 173 | </span> |
| 174 | </p> |
| 175 | {routine.paused_note && ( |
| 176 | <p className="mt-2 flex items-start gap-1.5 rounded-md border border-warn/30 bg-warn/10 px-2.5 py-1.5 text-xs text-warn"> |
| 177 | <AlertTriangle size={13} className="mt-px shrink-0" /> |
| 178 | {routine.paused_note} |
| 179 | </p> |
| 180 | )} |
| 181 | {run.data?.ok && <p className="mt-2 text-xs text-success">Started. It shows on the Sessions tab and in #{routine.channel_name ?? "its channel"}.</p>} |
| 182 | {failed && <p className="mt-2 text-xs text-danger">{failed.error}</p>} |
| 183 | </div> |
| 184 | {isOwner ? ( |
| 185 | <div className="flex shrink-0 items-center gap-1"> |
| 186 | <run.Form method="post"> |
| 187 | <input type="hidden" name="intent" value="run" /> |
| 188 | <input type="hidden" name="id" value={routine.id} /> |
| 189 | <Hint label="Run now"> |
| 190 | <Button type="submit" aria-label="Run now" disabled={run.state !== "idle"} variant="ghost" size="icon-sm" className="text-faint"> |
| 191 | <Play size={14} /> |
| 192 | </Button> |
| 193 | </Hint> |
| 194 | </run.Form> |
| 195 | <RoutineDialog |
| 196 | agentName={agent.display_name} |
| 197 | draft={routine} |
| 198 | channels={channels} |
| 199 | title="Edit routine" |
| 200 | trigger={ |
| 201 | <Button type="button" aria-label="Edit" variant="ghost" size="icon-sm" className="text-faint"> |
| 202 | <Pencil size={14} /> |
| 203 | </Button> |
| 204 | } |
| 205 | /> |
| 206 | <Confirm |
| 207 | title={`Delete “${routine.name}”?`} |
| 208 | confirm="Delete routine" |
| 209 | fields={{ intent: "delete", id: routine.id }} |
| 210 | fetcherKey={`delete-${routine.id}`} |
| 211 | trigger={ |
| 212 | <Button type="button" aria-label="Delete" variant="ghost" size="icon-sm" className="text-faint hover:text-danger"> |
| 213 | <Trash2 size={14} /> |
| 214 | </Button> |
| 215 | } |
| 216 | > |
| 217 | It stops running. The sessions it already ran stay, with what they cost. |
| 218 | </Confirm> |
| 219 | <toggle.Form method="post" className="ml-1.5 flex"> |
| 220 | <input type="hidden" name="intent" value="toggle" /> |
| 221 | <input type="hidden" name="id" value={routine.id} /> |
| 222 | <Switch |
| 223 | checked={enabled} |
| 224 | aria-label={enabled ? "Turn off" : "Turn on"} |
| 225 | onCheckedChange={(on) => toggle.submit({ intent: "toggle", id: routine.id, enabled: String(on) }, { method: "post" })} |
| 226 | /> |
| 227 | </toggle.Form> |
| 228 | </div> |
| 229 | ) : ( |
| 230 | <span className={`shrink-0 text-xs ${enabled ? "text-success" : "text-faint"}`}>{enabled ? "On" : "Off"}</span> |
| 231 | )} |
| 232 | </div> |
| 233 | </li> |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | function Suggestions({ agent, suggestions, channels }: { agent: WorkspaceAgent; suggestions: RoutineSuggestion[]; channels: RoutineChannel[] }) { |
| 238 | return ( |
| 239 | <section> |
| 240 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 241 | <Lightbulb size={15} className="text-accent" /> |
| 242 | Suggested from {agent.display_name}'s responsibilities |
| 243 | </h2> |
| 244 | <ul className="mt-3 grid gap-3 sm:grid-cols-2"> |
| 245 | {suggestions.map((suggestion) => ( |
| 246 | <Card asChild key={`${suggestion.responsibility}:${suggestion.routine.name}`} tone="plain" className="flex flex-col border-dashed bg-surface/60 p-4"> |
| 247 | <li> |
| 248 | <p className="text-xs text-faint">“{suggestion.responsibility}”</p> |
| 249 | <p className="mt-2 text-sm font-medium">{suggestion.routine.name}</p> |
| 250 | <p className="mt-0.5 text-xs text-muted">{routineInWords({ schedule: suggestion.routine.schedule, events: suggestion.routine.events, repos: suggestion.routine.repos }, EVENT_LABELS)}</p> |
| 251 | <div className="mt-auto pt-3"> |
| 252 | <RoutineDialog |
| 253 | agentName={agent.display_name} |
| 254 | draft={suggestion.routine} |
| 255 | channels={channels} |
| 256 | title="Add routine" |
| 257 | trigger={ |
| 258 | <Button type="button" variant="outline" size="sm"> |
| 259 | <Plus size={14} /> |
| 260 | Add |
| 261 | </Button> |
| 262 | } |
| 263 | /> |
| 264 | </div> |
| 265 | </li> |
| 266 | </Card> |
| 267 | ))} |
| 268 | </ul> |
| 269 | </section> |
| 270 | ); |
| 271 | } |