diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index 19505971..f9e719c4 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -9,6 +9,7 @@ import { Button } from "../../ui/Button"; import { Details } from "../../ui/Details"; import { HELP_TOOL, HelpCard } from "../browser/HelpCard"; import { SITE_TOOL, SiteCard } from "../browser/SiteCard"; +import { ROUTINE_TOOL, RoutineRequestCard } from "../routines/RoutineRequestCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; @@ -66,6 +67,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B return ; case SUGGEST_TOOL: return ; + case ROUTINE_TOOL: + return ; case SITE_TOOL: return ; case HELP_TOOL: diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 7f9a9c9a..8a92d0a2 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -3,6 +3,7 @@ // and the output (spec 15.3). import { + AlarmClock, Check, ChevronRight, FilePen, @@ -51,6 +52,7 @@ const ICONS: Record = { Agent: Users, send_message: Send, suggest_bot: UserPlus, + schedule_routine: AlarmClock, browser_ask_owner: Hand, }; diff --git a/app/src/features/routines/RoutineRequestCard.tsx b/app/src/features/routines/RoutineRequestCard.tsx new file mode 100644 index 00000000..59cc4b5c --- /dev/null +++ b/app/src/features/routines/RoutineRequestCard.tsx @@ -0,0 +1,212 @@ +// A bot asking for a routine (spec 20.12), for itself or for another bot +// of its crew. The owner can change the name, what to do and when before +// creating it, or say no with a note for the bot. A refusal the daemon +// explains with a reason is worded here. Once answered it shrinks to one +// line. + +import { AlarmClock, Ban, Check, ChevronRight, TimerOff } from "lucide-react"; +import { useId, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { type ApprovalItem, type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; +import { RpcError } from "../../lib/rpc"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { TextArea, TextField } from "../../ui/Field"; +import { describeSchedule, systemZone } from "./describe"; +import { formOf, type RoutineFields, type RoutineForm, scheduleOf } from "./form"; +import { MoreOptions } from "./MoreOptions"; +import { ScheduleFields } from "./ScheduleFields"; + +export const ROUTINE_TOOL = "mcp__botloft__schedule_routine"; + +/** The routine asked for, and the bot it is for by handle. */ +interface Asked { + routine: RoutineFields; + bot?: string; +} + +/** The routine in the request's input; defaults for what is cut short. */ +export function askedOf(input: string): Asked { + const blank: RoutineFields = { + name: "", + prompt: "", + schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "09:00" }, + timezone: systemZone(), + overlap: "skip", + missed: "run_once", + }; + try { + const { bot, ...fields } = JSON.parse(input) as Partial & { bot?: string }; + return { routine: { ...blank, ...fields }, ...(bot && { bot }) }; + } catch { + return { routine: { ...blank, prompt: input } }; + } +} + +function formFields(form: RoutineForm): RoutineFields { + return { + name: form.name, + prompt: form.prompt, + schedule: scheduleOf(form), + timezone: form.timezone, + overlap: form.overlap, + missed: form.missed, + }; +} + +/** The bot the routine is for: the one that asked, or one of its crew. */ +function useRunner(bot: Bot, handle: string | undefined): Bot { + const bots = useApp((state) => state.bots); + if (!handle) { + return bot; + } + const found = Object.values(bots).find( + (other) => other.crewId === bot.crewId && other.handle === handle, + ); + return found ?? { ...bot, name: `@${handle}` }; +} + +export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const r = t.chat.routineRequest; + const d = t.routines.dialog; + const api = useApi(); + const asked = askedOf(approval.input); + const runner = useRunner(bot, asked.bot); + const [form, setForm] = useState(() => formOf(asked.routine)); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const noteId = useId(); + if (approval.status !== "pending") { + return ; + } + const change = (patch: Partial) => setForm((current) => ({ ...current, ...patch })); + + const answer = async (allow: boolean) => { + setBusy(true); + setError(null); + const edited = formFields(form); + const changed = JSON.stringify(edited) !== JSON.stringify(formFields(formOf(asked.routine))); + const trimmed = note.trim(); + try { + await api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(!allow && trimmed ? { note: trimmed } : {}), + ...(allow && changed ? { input: JSON.stringify(edited) } : {}), + }); + } catch (failure) { + const reason = failure instanceof RpcError ? failure.reason : undefined; + const worded = + reason && reason in d.reasons ? d.reasons[reason as keyof typeof d.reasons] : null; + setError(worded ?? `${allow ? r.createFailed : r.declineFailed}: ${errorText(failure)}`); + } + setBusy(false); + }; + + const title = asked.bot ? r.titleFor(bot.name, runner.name) : r.title(bot.name); + return ( +
+

+ + + + {title} +

+
+ change({ name: event.target.value })} + /> +