Skip to content

Commit 0b4d7cf

Browse files
committed
feat(routines): a daily summary from the crew's chief (spec 29.2)
The chief gets a new tool, crew_activity: for the last 1 to 168 hours (24 by default) it reports, per bot, the tasks done, failed, expired, open and late, a short sample of the latest ones with what was asked and the result, what waits for the owner (approvals and questions) and the latest run of each routine. Only the chief may call it; the chats with the owner are not included. The crew's routines tab gets a "Daily summary" card: pick a time and turn it on to give the chief a routine that runs every day, with a request in the owner's language that tells it to use crew_activity and write a short summary. It is an ordinary routine afterwards: it shows in the list, can be changed, turned off or run now.
1 parent abd3352 commit 0b4d7cf

13 files changed

Lines changed: 702 additions & 3 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// The daily summary (spec 29.2): a routine for the crew's chief, which asks it
2+
// each evening to tell the owner what the bots did. It is a routine like any
3+
// other, shown below in the list; this card is the easy way to make it and to
4+
// turn it on or off. The app knows it by the tool its request names.
5+
6+
import { Newspaper } from "lucide-react";
7+
import { useState } from "react";
8+
import { useShallow } from "zustand/react/shallow";
9+
import { useT } from "../../i18n";
10+
import type { Crew } from "../../lib/protocol.gen";
11+
import { routinesOf } from "../../store/app";
12+
import { useApi, useApp } from "../../store/context";
13+
import { Button } from "../../ui/Button";
14+
import { attempt } from "../../ui/toast";
15+
import { describeSchedule, systemZone } from "./describe";
16+
import { RoutineDialog } from "./RoutineDialog";
17+
18+
const TOOL = "crew_activity";
19+
const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7];
20+
21+
export function DailySummary({ crew }: { crew: Crew }) {
22+
const t = useT();
23+
const s = t.routines.summary;
24+
const api = useApi();
25+
const putRoutine = useApp((state) => state.putRoutine);
26+
const chief = useApp((state) => (crew.leadBotId ? state.bots[crew.leadBotId] : undefined));
27+
const mine = useApp(useShallow((state) => (chief ? routinesOf(state, chief.id) : [])));
28+
const existing = mine.find((routine) => routine.prompt.includes(TOOL));
29+
const [time, setTime] = useState("18:00");
30+
const [busy, setBusy] = useState(false);
31+
const [editing, setEditing] = useState(false);
32+
if (!chief) {
33+
return null;
34+
}
35+
36+
const run = async (action: () => Promise<unknown>) => {
37+
setBusy(true);
38+
await attempt(s.failed, action);
39+
setBusy(false);
40+
};
41+
const create = () =>
42+
run(async () =>
43+
putRoutine(
44+
await api.call("routines.create", {
45+
botId: chief.id,
46+
name: s.name,
47+
prompt: s.prompt,
48+
schedule: { kind: "weekly", days: EVERY_DAY, time },
49+
timezone: systemZone(),
50+
overlap: "skip",
51+
missed: "run_once",
52+
}),
53+
),
54+
);
55+
const toggle = (enabled: boolean) =>
56+
run(async () => {
57+
if (existing) {
58+
putRoutine(await api.call("routines.setEnabled", { routineId: existing.id, enabled }));
59+
}
60+
});
61+
62+
return (
63+
<section
64+
aria-label={s.title}
65+
className="mb-3 flex max-w-3xl flex-col gap-2 rounded-xl border border-line bg-panel p-4"
66+
>
67+
<div className="flex items-start gap-3">
68+
<Newspaper aria-hidden size={20} className="mt-0.5 shrink-0 text-muted" />
69+
<div className="min-w-0 flex-1">
70+
<h3 className="font-semibold">{s.title}</h3>
71+
<p className="text-ink-soft text-sm">{s.hint}</p>
72+
</div>
73+
</div>
74+
{existing ? (
75+
<div className="flex flex-wrap items-center gap-2">
76+
<span className={`text-sm ${existing.enabled ? "text-ok" : "text-muted"}`}>
77+
{existing.enabled ? s.on(describeSchedule(existing.schedule, t.routines.when)) : s.off}
78+
</span>
79+
<Button size="sm" disabled={busy} onClick={() => toggle(!existing.enabled)}>
80+
{existing.enabled ? s.turnOff : s.turnOn}
81+
</Button>
82+
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
83+
{s.change}
84+
</Button>
85+
</div>
86+
) : (
87+
<div className="flex flex-wrap items-center gap-2">
88+
<label className="flex items-center gap-2 text-sm">
89+
<span className="text-ink-soft">{s.time}</span>
90+
<input
91+
type="time"
92+
value={time}
93+
onChange={(event) => setTime(event.target.value)}
94+
className="h-8 rounded-lg border border-line-strong bg-canvas px-2 text-ink outline-none focus:border-accent"
95+
/>
96+
</label>
97+
<Button size="sm" variant="primary" disabled={busy || !time} onClick={create}>
98+
{s.turnOn}
99+
</Button>
100+
</div>
101+
)}
102+
{editing && existing && (
103+
<RoutineDialog bot={chief} routine={existing} onClose={() => setEditing(false)} />
104+
)}
105+
</section>
106+
);
107+
}

