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: 2 additions & 1 deletion app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { ListAvatar } from "../bots/ListAvatar";
import { toolAction, toolTitle } from "../chat/toolNames";
import { CrewDialog } from "./CrewDialog";
import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed";
import { QuestionsEntry, row, SearchEntry } from "./SidebarPages";
import { QuestionsEntry, RoutinesEntry, row, SearchEntry } from "./SidebarPages";

/** Crews as sections and their bots as conversations (spec 15.1). */
export function Sidebar() {
Expand Down Expand Up @@ -65,6 +65,7 @@ export function Sidebar() {
</div>
<SearchEntry />
<QuestionsEntry />
<RoutinesEntry />
<ul className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{crews.map((crew) => (
<CrewEntry key={crew.id} crew={crew} />
Expand Down
30 changes: 27 additions & 3 deletions app/src/features/crews/SidebarPages.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The pages at the top of the sidebar: the search (spec 8.8) and the
// question box (spec 23.6).
// The pages at the top of the sidebar: the search (spec 8.8), the
// question box (spec 23.6) and every routine (spec 20.9).

import { MessageCircleQuestion, Search } from "lucide-react";
import { AlarmClock, MessageCircleQuestion, Search } from "lucide-react";
import { useT } from "../../i18n";
import { useApp } from "../../store/context";
import { openQuestions } from "../../store/questions";
Expand Down Expand Up @@ -60,3 +60,27 @@ export function QuestionsEntry() {
</div>
);
}

/** Every routine of every bot (spec 20.9), shown once there is one. */
export function RoutinesEntry() {
const t = useT();
const any = useApp((state) => Object.keys(state.routines).length > 0);
const open = useApp((state) => state.page === "routines");
const openPage = useApp((state) => state.openPage);
if (!any && !open) {
return null;
}
return (
<div className="shrink-0 px-2">
<button
type="button"
aria-current={open ? "page" : undefined}
onClick={() => openPage("routines")}
className={`${row} gap-2.5 px-2.5 py-1.5 text-sm ${open ? "bg-sunken text-ink" : "text-ink-soft"}`}
>
<AlarmClock aria-hidden size={16} className="text-muted" />
<span className="min-w-0 flex-1 truncate font-medium">{t.routines.page.title}</span>
</button>
</div>
);
}
205 changes: 205 additions & 0 deletions app/src/features/routines/RoutinesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Every routine in one place (spec 20.9): what runs next across all crews,
// in the order it will run, and below, each bot's routines under its
// mascot, folded by the owner if they like.

import { AlarmClock, ChevronDown, LoaderCircle, Plus, Radar } from "lucide-react";
import { useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import { AVATAR_PALETTE, type Bot, type Routine } from "../../lib/protocol.gen";
import { crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { EmptyState } from "../../ui/EmptyState";
import { BotAvatar } from "../bots/BotAvatar";
import { describeSchedule, nextText } from "./describe";
import { RoutineDialog } from "./RoutineDialog";
import { RoutineRow } from "./RoutineRow";

/** How many upcoming routines show before "Show more". */
const FIRST = 5;

interface Owned {
bot: Bot;
routine: Routine;
}

const capitalize = (text: string) => text.charAt(0).toLocaleUpperCase() + text.slice(1);

const byCreation = (a: { createdAt: number; id: string }, b: { createdAt: number; id: string }) =>
a.createdAt - b.createdAt || a.id.localeCompare(b.id);

/** The bots with routines, crew by crew, each with its routines. */
function useGroups(): { bot: Bot; crew: string; routines: Routine[] }[] {
// The store's own objects, so the selections stay equal between renders.
const crews = useApp(useShallow(crewList));
const bots = useApp(useShallow((state) => Object.values(state.bots)));
const routines = useApp(useShallow((state) => Object.values(state.routines)));
return useMemo(
() =>
crews.flatMap((crew) =>
bots
.filter((bot) => bot.crewId === crew.id)
.sort(byCreation)
.flatMap((bot) => {
const own = routines.filter((routine) => routine.botId === bot.id).sort(byCreation);
return own.length ? [{ bot, crew: crew.name, routines: own }] : [];
}),
),
[crews, bots, routines],
);
}

/** Routines that run on time, soonest first; signals wait, so they are left out. */
function upcoming(groups: { bot: Bot; routines: Routine[] }[]): Owned[] {
return groups
.flatMap(({ bot, routines }) => routines.map((routine) => ({ bot, routine })))
.filter(
({ routine }) =>
routine.enabled && routine.nextRunAt !== null && routine.schedule.kind !== "signal",
)
.sort((a, b) => (a.routine.nextRunAt ?? 0) - (b.routine.nextRunAt ?? 0));
}

function Upcoming({ bot, routine }: Owned) {
const r = useT().routines;
const [editing, setEditing] = useState(false);
const schedule = describeSchedule(routine.schedule, r.when);
// A routine every few minutes is keeping watch more than it is due.
const watching = routine.schedule.kind === "interval";
const running = routine.lastRun?.status === "queued";
const when = watching
? r.page.watching
: capitalize(nextText(routine.nextRunAt ?? 0, routine.timezone, r));
return (
<li>
<button
type="button"
onClick={() => setEditing(true)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-sunken"
>
<BotAvatar color={bot.color} size={28} mood={running ? "working" : undefined} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{routine.name}</span>
<span className="flex items-center gap-1.5 truncate text-muted text-sm">
{running ? (
<>
<LoaderCircle aria-hidden size={12} className="shrink-0 animate-spin text-work" />
<span className="text-work">{r.last.queued}</span>
</>
) : (
<>
{watching && <Radar aria-hidden size={12} className="shrink-0 text-ok" />}
<span className={watching ? "text-ok" : "text-ink-soft"}>{when}</span>
</>
)}
<span aria-hidden>·</span>
<span className="truncate">{schedule}</span>
</span>
</span>
<span className="shrink-0 text-muted text-xs">{bot.name}</span>
</button>
{editing && <RoutineDialog bot={bot} routine={routine} onClose={() => setEditing(false)} />}
</li>
);
}

function BotGroup({ bot, crew, routines }: { bot: Bot; crew: string; routines: Routine[] }) {
const r = useT().routines;
const [open, setOpen] = useState(true);
const [creating, setCreating] = useState(false);
return (
<section aria-label={bot.name} className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-sunken"
>
<BotAvatar color={bot.color} size={22} />
<span className="truncate font-semibold">{bot.name}</span>
<span className="truncate text-muted text-xs">{crew}</span>
<ChevronDown
aria-hidden
size={14}
className={`shrink-0 text-muted transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
<Button
variant="ghost"
size="sm"
icon={Plus}
label={r.dialog.newTitle(bot.name)}
onClick={() => setCreating(true)}
/>
</div>
{open && (
<ul aria-label={r.page.of(bot.name)} className="flex flex-col gap-2">
{routines.map((routine) => (
<RoutineRow key={routine.id} routine={routine} bot={bot} showBot={false} />
))}
</ul>
)}
{creating && <RoutineDialog bot={bot} onClose={() => setCreating(false)} />}
</section>
);
}

export function RoutinesPage() {
const r = useT().routines;
const groups = useGroups();
const next = upcoming(groups);
const [all, setAll] = useState(false);
const shown = all ? next : next.slice(0, FIRST);
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-8 px-5 py-6">
<header>
<h1 className="font-semibold text-xl">{r.page.title}</h1>
<p className="text-muted text-sm">{r.page.lead}</p>
</header>
{groups.length === 0 ? (
<EmptyState
color={AVATAR_PALETTE[0]}
icon={AlarmClock}
title={r.page.emptyTitle}
body={r.page.emptyBody}
/>
) : (
<>
{next.length > 0 && (
<section aria-labelledby="routines-upcoming" className="flex flex-col gap-1">
<h2
id="routines-upcoming"
className="px-3 font-medium text-muted text-xs uppercase tracking-wide"
>
{r.page.upcoming}
</h2>
<ul aria-labelledby="routines-upcoming" className="flex flex-col">
{shown.map((item) => (
<Upcoming key={item.routine.id} {...item} />
))}
</ul>
{next.length > FIRST && (
<button
type="button"
onClick={() => setAll(!all)}
className="self-start rounded-lg px-3 py-1.5 text-muted text-sm transition-colors hover:bg-sunken hover:text-ink"
>
{all ? r.page.showLess : r.page.showMore(next.length - FIRST)}
</button>
)}
</section>
)}
<div className="flex flex-col gap-6">
{groups.map((group) => (
<BotGroup key={group.bot.id} {...group} />
))}
</div>
</>
)}
</div>
</div>
);
}
85 changes: 85 additions & 0 deletions app/src/features/routines/routinesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import type { BotId, Schedule } from "../../lib/protocol.gen";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

function add(fake: FakeBotloft, botId: BotId, name: string, schedule: Schedule) {
return fake.routines.handlers()["routines.create"]({
botId,
name,
prompt: "Do it",
schedule,
timezone: "UTC",
});
}

const weekdays: Schedule = { kind: "weekly", days: [1, 2, 3, 4, 5], time: "09:00" };

/** Two crews, three bots, routines on two of them. */
function setup() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const site = fake.addCrew("Site");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.addBot(ops.id, "Writer", "Writes");
const watcher = fake.addBot(site.id, "Watcher", "Watches");
add(fake, scout.id, "Morning summary", weekdays);
const alerts = add(fake, watcher.id, "Camper van alerts", { kind: "interval", minutes: 30 });
// Every 30 minutes: due before the morning summary.
alerts.nextRunAt = fake.now + 30 * 60_000;
add(fake, watcher.id, "On deploy", { kind: "signal", name: "deployed" });
return fake;
}

async function openPage(fake: FakeBotloft) {
renderApp(fake);
await crewOpened("Ops");
fireEvent.click(screen.getByRole("button", { name: "Routines" }));
return screen.findByRole("heading", { level: 1, name: "Routines" });
}

describe("the routines page", () => {
test("is in the sidebar only once there is a routine", async () => {
const fake = new FakeBotloft();
fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources");
renderApp(fake);
await crewOpened("Ops");
expect(screen.queryByRole("button", { name: "Routines" })).toBeNull();
});

test("lists what comes up across crews, interval routines as keeping watch", async () => {
await openPage(setup());
const coming = screen.getByRole("list", { name: "Coming up" });
const rows = within(coming).getAllByRole("listitem");
// A signal waits for one: it never comes up on its own.
expect(rows.map((row) => row.querySelector(".font-medium")?.textContent)).toEqual([
"Camper van alerts",
"Morning summary",
]);
expect(rows[0]?.textContent).toContain("Keeping watch");
expect(rows[0]?.textContent).toContain("Every 30 minutes");
expect(rows[1]?.textContent).toContain("Weekdays at 09:00");
expect(rows[1]?.textContent).toContain("Scout");

// A click opens the routine to edit.
fireEvent.click(within(rows[1] as HTMLElement).getByRole("button"));
expect(screen.getByRole("dialog", { name: "Edit Morning summary" })).toBeDefined();
});

test("groups each bot's routines under its mascot, folded on demand", async () => {
await openPage(setup());
const watcher = screen.getByRole("region", { name: "Watcher" });
const list = within(watcher).getByRole("list", { name: "Watcher's routines" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
// Writer has none, so no group.
expect(screen.queryByRole("region", { name: "Writer" })).toBeNull();

fireEvent.click(within(watcher).getByRole("button", { name: /Watcher/, expanded: true }));
expect(within(watcher).queryByRole("list")).toBeNull();
fireEvent.click(within(watcher).getByRole("button", { name: "New routine for Watcher" }));
expect(screen.getByRole("dialog", { name: "New routine for Watcher" })).toBeDefined();
});
});
13 changes: 13 additions & 0 deletions app/src/i18n/en/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ export const routines = {
? "Last time was skipped: the computer was off"
: `${count} times were skipped: the computer was off`,
},
/** Every routine of every bot, a page of the sidebar. */
page: {
title: "Routines",
lead: "What your bots do on their own, in every crew.",
upcoming: "Coming up",
watching: "Keeping watch",
showMore: (count: number) => `Show ${count} more`,
showLess: "Show fewer",
of: (bot: string) => `${bot}'s routines`,
emptyTitle: "No routines yet",
emptyBody:
"A routine makes a bot work on its own at set times. Ask a bot in the chat, or open a bot's Routines tab.",
},
when: {
everyDay: (time: string) => `Every day at ${time}`,
weekdays: (time: string) => `Weekdays at ${time}`,
Expand Down
12 changes: 12 additions & 0 deletions app/src/i18n/es/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export const routines: Messages["routines"] = {
? "La última vez se saltó: el ordenador estaba apagado"
: `Se saltaron ${count} veces: el ordenador estaba apagado`,
},
page: {
title: "Rutinas",
lead: "Lo que tus bots hacen solos, en todos los equipos.",
upcoming: "Próximas",
watching: "Vigilando",
showMore: (count) => `Mostrar ${count} más`,
showLess: "Mostrar menos",
of: (bot) => `Rutinas de ${bot}`,
emptyTitle: "Aún no hay rutinas",
emptyBody:
"Una rutina hace que un bot trabaje solo a horas fijas. Pídeselo a un bot en el chat, o abre la pestaña Rutinas de un bot.",
},
when: {
everyDay: (time: string) => `Todos los días a las ${time}`,
weekdays: (time: string) => `Días laborables a las ${time}`,
Expand Down
Loading
Loading