Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents mode: an overview of every agent's work and spend, and each agent's sessions, memory, routines, spend, activity and versions | 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 | } |