From f8e8a1244da666379afc9ae514884ca204788511 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:03:50 -0300 Subject: [PATCH] feat(routines): a page with every routine, coming up first The sidebar gets a Routines page once a bot has one: what runs next across all crews, soonest first, with interval routines keeping watch, and below, each bot's routines under its mascot, foldable, with a button for a new one. --- app/src/features/crews/Sidebar.tsx | 3 +- app/src/features/crews/SidebarPages.tsx | 30 ++- app/src/features/routines/RoutinesPage.tsx | 205 ++++++++++++++++++ .../features/routines/routinesPage.test.tsx | 85 ++++++++ app/src/i18n/en/routines.ts | 13 ++ app/src/i18n/es/routines.ts | 12 + app/src/i18n/pt-BR/routines.ts | 12 + app/src/shell/Workspace.tsx | 9 +- app/src/store/app.ts | 4 +- docs/spec.md | 1 + 10 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 app/src/features/routines/RoutinesPage.tsx create mode 100644 app/src/features/routines/routinesPage.test.tsx diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 3e7b3f4c..19fe8307 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -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() { @@ -65,6 +65,7 @@ export function Sidebar() { +
    {crews.map((crew) => ( diff --git a/app/src/features/crews/SidebarPages.tsx b/app/src/features/crews/SidebarPages.tsx index b7299793..34e838c0 100644 --- a/app/src/features/crews/SidebarPages.tsx +++ b/app/src/features/crews/SidebarPages.tsx @@ -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"; @@ -60,3 +60,27 @@ export function QuestionsEntry() { ); } + +/** 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 ( +
    + +
    + ); +} diff --git a/app/src/features/routines/RoutinesPage.tsx b/app/src/features/routines/RoutinesPage.tsx new file mode 100644 index 00000000..a525a861 --- /dev/null +++ b/app/src/features/routines/RoutinesPage.tsx @@ -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 ( +
  • + + {editing && setEditing(false)} />} +
  • + ); +} + +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 ( +
    +
    + +
    + {open && ( +
      + {routines.map((routine) => ( + + ))} +
    + )} + {creating && setCreating(false)} />} +
    + ); +} + +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 ( +
    +
    +
    +

    {r.page.title}

    +

    {r.page.lead}

    +
    + {groups.length === 0 ? ( + + ) : ( + <> + {next.length > 0 && ( +
    +

    + {r.page.upcoming} +

    +
      + {shown.map((item) => ( + + ))} +
    + {next.length > FIRST && ( + + )} +
    + )} +
    + {groups.map((group) => ( + + ))} +
    + + )} +
    +
    + ); +} diff --git a/app/src/features/routines/routinesPage.test.tsx b/app/src/features/routines/routinesPage.test.tsx new file mode 100644 index 00000000..b2f3c3e2 --- /dev/null +++ b/app/src/features/routines/routinesPage.test.tsx @@ -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(); + }); +}); diff --git a/app/src/i18n/en/routines.ts b/app/src/i18n/en/routines.ts index ed796c15..c06d4b98 100644 --- a/app/src/i18n/en/routines.ts +++ b/app/src/i18n/en/routines.ts @@ -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}`, diff --git a/app/src/i18n/es/routines.ts b/app/src/i18n/es/routines.ts index 4f1c06c5..ff813f76 100644 --- a/app/src/i18n/es/routines.ts +++ b/app/src/i18n/es/routines.ts @@ -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}`, diff --git a/app/src/i18n/pt-BR/routines.ts b/app/src/i18n/pt-BR/routines.ts index f1779bfc..21a3aff4 100644 --- a/app/src/i18n/pt-BR/routines.ts +++ b/app/src/i18n/pt-BR/routines.ts @@ -34,6 +34,18 @@ export const routines: Messages["routines"] = { ? "A última vez foi pulada: o computador estava desligado" : `${count} vezes foram puladas: o computador estava desligado`, }, + page: { + title: "Rotinas", + lead: "O que seus bots fazem sozinhos, em todas as equipes.", + upcoming: "Próximas", + watching: "Vigiando", + showMore: (count) => `Mostrar mais ${count}`, + showLess: "Mostrar menos", + of: (bot) => `Rotinas de ${bot}`, + emptyTitle: "Nenhuma rotina ainda", + emptyBody: + "Uma rotina faz um bot trabalhar sozinho em horários marcados. Peça a um bot no chat, ou abra a aba Rotinas de um bot.", + }, when: { everyDay: (time: string) => `Todo dia às ${time}`, weekdays: (time: string) => `Dias úteis às ${time}`, diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index c384935b..d5ee006e 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -13,6 +13,7 @@ import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; import { SignInButton } from "../features/onboarding/SignIn"; import { Welcome } from "../features/onboarding/Welcome"; import { QuestionBox } from "../features/questions/QuestionBox"; +import { RoutinesPage } from "../features/routines/RoutinesPage"; import { SearchPage } from "../features/search/SearchPage"; import { useSearchKey } from "../features/search/searchKey"; import { UpdateButton } from "../features/updates/UpdateButton"; @@ -120,6 +121,8 @@ function Selection() { view = ; } else if (page === "search") { view = ; + } else if (page === "routines") { + view = ; } else if (crew && bot) { view = ; } else if (crew) { @@ -148,7 +151,11 @@ function Breadcrumb() { <> - {page === "questions" ? t.questions.box.label : t.search.label} + {page === "questions" + ? t.questions.box.label + : page === "routines" + ? t.routines.page.title + : t.search.label} ); diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 569440ec..19d600ef 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -103,8 +103,8 @@ export interface AppState { export type AppStore = StoreApi; -/** The question box (spec 23.6) or the search (spec 8.8). */ -export type Page = "questions" | "search" | null; +/** The question box (spec 23.6), the search (spec 8.8) or every routine (spec 20.9). */ +export type Page = "questions" | "search" | "routines" | null; const byCreation = (a: T, b: T) => a.createdAt - b.createdAt || a.id.localeCompare(b.id); diff --git a/docs/spec.md b/docs/spec.md index f0cf1fde..5db4927c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1200,6 +1200,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho - "Avançado": expressão cron. - No chat, a message da rotina aparece só com a etiqueta "Rotina · " e uma seta; o texto que a rotina manda fica dobrado até o dono abrir. - Na página da crew, uma aba com as rotinas de todos os bots dela. +- Na barra lateral, embaixo de Buscar e Perguntas, a página **Rotinas**, com as rotinas de todas as crews; a entrada aparece quando existe ao menos uma rotina. Em cima, "Próximas": as ligadas que rodam por horário, da mais próxima para a mais distante, cinco de início e "Mostrar mais" para o resto. Cada uma mostra o mascote do bot, o nome, quando roda de novo ("Amanhã às 09:00") e a frase de quando; uma rotina por intervalo diz "Vigiando" no lugar da hora, porque está sempre de olho; uma que está rodando diz "Rodando agora". Uma rotina por sinal não entra, porque espera o sinal. Um clique abre a rotina para editar. Embaixo, um grupo por bot que tem rotina, na ordem das crews e dos bots: o mascote, o nome do bot e o da crew, uma seta para dobrar o grupo e **+** para uma rotina nova desse bot, com a lista de 20.9 (interruptor, rodar agora, editar e apagar). - Uma execução `failed` entra na marca da barra de tarefas (15.2) até o dono abrir o bot. O app guarda quando o dono abriu cada bot (`localStorage`, `botloft.seen`); um bot aberto não marca. - O store carrega `routines.list` a cada conexão e segue `routine.changed` e `routine.run`. - Textos nos três idiomas (15.6); a frase de "quando" é montada pelo app a partir do `schedule`.