Skip to content

Commit c840868

Browse files
committed
feat(routines): bots change and delete their own routines, with the owner's ok
my_routines lists a bot's routines; change_routine and delete_routine ask the owner in the chat, like schedule_routine does. The change card shows what changes, before and after, and the owner can adjust it before allowing it; the delete card shows why. A bot that bypasses permissions does it at once.
1 parent 3e15106 commit c840868

21 files changed

Lines changed: 1125 additions & 6 deletions

File tree

‎app/src/features/chat/ApprovalCard.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { NoteInput, RequestCard, SettledLine } from "../../ui/ChatCard";
99
import { Details } from "../../ui/Details";
1010
import { HELP_TOOL, HelpCard } from "../browser/HelpCard";
1111
import { SITE_TOOL, SiteCard } from "../browser/SiteCard";
12+
import {
13+
CHANGE_ROUTINE_TOOL,
14+
DELETE_ROUTINE_TOOL,
15+
RoutineChangeCard,
16+
RoutineDeleteCard,
17+
} from "../routines/RoutineChangeCard";
1218
import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard";
1319
import { commandOf, isCommand } from "./command";
1420
import { PLAN_TOOL, PlanCard } from "./PlanCard";
@@ -62,6 +68,10 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
6268
return <SuggestionCard approval={approval} bot={bot} />;
6369
case ROUTINE_TOOL:
6470
return <RoutineRequestCard approval={approval} bot={bot} />;
71+
case CHANGE_ROUTINE_TOOL:
72+
return <RoutineChangeCard approval={approval} bot={bot} />;
73+
case DELETE_ROUTINE_TOOL:
74+
return <RoutineDeleteCard approval={approval} bot={bot} />;
6575
case SITE_TOOL:
6676
return <SiteCard approval={approval} bot={bot} />;
6777
case HELP_TOOL:

‎app/src/features/chat/ToolLines.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ const ICONS: Record<string, LucideIcon> = {
5454
send_message: Send,
5555
suggest_bot: UserPlus,
5656
schedule_routine: AlarmClock,
57+
my_routines: AlarmClock,
58+
change_routine: AlarmClock,
59+
delete_routine: AlarmClock,
5760
browser_ask_owner: Hand,
5861
};
5962

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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

Comments
 (0)