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
107 changes: 107 additions & 0 deletions app/src/features/routines/DailySummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// The daily summary (spec 29.2): a routine for the crew's chief, which asks it
// each evening to tell the owner what the bots did. It is a routine like any
// other, shown below in the list; this card is the easy way to make it and to
// turn it on or off. The app knows it by the tool its request names.

import { Newspaper } from "lucide-react";
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Crew } from "../../lib/protocol.gen";
import { routinesOf } from "../../store/app";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { describeSchedule, systemZone } from "./describe";
import { RoutineDialog } from "./RoutineDialog";

const TOOL = "crew_activity";
const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7];

export function DailySummary({ crew }: { crew: Crew }) {
const t = useT();
const s = t.routines.summary;
const api = useApi();
const putRoutine = useApp((state) => state.putRoutine);
const chief = useApp((state) => (crew.leadBotId ? state.bots[crew.leadBotId] : undefined));
const mine = useApp(useShallow((state) => (chief ? routinesOf(state, chief.id) : [])));
const existing = mine.find((routine) => routine.prompt.includes(TOOL));
const [time, setTime] = useState("18:00");
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
if (!chief) {
return null;
}

const run = async (action: () => Promise<unknown>) => {
setBusy(true);
await attempt(s.failed, action);
setBusy(false);
};
const create = () =>
run(async () =>
putRoutine(
await api.call("routines.create", {
botId: chief.id,
name: s.name,
prompt: s.prompt,
schedule: { kind: "weekly", days: EVERY_DAY, time },
timezone: systemZone(),
overlap: "skip",
missed: "run_once",
}),
),
);
const toggle = (enabled: boolean) =>
run(async () => {
if (existing) {
putRoutine(await api.call("routines.setEnabled", { routineId: existing.id, enabled }));
}
});

return (
<section
aria-label={s.title}
className="mb-3 flex max-w-3xl flex-col gap-2 rounded-xl border border-line bg-panel p-4"
>
<div className="flex items-start gap-3">
<Newspaper aria-hidden size={20} className="mt-0.5 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{s.title}</h3>
<p className="text-ink-soft text-sm">{s.hint}</p>
</div>
</div>
{existing ? (
<div className="flex flex-wrap items-center gap-2">
<span className={`text-sm ${existing.enabled ? "text-ok" : "text-muted"}`}>
{existing.enabled ? s.on(describeSchedule(existing.schedule, t.routines.when)) : s.off}
</span>
<Button size="sm" disabled={busy} onClick={() => toggle(!existing.enabled)}>
{existing.enabled ? s.turnOff : s.turnOn}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
{s.change}
</Button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<span className="text-ink-soft">{s.time}</span>
<input
type="time"
value={time}
onChange={(event) => setTime(event.target.value)}
className="h-8 rounded-lg border border-line-strong bg-canvas px-2 text-ink outline-none focus:border-accent"
/>
</label>
<Button size="sm" variant="primary" disabled={busy || !time} onClick={create}>
{s.turnOn}
</Button>
</div>
)}
{editing && existing && (
<RoutineDialog bot={chief} routine={existing} onClose={() => setEditing(false)} />
)}
</section>
);
}
2 changes: 2 additions & 0 deletions app/src/features/routines/RoutineList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen";
import { botsOf, routinesOf } from "../../store/app";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { DailySummary } from "./DailySummary";
import { RoutineDialog } from "./RoutineDialog";
import { RoutineRow } from "./RoutineRow";

Expand Down Expand Up @@ -66,6 +67,7 @@ export function CrewRoutines({ crew }: { crew: Crew }) {
});
return (
<div className="min-h-0 flex-1 overflow-y-auto p-5">
<DailySummary crew={crew} />
{byBot.length === 0 ? (
<p className="rounded-2xl border border-line border-dashed px-6 py-10 text-center text-muted text-sm">
{r.crewEmpty}
Expand Down
80 changes: 80 additions & 0 deletions app/src/features/routines/dailySummary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { en } from "../../i18n/en";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openTab, renderApp } from "../../test/app";

afterEach(cleanup);

const calls = (fake: FakeBotloft, method: string) =>
fake.calls.filter((call) => call.method === method);

/** A crew "Site" with its chief, open on the crew's routines. */
async function openCrewRoutines(withChief = true) {
const fake = new FakeBotloft();
if (withChief) {
await fake.call("crews.create", {
name: "Site",
lead: { name: "Chief", role: "Leads the crew", instructions: "Build it" },
});
} else {
fake.addCrew("Site");
}
renderApp(fake);
await crewOpened("Site");
openTab(/^Routines/);
return { fake };
}

describe("the daily summary", () => {
test("is offered on the crew's routines and turned on for the chief, every day", async () => {
const { fake } = await openCrewRoutines();
const card = await screen.findByRole("region", { name: "Daily summary" });
fireEvent.change(within(card).getByLabelText("Time"), { target: { value: "19:30" } });
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));

await waitFor(() => expect(calls(fake, "routines.create")).toHaveLength(1));
const [create] = calls(fake, "routines.create");
expect(create?.params).toMatchObject({
name: "Daily summary",
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "19:30" },
overlap: "skip",
missed: "run_once",
});
// It asks the chief to use the tool that tells it what the bots did.
const params = create?.params as { prompt: string; botId: string } | undefined;
expect(params?.prompt).toContain("crew_activity");
const chief = (await fake.call("bots.list", {})).find((bot) => bot.name === "Chief");
expect(params?.botId).toBe(chief?.id);

expect(await within(card).findByText(/^On · /)).toBeDefined();
expect(within(card).getByRole("button", { name: "Turn off" })).toBeDefined();
});

test("can be turned off and on again, and shows in the list like any routine", async () => {
const { fake } = await openCrewRoutines();
const card = await screen.findByRole("region", { name: "Daily summary" });
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));
await within(card).findByText(/^On · /);
expect(
within(await screen.findByRole("list", { name: "Routines" })).getByText("Daily summary"),
).toBeDefined();

fireEvent.click(within(card).getByRole("button", { name: "Turn off" }));
expect(await within(card).findByText("Off")).toBeDefined();
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));
await within(card).findByText(/^On · /);
expect(calls(fake, "routines.setEnabled").map((call) => call.params)).toEqual([
expect.objectContaining({ enabled: false }),
expect.objectContaining({ enabled: true }),
]);
// One routine, not a second one each time.
expect(calls(fake, "routines.create")).toHaveLength(1);
});

