Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/features/chat/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -66,6 +67,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B
return <PlanCard approval={approval} bot={bot} />;
case SUGGEST_TOOL:
return <SuggestionCard approval={approval} bot={bot} />;
case ROUTINE_TOOL:
return <RoutineRequestCard approval={approval} bot={bot} />;
case SITE_TOOL:
return <SiteCard approval={approval} bot={bot} />;
case HELP_TOOL:
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/chat/ToolLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// and the output (spec 15.3).

import {
AlarmClock,
Check,
ChevronRight,
FilePen,
Expand Down Expand Up @@ -51,6 +52,7 @@ const ICONS: Record<string, LucideIcon> = {
Agent: Users,
send_message: Send,
suggest_bot: UserPlus,
schedule_routine: AlarmClock,
browser_ask_owner: Hand,
};

Expand Down
212 changes: 212 additions & 0 deletions app/src/features/routines/RoutineRequestCard.tsx
Original file line number Diff line number Diff line change
@@ -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<RoutineFields> & { 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<RoutineForm>(() => formOf(asked.routine));
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const noteId = useId();
if (approval.status !== "pending") {
return <Answered approval={approval} />;
}
const change = (patch: Partial<RoutineForm>) => 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 (
<section
aria-label={title}
className="my-1 overflow-hidden rounded-2xl border border-line bg-panel shadow-sm animate-attention"
>
<p className="flex items-center gap-2.5 border-line border-b px-4 py-3 font-semibold text-sm">
<span className="grid h-7 w-7 place-items-center rounded-full bg-accent/12 text-accent">
<AlarmClock aria-hidden size={15} />
</span>
{title}
</p>
<div className="flex flex-col gap-4 px-4 py-3">
<TextField
label={d.name}
value={form.name}
max={80}
onChange={(event) => change({ name: event.target.value })}
/>
<TextArea
label={d.prompt(runner.name)}
value={form.prompt}
max={FIELD_LIMITS.message}
rows={4}
onChange={(event) => change({ prompt: event.target.value })}
/>
<ScheduleFields form={form} change={change} />
<MoreOptions form={form} change={change} />
<p className="text-muted text-xs">{r.explain(runner.name)}</p>
{error && (
<p role="alert" className="text-danger text-sm">
{error}
</p>
)}
</div>
<div className="border-line border-t bg-canvas/40 px-4 py-3">
<label htmlFor={noteId} className="sr-only">
{r.noteLabel(bot.name)}
</label>
<textarea
id={noteId}
rows={2}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={r.notePlaceholder(bot.name)}
className="block w-full resize-none rounded-xl border border-line-strong bg-panel px-3 py-2 text-sm outline-none placeholder:text-muted focus:border-muted"
/>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button
variant="primary"
icon={Check}
disabled={busy || !form.name.trim() || !form.prompt.trim()}
onClick={() => answer(true)}
>
{r.create}
</Button>
<Button icon={Ban} disabled={busy} onClick={() => answer(false)}>
{r.decline}
</Button>
</div>
</div>
</section>
);
}

/** One line once answered; what was asked stays a click away. */
function Answered({ approval }: { approval: ApprovalItem }) {
const t = useT();
const r = t.chat.routineRequest;
const { routine } = askedOf(approval.input);
const name = routine.name || t.routines.dialog.name;
const [Icon, text, tone] =
approval.status === "allowed"
? [Check, r.created(name), "text-ok"]
: approval.status === "denied"
? [Ban, r.declined(name), "text-danger"]
: [TimerOff, r.expired(name), "text-quiet"];
return (
<details className="group">
<summary className="flex min-w-0 cursor-default items-center gap-2 rounded-lg px-1.5 py-1 text-sm hover:bg-sunken">
<Icon aria-hidden size={14} className={`shrink-0 ${tone}`} />
<span className="shrink-0 font-medium">{text}</span>
{approval.note && (
<span className="truncate text-muted text-xs">{`“${approval.note}”`}</span>
)}
<ChevronRight
aria-hidden
size={13}
className="shrink-0 text-muted transition-transform group-open:rotate-90"
/>
</summary>
<dl
className="mt-1.5 ml-6 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-xl border border-line bg-panel px-4 py-3 text-sm"
data-selectable
>
<dt className="text-muted">{t.routines.dialog.when}</dt>
<dd>{describeSchedule(routine.schedule, t.routines.when)}</dd>
<dt className="text-muted">{t.routines.dialog.name}</dt>
<dd>{routine.name}</dd>
<dd className="col-span-2 whitespace-pre-wrap">{routine.prompt}</dd>
</dl>
</details>
);
}
8 changes: 7 additions & 1 deletion app/src/features/routines/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,14 @@ export function blankForm(): RoutineForm {
};
}

/** What the form is filled from: a routine, or one a bot asks for. */
export type RoutineFields = Pick<
Routine,
"name" | "prompt" | "schedule" | "timezone" | "overlap" | "missed"
>;

