Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

271 linesCodeBlameRaw
1import { AlertTriangle, CalendarClock, Lightbulb, Pencil, Play, Plus, Trash2 } from "lucide-react";
2import { Link, data, useFetcher, useOutletContext } from "react-router";
3
4import { type AgentRoutine, type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSuggestion, type WorkspaceAgent } from "@g1t/contracts";
5
6import type { Route } from "./+types/routines";
7import { agentsAction, answer, readOrNull } from "../../../components/agents/actions.server";
8import { type ActionResult, Confirm } from "../../../components/agents/dialogs";
9import { reposFrom, routineInWords, scheduleFrom, untilLabel } from "../../../components/agents/format";
10import { Quiet } from "../../../components/agents/parts";
11import { type RoutineChannel, RoutineDialog } from "../../../components/agents/routine-dialog";
12import { TimeAgo } from "../../../components/ui";
13import { Button } from "../../../components/ui/button";
14import { Card } from "../../../components/ui/card";
15import { Hint } from "../../../components/ui/hint";
16import { Switch } from "../../../components/ui/switch";
17import { agentDmOf } from "../../../lib/chat";
18import { sidebarOrNull } from "../../../lib/chat.server";
19import { workspaceAgents } from "../../../lib/services.server";
20import { requireUser, roleIn } from "../../../lib/session.server";
21
22const 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. */
25export 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. */
45function 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
70export 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 */
97export 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&apos;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&apos;s channel, paid from {agent.display_name}&apos;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
143function 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
237function 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}&apos;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">&ldquo;{suggestion.responsibility}&rdquo;</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}