Skip to content
265 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, BUTTONS, 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 { Hint } from "../../../components/ui/hint";
14import { Switch } from "../../../components/ui/switch";
15import { agentDmOf } from "../../../lib/chat";
16import { sidebarOrNull } from "../../../lib/chat.server";
17import { workspaceAgents } from "../../../lib/services.server";
18import { requireUser, roleIn } from "../../../lib/session.server";
19
20const 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. */
23export 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. */
43function 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
68export 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 */
95export 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&apos;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&apos;s channel, paid from {agent.display_name}&apos;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
139function 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
233function 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}&apos;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">&ldquo;{suggestion.responsibility}&rdquo;</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}