/** The form as it opens for an existing routine. */
export function formOf(routine: Routine): RoutineForm {
export function formOf(routine: RoutineFields): RoutineForm {
const form: RoutineForm = {
...blankForm(),
name: routine.name,
Expand Down
109 changes: 109 additions & 0 deletions app/src/features/routines/routineRequest.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { ROUTINE_TOOL } from "../../lib/fakeChat";
import { crewOpened, openBot, renderApp } from "../../test/app";

afterEach(cleanup);

const INBOX = {
name: "Morning inbox",
prompt: "Read the new mail and summarize what is urgent.",
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "08:00" },
timezone: "America/Sao_Paulo",
};

/** A crew with its chief and a bot "Mail", open on the chief's chat. */
async function openChief() {
const fake = new FakeBotloft();
const crew = await fake.call("crews.create", {
name: "Office",
lead: { name: "Chief", role: "Leads the crew", instructions: "Run the office" },
});
const chiefId = crew.leadBotId as string;
const mail = await fake.call("bots.create", {
crewId: crew.id,
name: "Mail",
role: "Reads the inbox",
instructions: "",
});
fake.setBotState(chiefId, "idle");
renderApp(fake);
await crewOpened("Office");
openBot("Chief");
await screen.findByRole("list", { name: "Messages" });
return { fake, chiefId, mail };
}

function ask(fake: FakeBotloft, botId: string, input: object, title: string) {
act(() => {
fake.chat.ask(botId, ROUTINE_TOOL, "Morning inbox", JSON.stringify(input));
});
return screen.getByRole("region", { name: title });
}

const answer = (fake: FakeBotloft) =>
fake.calls.find((call) => call.method === "approvals.answer")?.params;

const routines = (fake: FakeBotloft) => [...fake.routines.routines.values()];

describe("a bot asking for a routine", () => {
test("the owner creates it as asked", async () => {
const { fake, chiefId } = await openChief();
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
expect((within(card).getByLabelText("Name") as HTMLInputElement).value).toBe("Morning inbox");
expect(within(card).getByRole("radio", { name: "Weekdays" })).toHaveProperty("checked", true);
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));

expect(await screen.findByText("You created the routine Morning inbox")).toBeDefined();
expect(answer(fake)).toEqual({ approvalId: expect.any(String), allow: true });
expect(routines(fake)).toMatchObject([
{ botId: chiefId, name: "Morning inbox", timezone: "America/Sao_Paulo" },
]);
});

test("the owner can change when it runs before creating it", async () => {
const { fake, chiefId } = await openChief();
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
fireEvent.change(within(card).getByLabelText("Name"), { target: { value: "Inbox at nine" } });
fireEvent.click(within(card).getByRole("radio", { name: "Every day" }));
fireEvent.change(within(card).getByLabelText("At"), { target: { value: "09:00" } });
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));

expect(await screen.findByText("You created the routine Inbox at nine")).toBeDefined();
const sent = JSON.parse(String((answer(fake) as { input?: string } | undefined)?.input));
expect(sent).toMatchObject({
name: "Inbox at nine",
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "09:00" },
});
expect(routines(fake)[0]?.schedule).toEqual(sent.schedule);
});

test("a routine for another bot of the crew goes to that bot", async () => {
const { fake, chiefId, mail } = await openChief();
const card = ask(
fake,
chiefId,
{ ...INBOX, bot: mail.handle },
"Chief wants to set up a routine for Mail",
);
expect(within(card).getByLabelText("What should Mail do?")).toBeDefined();
fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));

await screen.findByText("You created the routine Morning inbox");
expect(routines(fake)).toMatchObject([{ botId: mail.id }]);
});

test("a no goes back with the owner's note and creates nothing", async () => {
const { fake, chiefId } = await openChief();
const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
fireEvent.change(within(card).getByPlaceholderText(/If you say no, tell Chief why/), {
target: { value: "I check it myself" },
});
fireEvent.click(within(card).getByRole("button", { name: "Not now" }));

expect(await screen.findByText("You said no to the routine Morning inbox")).toBeDefined();
expect(answer(fake)).toMatchObject({ allow: false, note: "I check it myself" });
expect(routines(fake)).toEqual([]);
});
});
15 changes: 15 additions & 0 deletions app/src/i18n/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,21 @@ export const chat = {
declined: (name: string) => `You said no to ${name}`,
expired: (name: string) => `${name} was not answered in time`,
},
routineRequest: {
title: (bot: string) => `${bot} wants to set up a routine`,
titleFor: (bot: string, runner: string) => `${bot} wants to set up a routine for ${runner}`,
explain: (runner: string) =>
`A routine makes ${runner} work on its own at set times. It shows in the Routines tab, where you can pause or delete it.`,
noteLabel: (bot: string) => `What to tell ${bot} if you say no`,
notePlaceholder: (bot: string) => `If you say no, tell ${bot} why (optional)`,
create: "Create routine",
decline: "Not now",
createFailed: "Could not create the routine",
declineFailed: "Could not send the answer",
created: (name: string) => `You created the routine ${name}`,
declined: (name: string) => `You said no to the routine ${name}`,
expired: (name: string) => `The routine ${name} was not answered in time`,
},
plan: {
ready: (bot: string) => `${bot} made a plan and wants to go ahead`,
noteLabel: (bot: string) => `What ${bot} should change in the plan`,
Expand Down
Loading
Loading