test("is not offered to a crew without a chief", async () => {
await openCrewRoutines(false);
await screen.findByText(en.routines.crewEmpty);
expect(screen.queryByRole("region", { name: "Daily summary" })).toBeNull();
});
});
17 changes: 17 additions & 0 deletions app/src/i18n/en/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ export const routines = {
signal_invalid: "A signal needs letters or digits, like report-ready.",
},
},
/** The daily summary the chief writes (spec 29.2), a routine made for the owner. */
summary: {
title: "Daily summary",
hint: "Every day the chief tells you what each bot did, what is stuck and what is waiting for you.",
name: "Daily summary",
// Sent to the chief every day. It names the tool, which is also how the app
// recognizes this routine.
prompt:
"The day is ending. Use crew_activity (the last 24 hours) and write me a short summary of the day: what each bot finished, what is still open or late, what failed, and what is waiting for me (approvals and questions), the most important first. Plain words, no more than 12 lines. If nothing happened, say so in one line. Do not start new work and do not message other bots for this.",
time: "Time",
turnOn: "Turn on",
turnOff: "Turn off",
on: (when: string) => `On · ${when}`,
off: "Off",
change: "Change",
failed: "Could not change the daily summary",
},
removeTitle: (name: string) => `Delete ${name}?`,
removeBody: "It stops running. What it already did stays in the chat.",
failed: {
Expand Down
14 changes: 14 additions & 0 deletions app/src/i18n/es/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ export const routines: Messages["routines"] = {
signal_invalid: "El aviso necesita letras o números, como informe-listo.",
},
},
summary: {
title: "Resumen diario",
hint: "Cada día el jefe te cuenta qué hizo cada bot, qué está parado y qué te está esperando.",
name: "Resumen diario",
prompt:
"El día se acaba. Usa crew_activity (las últimas 24 horas) y escríbeme un resumen corto del día: qué terminó cada bot, qué sigue abierto o atrasado, qué falló y qué me está esperando (aprobaciones y preguntas), lo más importante primero. Palabras sencillas, como máximo 12 líneas. Si no pasó nada, dilo en una línea. No empieces trabajo nuevo ni escribas a otros bots por esto.",
time: "Hora",
turnOn: "Activar",
turnOff: "Desactivar",
on: (when: string) => `Activado · ${when}`,
off: "Desactivado",
change: "Cambiar",
failed: "No se pudo cambiar el resumen diario",
},
removeTitle: (name: string) => `¿Eliminar ${name}?`,
removeBody: "Deja de ejecutarse. Lo que ya hizo sigue en el chat.",
failed: {
Expand Down
14 changes: 14 additions & 0 deletions app/src/i18n/pt-BR/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ export const routines: Messages["routines"] = {
signal_invalid: "O aviso precisa de letras ou números, como relatorio-pronto.",
},
},
summary: {
title: "Resumo diário",
hint: "Todo dia o chefe conta o que cada bot fez, o que está parado e o que está esperando por você.",
name: "Resumo diário",
prompt:
"O dia está acabando. Use crew_activity (as últimas 24 horas) e escreva para mim um resumo curto do dia: o que cada bot terminou, o que ainda está aberto ou atrasado, o que deu errado e o que está esperando por mim (aprovações e perguntas), o mais importante primeiro. Palavras simples, no máximo 12 linhas. Se nada aconteceu, diga isso em uma linha. Não comece trabalho novo e não escreva para outros bots por causa disso.",
time: "Horário",
turnOn: "Ligar",
turnOff: "Desligar",
on: (when: string) => `Ligado · ${when}`,
off: "Desligado",
change: "Mudar",
failed: "Não foi possível mudar o resumo diário",
},
removeTitle: (name: string) => `Apagar ${name}?`,
removeBody: "Ela para de rodar. O que ela já fez continua no chat.",
failed: {
Expand Down
Loading
Loading