Skip to content
210 linesCodeBlameRaw
1import { type ReactNode, useId, useState } from "react";
2
3import { type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSchedule } from "@g1t/contracts";
4
5import { Button } from "../ui/button";
6import { Card } from "../ui/card";
7import { CheckboxOption } from "../ui/checkbox";
8import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
9import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
10import { Input } from "../ui/input";
11import { SelectField } from "../ui/select";
12import { Switch } from "../ui/switch";
13import { Textarea } from "../ui/textarea";
14import { useDialogFetcher } from "./dialogs";
15import { timeField, weekdayName } from "./format";
16
17/** A conversation a routine can post in. */
18export type RoutineChannel = { id: string; label: string };
19
20/** What the dialog starts from: a routine to change, a suggestion to add, or nothing. */
21export type RoutineDraft = Partial<Omit<NewRoutine, "schedule">> & { id?: string | null; schedule?: RoutineSchedule | null };
22
23const EVERY = [
24 { value: "hour", label: "Every hour" },
25 { value: "day", label: "Every day" },
26 { value: "weekday", label: "Every weekday" },
27 { value: "week", label: "Every week" },
28];
29
30const DEFAULT_SCHEDULE: RoutineSchedule = { every: "weekday", hour: 9, minute: 0, weekday: 1 };
31
32/**
33 * A routine, made or changed, for owners: its name and instructions, when it
34 * runs (a schedule, things that happen, or both), and where it posts.
35 * Whoever saves it becomes its sponsor: it runs with their access.
36 */
37export function RoutineDialog({
38 agentName,
39 draft,
40 channels,
41 trigger,
42 title,
43}: {
44 agentName: string;
45 draft: RoutineDraft;
46 channels: RoutineChannel[];
47 trigger: ReactNode;
48 title: string;
49}) {
50 const { fetcher, open, setOpen, error, busy } = useDialogFetcher(`routine-${draft.id ?? draft.name ?? "new"}`);
51 const id = useId();
52 const [scheduled, setScheduled] = useState(draft.schedule != null || (draft.events ?? []).length === 0);
53 const [schedule, setSchedule] = useState<RoutineSchedule>(draft.schedule ?? DEFAULT_SCHEDULE);
54 const [events, setEvents] = useState<RoutineEvent[]>(draft.events ?? []);
55 const known = draft.channel_id && !channels.some((c) => c.id === draft.channel_id) ? [{ id: draft.channel_id, label: "Its current channel" }, ...channels] : channels;
56 return (
57 <Dialog open={open} onOpenChange={setOpen}>
58 <DialogTrigger asChild>{trigger}</DialogTrigger>
59 <DialogContent className="max-w-xl">
60 <DialogHeader>
61 <DialogTitle>{title}</DialogTitle>
62 <DialogDescription>
63 Each run is a session {agentName} posts in the channel you choose, paid from its budget. It runs with your access, never more: saving makes you its sponsor.
64 </DialogDescription>
65 </DialogHeader>
66 <fetcher.Form method="post" className="grid gap-5">
67 <input type="hidden" name="intent" value="save" />
68 {draft.id && <input type="hidden" name="id" value={draft.id} />}
69 <input type="hidden" name="enabled" value={draft.enabled === false ? "false" : "true"} />
70 <Field>
71 <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel>
72 <Input id={`${id}-name`} name="name" defaultValue={draft.name ?? ""} maxLength={80} required placeholder="Monday support digest" />
73 </Field>
74 <Field>
75 <FieldLabel htmlFor={`${id}-instructions`}>Instructions</FieldLabel>
76 <Textarea
77 id={`${id}-instructions`}
78 name="instructions"
79 defaultValue={draft.instructions ?? ""}
80 rows={4}
81 maxLength={8000}
82 required
83 placeholder="Read last week's #support threads and post the top themes, with links, and what's still open."
84 />
85 </Field>
86
87 <Card asChild tone="plain" radius="lg" className="grid gap-4 p-4">
88 <fieldset>
89 <legend className="px-1 text-sm font-medium">When</legend>
90 <label className="flex items-center justify-between gap-3 text-sm">
91 <span>
92 On a schedule
93 <span className="block text-xs text-faint">In UTC.</span>
94 </span>
95 <Switch checked={scheduled} onCheckedChange={setScheduled} aria-label="On a schedule" />
96 </label>
97 {scheduled && (
98 <div className="flex flex-wrap items-center gap-2">
99 <input type="hidden" name="every" value={schedule.every} />
100 <input type="hidden" name="weekday" value={schedule.weekday} />
101 <div className="w-40">
102 <SelectField
103 value={schedule.every}
104 onValueChange={(every) => setSchedule({ ...schedule, every: every as RoutineSchedule["every"] })}
105 options={EVERY}
106 size="sm"
107 aria-label="How often"
108 />
109 </div>
110 {schedule.every === "week" && (
111 <div className="w-36">
112 <SelectField
113 value={String(schedule.weekday)}
114 onValueChange={(day) => setSchedule({ ...schedule, weekday: Number(day) })}
115 options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: `on ${weekdayName(d)}` }))}
116 size="sm"
117 aria-label="Day of the week"
118 />
119 </div>
120 )}
121 {schedule.every === "hour" ? (
122 <label className="flex items-center gap-2 text-sm text-muted">
123 at minute
124 <Input
125 name="minute"
126 type="number"
127 min={0}
128 max={59}
129 value={schedule.minute}
130 onChange={(e) => setSchedule({ ...schedule, minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })}
131 className="h-8 w-20"
132 />
133 </label>
134 ) : (
135 <label className="flex items-center gap-2 text-sm text-muted">
136 at
137 <Input
138 name="time"
139 type="time"
140 value={timeField(schedule)}
141 onChange={(e) => {
142 const [h, m] = e.target.value.split(":").map(Number);
143 setSchedule({ ...schedule, hour: h || 0, minute: m || 0 });
144 }}
145 className="h-8 w-28"
146 />
147 UTC
148 </label>
149 )}
150 </div>
151 )}
152 <div className="border-t border-line pt-4">
153 <p className="text-sm">When something happens</p>
154 <p className="text-xs text-faint">Each run is about the one thing that happened, in a repository you can read.</p>
155 <div className="mt-3 grid gap-2.5">
156 {ROUTINE_EVENTS.map((event) => (
157 <CheckboxOption
158 key={event.key}
159 name="events"
160 value={event.key}
161 checked={events.includes(event.key)}
162 onCheckedChange={(on) => setEvents(on ? [...events, event.key] : events.filter((e) => e !== event.key))}
163 label={event.label}
164 description={event.hint}
165 />
166 ))}
167 </div>
168 {events.length > 0 && (
169 <Field className="mt-4">
170 <FieldLabel htmlFor={`${id}-repos`}>Repositories</FieldLabel>
171 <Input id={`${id}-repos`} name="repos" defaultValue={(draft.repos ?? []).join(", ")} placeholder="acme/web, acme/api" autoComplete="off" />
172 <FieldDescription>Empty: every repository you can read.</FieldDescription>
173 </Field>
174 )}
175 </div>
176 {!scheduled && events.length === 0 && <p className="text-xs text-warn">Choose a schedule, something that happens, or both.</p>}
177 </fieldset>
178 </Card>
179 {scheduled ? null : <input type="hidden" name="every" value="none" />}
180
181 <Field>
182 <FieldLabel htmlFor={`${id}-channel`}>Posts in</FieldLabel>
183 {known.length > 0 ? (
184 <SelectField
185 id={`${id}-channel`}
186 name="channel"
187 defaultValue={draft.channel_id ?? undefined}
188 options={known.map((c) => ({ value: c.id, label: c.label }))}
189 placeholder="Choose a conversation"
190 required
191 />
192 ) : (
193 <p className="text-sm text-muted">You&apos;re in no channel yet. Join one in Chat, with {agentName} in it, first.</p>
194 )}
195 <FieldDescription>A channel you and {agentName} are both in.</FieldDescription>
196 </Field>
197 <FieldError>{error}</FieldError>
198 <DialogFooter>
199 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
200 Cancel
201 </Button>
202 <Button type="submit" variant="accent" disabled={busy || (!scheduled && events.length === 0)}>
203 {busy ? "Saving…" : draft.id ? "Save routine" : "Add routine"}
204 </Button>
205 </DialogFooter>
206 </fetcher.Form>
207 </DialogContent>
208 </Dialog>
209 );
210}