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