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