Skip to content
206 linesCodeBlameRaw

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 versions1import { type ReactNode, useId, useState } from "react";
2
3import { type NewRoutine, ROUTINE_EVENTS, type RoutineEvent, type RoutineSchedule } from "@g1t/contracts";
4
5import { CheckboxOption } from "../ui/checkbox";
6import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
7import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
8import { Input } from "../ui/input";
9import { SelectField } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { Textarea } from "../ui/textarea";
12import { BUTTONS, useDialogFetcher } from "./dialogs";
13import { timeField, weekdayName } from "./format";
14
15/** A conversation a routine can post in. */
16export type RoutineChannel = { id: string; label: string };
17
18/** What the dialog starts from: a routine to change, a suggestion to add, or nothing. */
19export type RoutineDraft = Partial<Omit<NewRoutine, "schedule">> & { id?: string | null; schedule?: RoutineSchedule | null };
20
21const 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
28const 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 */
35export 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&apos;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}