‎app/src/features/routines/RoutineList.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen";
99
import { botsOf, routinesOf } from "../../store/app";
1010
import { useApp } from "../../store/context";
1111
import { Button } from "../../ui/Button";
12+
import { DailySummary } from "./DailySummary";
1213
import { RoutineDialog } from "./RoutineDialog";
1314
import { RoutineRow } from "./RoutineRow";
1415

@@ -66,6 +67,7 @@ export function CrewRoutines({ crew }: { crew: Crew }) {
6667
});
6768
return (
6869
<div className="min-h-0 flex-1 overflow-y-auto p-5">
70+
<DailySummary crew={crew} />
6971
{byBot.length === 0 ? (
7072
<p className="rounded-2xl border border-line border-dashed px-6 py-10 text-center text-muted text-sm">
7173
{r.crewEmpty}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { en } from "../../i18n/en";
4+
import { FakeBotloft } from "../../lib/fake";
5+
import { crewOpened, openTab, renderApp } from "../../test/app";
6+
7+
afterEach(cleanup);
8+
9+
const calls = (fake: FakeBotloft, method: string) =>
10+
fake.calls.filter((call) => call.method === method);
11+
12+
/** A crew "Site" with its chief, open on the crew's routines. */
13+
async function openCrewRoutines(withChief = true) {
14+
const fake = new FakeBotloft();
15+
if (withChief) {
16+
await fake.call("crews.create", {
17+
name: "Site",
18+
lead: { name: "Chief", role: "Leads the crew", instructions: "Build it" },
19+
});
20+
} else {
21+
fake.addCrew("Site");
22+
}
23+
renderApp(fake);
24+
await crewOpened("Site");
25+
openTab(/^Routines/);
26+
return { fake };
27+
}
28+
29+
describe("the daily summary", () => {
30+
test("is offered on the crew's routines and turned on for the chief, every day", async () => {
31+
const { fake } = await openCrewRoutines();
32+
const card = await screen.findByRole("region", { name: "Daily summary" });
33+
fireEvent.change(within(card).getByLabelText("Time"), { target: { value: "19:30" } });
34+
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));
35+
36+
await waitFor(() => expect(calls(fake, "routines.create")).toHaveLength(1));
37+
const [create] = calls(fake, "routines.create");
38+
expect(create?.params).toMatchObject({
39+
name: "Daily summary",
40+
schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "19:30" },
41+
overlap: "skip",
42+
missed: "run_once",
43+
});
44+
// It asks the chief to use the tool that tells it what the bots did.
45+
const params = create?.params as { prompt: string; botId: string } | undefined;
46+
expect(params?.prompt).toContain("crew_activity");
47+
const chief = (await fake.call("bots.list", {})).find((bot) => bot.name === "Chief");
48+
expect(params?.botId).toBe(chief?.id);
49+
50+
expect(await within(card).findByText(/^On · /)).toBeDefined();
51+
expect(within(card).getByRole("button", { name: "Turn off" })).toBeDefined();
52+
});
53+
54+
test("can be turned off and on again, and shows in the list like any routine", async () => {
55+
const { fake } = await openCrewRoutines();
56+
const card = await screen.findByRole("region", { name: "Daily summary" });
57+
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));
58+
await within(card).findByText(/^On · /);
59+
expect(
60+
within(await screen.findByRole("list", { name: "Routines" })).getByText("Daily summary"),
61+
).toBeDefined();
62+
63+
fireEvent.click(within(card).getByRole("button", { name: "Turn off" }));
64+
expect(await within(card).findByText("Off")).toBeDefined();
65+
fireEvent.click(within(card).getByRole("button", { name: "Turn on" }));
66+
await within(card).findByText(/^On · /);
67+
expect(calls(fake, "routines.setEnabled").map((call) => call.params)).toEqual([
68+
expect.objectContaining({ enabled: false }),
69+
expect.objectContaining({ enabled: true }),
70+
]);
71+
// One routine, not a second one each time.
72+
expect(calls(fake, "routines.create")).toHaveLength(1);
73+
});
74+
75+
test("is not offered to a crew without a chief", async () => {
76+
await openCrewRoutines(false);
77+
await screen.findByText(en.routines.crewEmpty);
78+
expect(screen.queryByRole("region", { name: "Daily summary" })).toBeNull();
79+
});
80+
});

