|
| 1 | +// A bot asking to change or delete one of its routines (spec 20.12). A |
| 2 | +// change shows what changes, before and after, and the owner can adjust it |
| 3 | +// before allowing it; a deletion shows why. Either can be refused with a |
| 4 | +// note for the bot. Answered, each shrinks to one line. |
| 5 | + |
| 6 | +import { AlarmClock, Ban, Check, Pencil, TimerOff, Trash2 } from "lucide-react"; |
| 7 | +import { useState } from "react"; |
| 8 | +import { useT } from "../../i18n"; |
| 9 | +import { errorText } from "../../lib/api"; |
| 10 | +import { type ApprovalItem, type Bot, FIELD_LIMITS, type Schedule } from "../../lib/protocol.gen"; |
| 11 | +import { RpcError } from "../../lib/rpc"; |
| 12 | +import { useApi } from "../../store/context"; |
| 13 | +import { Button } from "../../ui/Button"; |
| 14 | +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; |
| 15 | +import { TextArea, TextField } from "../../ui/Field"; |
| 16 | +import { Switch } from "../../ui/Switch"; |
| 17 | +import { describeSchedule, zoneLabel } from "./describe"; |
| 18 | +import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form"; |
| 19 | +import { MoreOptions } from "./MoreOptions"; |
| 20 | +import { ScheduleFields } from "./ScheduleFields"; |
| 21 | + |
| 22 | +export const CHANGE_ROUTINE_TOOL = "mcp__botloft__change_routine"; |
| 23 | +export const DELETE_ROUTINE_TOOL = "mcp__botloft__delete_routine"; |
| 24 | + |
| 25 | +type Changed = RoutineFields & { routine_id: string; enabled: boolean }; |
| 26 | + |
| 27 | +interface Asked { |
| 28 | + name: string; |
| 29 | + before: Changed | null; |
| 30 | + after: Changed | null; |
| 31 | +} |
| 32 | + |
| 33 | +function parsed<T>(input: string): Partial<T> { |
| 34 | + try { |
| 35 | + return JSON.parse(input) as Partial<T>; |
| 36 | + } catch { |
| 37 | + return {}; |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +/** One thing that changes, before and after, in words. */ |
| 42 | +function useChanges(before: Changed, after: Changed): [string, string, string][] { |
| 43 | + const t = useT(); |
| 44 | + const w = t.chat.routineChange; |
| 45 | + const when = (schedule: Schedule) => describeSchedule(schedule, t.routines.when); |
| 46 | + const onOff = (on: boolean) => (on ? w.on : w.off); |
| 47 | + const rows: [string, string, string][] = []; |
| 48 | + if (before.name !== after.name) rows.push([t.routines.dialog.name, before.name, after.name]); |
| 49 | + if (JSON.stringify(before.schedule) !== JSON.stringify(after.schedule)) { |
| 50 | + rows.push([t.routines.dialog.when, when(before.schedule), when(after.schedule)]); |
| 51 | + } |
| 52 | + if (before.timezone !== after.timezone) { |
| 53 | + rows.push([w.timezone, zoneLabel(before.timezone), zoneLabel(after.timezone)]); |
| 54 | + } |
| 55 | + if (before.enabled !== after.enabled) { |
| 56 | + rows.push([w.state, onOff(before.enabled), onOff(after.enabled)]); |
| 57 | + } |
| 58 | + if (before.overlap !== after.overlap || before.missed !== after.missed) { |
| 59 | + rows.push([w.options, w.optionsBefore, w.optionsAfter]); |
| 60 | + } |
| 61 | + return rows; |
| 62 | +} |
| 63 | + |
| 64 | +function answerError(failure: unknown, reasons: Record<string, string>, fallback: string): string { |
| 65 | + const reason = failure instanceof RpcError ? failure.reason : undefined; |
| 66 | + return reason && reason in reasons |
| 67 | + ? (reasons[reason] as string) |
| 68 | + : `${fallback}: ${errorText(failure)}`; |
| 69 | +} |
| 70 | + |
| 71 | +export function RoutineChangeCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { |
| 72 | + const t = useT(); |
| 73 | + const w = t.chat.routineChange; |
| 74 | + const d = t.routines.dialog; |
| 75 | + const api = useApi(); |
| 76 | + const asked = parsed<Asked>(approval.input); |
| 77 | + const before = asked.before ?? null; |
| 78 | + const after = asked.after ?? null; |
| 79 | + const [form, setForm] = useState<RoutineForm | null>(null); |
| 80 | + const [enabled, setEnabled] = useState(after?.enabled ?? true); |
| 81 | + const [note, setNote] = useState(""); |
| 82 | + const [busy, setBusy] = useState(false); |
| 83 | + const [error, setError] = useState<string | null>(null); |
| 84 | + const changes = useChanges(before ?? (after as Changed), after ?? (before as Changed)); |
| 85 | + if (approval.status !== "pending" || !before || !after) { |
| 86 | + return <Answered approval={approval} kind="change" />; |
| 87 | + } |
| 88 | + const change = (patch: Partial<RoutineForm>) => |
| 89 | + setForm((current) => (current ? { ...current, ...patch } : current)); |
| 90 | + |
| 91 | + const answer = async (allow: boolean) => { |
| 92 | + setBusy(true); |
| 93 | + setError(null); |
| 94 | + const adjusted: Changed | null = form |
| 95 | + ? { |
| 96 | + routine_id: after.routine_id, |
| 97 | + name: form.name, |
| 98 | + prompt: form.prompt, |
| 99 | + schedule: scheduleOf(form), |
| 100 | + timezone: form.timezone, |
| 101 | + overlap: form.overlap, |
| 102 | + missed: form.missed, |
| 103 | + enabled, |
| 104 | + } |
| 105 | + : null; |
| 106 | + const trimmed = note.trim(); |
| 107 | + try { |
| 108 | + await api.call("approvals.answer", { |
| 109 | + approvalId: approval.approvalId, |
| 110 | + allow, |
| 111 | + ...(!allow && trimmed ? { note: trimmed } : {}), |
| 112 | + ...(allow && adjusted ? { input: JSON.stringify(adjusted) } : {}), |
| 113 | + }); |
| 114 | + } catch (failure) { |
| 115 | + setError(answerError(failure, d.reasons, allow ? w.applyFailed : w.declineFailed)); |
| 116 | + } |
| 117 | + setBusy(false); |
| 118 | + }; |
| 119 | + |
| 120 | + return ( |
| 121 | + <SectionCard |
| 122 | + label={w.title(bot.name, asked.name ?? before.name)} |
| 123 | + icon={AlarmClock} |
| 124 | + tone="accent" |
| 125 | + footer={ |
| 126 | + <> |
| 127 | + <NoteArea |
| 128 | + label={w.noteLabel(bot.name)} |
| 129 | + value={note} |
| 130 | + onChange={setNote} |
| 131 | + placeholder={w.notePlaceholder(bot.name)} |
| 132 | + /> |
| 133 | + <div className="mt-2.5 flex flex-wrap gap-2"> |
| 134 | + <Button variant="primary" icon={Check} disabled={busy} onClick={() => answer(true)}> |
| 135 | + {w.apply} |
| 136 | + </Button> |
| 137 | + <Button icon={Ban} disabled={busy} onClick={() => answer(false)}> |
| 138 | + {w.decline} |
| 139 | + </Button> |
| 140 | + </div> |
| 141 | + </> |
| 142 | + } |
| 143 | + > |
| 144 | + <div className="flex flex-col gap-3 px-4 py-3 text-sm"> |
| 145 | + {changes.length > 0 && ( |
| 146 | + <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5"> |
| 147 | + {changes.map(([what, was, becomes]) => ( |
| 148 | + <div key={what} className="contents"> |
| 149 | + <dt className="text-muted">{what}</dt> |
| 150 | + <dd> |
| 151 | + <span className="text-muted line-through">{was}</span> |
| 152 | + {" → "} |
| 153 | + <span className="font-medium">{becomes}</span> |
| 154 | + </dd> |
| 155 | + </div> |
| 156 | + ))} |
| 157 | + </dl> |
| 158 | + )} |
| 159 | + {before.prompt !== after.prompt && ( |
| 160 | + <details className="text-xs"> |
| 161 | + <summary className="cursor-pointer text-muted">{w.newPrompt}</summary> |
| 162 | + <p className="mt-1 whitespace-pre-wrap text-ink-soft" data-selectable> |
| 163 | + {after.prompt} |
| 164 | + </p> |
| 165 | + </details> |
| 166 | + )} |
| 167 | + {form ? ( |
| 168 | + <div className="flex flex-col gap-4 border-line border-t pt-3"> |
| 169 | + <TextField |
| 170 | + label={d.name} |
| 171 | + value={form.name} |
| 172 | + max={80} |
| 173 | + onChange={(event) => change({ name: event.target.value })} |
| 174 | + /> |
| 175 | + <TextArea |
| 176 | + label={d.prompt(bot.name)} |
| 177 | + value={form.prompt} |
| 178 | + max={FIELD_LIMITS.message} |
| 179 | + rows={4} |
| 180 | + onChange={(event) => change({ prompt: event.target.value })} |
| 181 | + /> |
| 182 | + <ScheduleFields form={form} change={change} bot={bot.name} /> |
| 183 | + <MoreOptions form={form} change={change} /> |
| 184 | + <Switch checked={enabled} onChange={setEnabled} label={w.state} /> |
| 185 | + </div> |
| 186 | + ) : ( |
| 187 | + <Button |
| 188 | + size="sm" |
| 189 | + variant="ghost" |
| 190 | + icon={Pencil} |
| 191 | + className="self-start" |
| 192 | + onClick={() => setForm(formOf(after))} |
| 193 | + > |
| 194 | + {w.adjust} |
| 195 | + </Button> |
| 196 | + )} |
| 197 | + {error && ( |
| 198 | + <p role="alert" className="text-danger text-sm"> |
| 199 | + {error} |
| 200 | + </p> |
| 201 | + )} |
| 202 | + </div> |
| 203 | + </SectionCard> |
| 204 | + ); |
| 205 | +} |
| 206 | + |
| 207 | +export function RoutineDeleteCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { |
| 208 | + const t = useT(); |
| 209 | + const w = t.chat.routineDelete; |
| 210 | + const api = useApi(); |
| 211 | + const asked = parsed<{ name: string; schedule: Schedule; reason: string | null }>(approval.input); |
| 212 | + const [note, setNote] = useState(""); |
| 213 | + const [busy, setBusy] = useState(false); |
| 214 | + const [error, setError] = useState<string | null>(null); |
| 215 | + if (approval.status !== "pending") { |
| 216 | + return <Answered approval={approval} kind="delete" />; |
| 217 | + } |
| 218 | + const answer = async (allow: boolean) => { |
| 219 | + setBusy(true); |
| 220 | + setError(null); |
| 221 | + const trimmed = note.trim(); |
| 222 | + try { |
| 223 | + await api.call("approvals.answer", { |
| 224 | + approvalId: approval.approvalId, |
| 225 | + allow, |
| 226 | + ...(!allow && trimmed ? { note: trimmed } : {}), |
| 227 | + }); |
| 228 | + } catch (failure) { |
| 229 | + setError(`${w.failed}: ${errorText(failure)}`); |
| 230 | + } |
| 231 | + setBusy(false); |
| 232 | + }; |
| 233 | + return ( |
| 234 | + <SectionCard |
| 235 | + label={w.title(bot.name, asked.name ?? "")} |
| 236 | + icon={Trash2} |
| 237 | + tone="danger" |
| 238 | + footer={ |
| 239 | + <> |
| 240 | + <NoteArea |
| 241 | + label={w.noteLabel(bot.name)} |
| 242 | + value={note} |
| 243 | + onChange={setNote} |
| 244 | + placeholder={w.notePlaceholder(bot.name)} |
| 245 | + /> |
| 246 | + <div className="mt-2.5 flex flex-wrap gap-2"> |
| 247 | + <Button variant="danger" icon={Trash2} disabled={busy} onClick={() => answer(true)}> |
| 248 | + {w.delete} |
| 249 | + </Button> |
| 250 | + <Button icon={Ban} disabled={busy} onClick={() => answer(false)}> |
| 251 | + {w.keep} |
| 252 | + </Button> |
| 253 | + </div> |
| 254 | + </> |
| 255 | + } |
| 256 | + > |
| 257 | + <div className="flex flex-col gap-2 px-4 py-3 text-sm"> |
| 258 | + {asked.schedule && ( |
| 259 | + <p className="text-ink-soft">{describeSchedule(asked.schedule, t.routines.when)}</p> |
| 260 | + )} |
| 261 | + {asked.reason && ( |
| 262 | + <p> |
| 263 | + <span className="text-muted">{w.reason(bot.name)} </span> |
| 264 | + <span data-selectable>{asked.reason}</span> |
| 265 | + </p> |
| 266 | + )} |
| 267 | + <p className="text-muted text-xs">{w.explain}</p> |
| 268 | + {error && ( |
| 269 | + <p role="alert" className="text-danger text-sm"> |
| 270 | + {error} |
| 271 | + </p> |
| 272 | + )} |
| 273 | + </div> |
| 274 | + </SectionCard> |
| 275 | + ); |
| 276 | +} |
| 277 | + |
| 278 | +function Answered({ approval, kind }: { approval: ApprovalItem; kind: "change" | "delete" }) { |
| 279 | + const t = useT(); |
| 280 | + const w = kind === "change" ? t.chat.routineChange : t.chat.routineDelete; |
| 281 | + const name = parsed<{ name: string }>(approval.input).name ?? ""; |
| 282 | + const [icon, text, tone] = |
| 283 | + approval.status === "allowed" |
| 284 | + ? ([Check, w.done(name), "ok"] as const) |
| 285 | + : approval.status === "denied" |
| 286 | + ? ([Ban, w.declined(name), "danger"] as const) |
| 287 | + : ([TimerOff, w.expired(name), "quiet"] as const); |
| 288 | + return <SettledLine icon={icon} tone={tone} text={text} note={approval.note} />; |
| 289 | +} |
0 commit comments