‎app/src/i18n/en/routines.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ export const routines = {
105105
signal_invalid: "A signal needs letters or digits, like report-ready.",
106106
},
107107
},
108+
/** The daily summary the chief writes (spec 29.2), a routine made for the owner. */
109+
summary: {
110+
title: "Daily summary",
111+
hint: "Every day the chief tells you what each bot did, what is stuck and what is waiting for you.",
112+
name: "Daily summary",
113+
// Sent to the chief every day. It names the tool, which is also how the app
114+
// recognizes this routine.
115+
prompt:
116+
"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.",
117+
time: "Time",
118+
turnOn: "Turn on",
119+
turnOff: "Turn off",
120+
on: (when: string) => `On · ${when}`,
121+
off: "Off",
122+
change: "Change",
123+
failed: "Could not change the daily summary",
124+
},
108125
removeTitle: (name: string) => `Delete ${name}?`,
109126
removeBody: "It stops running. What it already did stays in the chat.",
110127
failed: {

‎app/src/i18n/es/routines.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export const routines: Messages["routines"] = {
106106
signal_invalid: "El aviso necesita letras o números, como informe-listo.",
107107
},
108108
},
109+
summary: {
110+
title: "Resumen diario",
111+
hint: "Cada día el jefe te cuenta qué hizo cada bot, qué está parado y qué te está esperando.",
112+
name: "Resumen diario",
113+
prompt:
114+
"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.",
115+
time: "Hora",
116+
turnOn: "Activar",
117+
turnOff: "Desactivar",
118+
on: (when: string) => `Activado · ${when}`,
119+
off: "Desactivado",
120+
change: "Cambiar",
121+
failed: "No se pudo cambiar el resumen diario",
122+
},
109123
removeTitle: (name: string) => `¿Eliminar ${name}?`,
110124
removeBody: "Deja de ejecutarse. Lo que ya hizo sigue en el chat.",
111125
failed: {

‎app/src/i18n/pt-BR/routines.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export const routines: Messages["routines"] = {
106106
signal_invalid: "O aviso precisa de letras ou números, como relatorio-pronto.",
107107
},
108108
},
109+
summary: {
110+
title: "Resumo diário",
111+
hint: "Todo dia o chefe conta o que cada bot fez, o que está parado e o que está esperando por você.",
112+
name: "Resumo diário",
113+
prompt:
114+
"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.",
115+
time: "Horário",
116+
turnOn: "Ligar",
117+
turnOff: "Desligar",
118+
on: (when: string) => `Ligado · ${when}`,
119+
off: "Desligado",
120+
change: "Mudar",
121+
failed: "Não foi possível mudar o resumo diário",
122+
},
109123
removeTitle: (name: string) => `Apagar ${name}?`,
110124
removeBody: "Ela para de rodar. O que ela já fez continua no chat.",
111125
failed: {

0 commit comments

Comments
 (0)