diff --git a/Cargo.lock b/Cargo.lock index c274ac2a..bd1bae76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,7 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hex", + "jiff", "serde", "serde_json", "sha2 0.11.0", diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 9840cf36..ae9308dd 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -12,6 +12,7 @@ import type { Host } from "../lib/host"; import { PROTOCOL_VERSION } from "../lib/protocol.gen"; import { seedChats } from "./seedChats"; import { seedChief } from "./seedChief"; +import { seedRoutines } from "./seedRoutines"; export function previewProps(search: string): { host: Host; connect: Connect } { const params = new URLSearchParams(search); @@ -109,6 +110,7 @@ function seed(fake: FakeBotloft): void { analyst: analyst.id, planner: planner.id, }); + seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); const ops = fake.addCrew("Ops"); const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays"); const watcher = fake.addBot(ops.id, "Watcher", "Keeps an eye on the error log"); diff --git a/app/src/dev/seedRoutines.ts b/app/src/dev/seedRoutines.ts new file mode 100644 index 00000000..617e4447 --- /dev/null +++ b/app/src/dev/seedRoutines.ts @@ -0,0 +1,65 @@ +// Dev only: routines for the preview, one of each kind of schedule and +// last run. + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId, Routine, RoutineRun } from "../lib/protocol.gen"; + +const HOUR = 60 * 60_000; + +export function seedRoutines(fake: FakeBotloft, bots: { scout: BotId; analyst: BotId }): void { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const create = (botId: BotId, fields: Partial & Pick) => + fake.routines.handlers()["routines.create"]({ + botId, + name: fields.name, + prompt: fields.prompt ?? "", + schedule: fields.schedule, + timezone: zone, + }); + const lastRun = (routine: Routine, run: Partial) => { + routine.lastRun = { + id: fake.id("rrn"), + routineId: routine.id, + scheduledFor: fake.now - 2 * HOUR, + status: "done", + reason: null, + skippedCount: 0, + messageId: null, + createdAt: fake.now - 2 * HOUR, + finishedAt: fake.now - HOUR, + ...run, + }; + }; + + const morning = create(bots.scout, { + name: "Morning summary", + prompt: "Summarize what arrived in shared/inbox and tell me what needs me.", + schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "09:00" }, + }); + lastRun(morning, {}); + morning.nextRunAt = fake.now + 14 * HOUR; + + const links = create(bots.scout, { + name: "Check the links", + prompt: "Run the link checker on the report and fix what moved.", + schedule: { kind: "interval", minutes: 120 }, + }); + lastRun(links, { status: "skipped", reason: "overlap", finishedAt: fake.now - HOUR }); + links.nextRunAt = fake.now + HOUR; + + const numbers = create(bots.analyst, { + name: "Weekly numbers", + prompt: "Recompute the growth numbers for the report.", + schedule: { kind: "weekly", days: [1, 4], time: "14:30" }, + }); + lastRun(numbers, { status: "failed" }); + numbers.nextRunAt = fake.now + 3 * 24 * HOUR; + + const backup = create(bots.analyst, { + name: "Nightly archive", + prompt: "Zip shared/ into archive/.", + schedule: { kind: "cron", expr: "0 2 * * *" }, + }); + backup.enabled = false; + backup.nextRunAt = null; +} diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 52b97ae7..e133f055 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -1,19 +1,38 @@ import { X } from "lucide-react"; import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; +import { routinesOf } from "../../store/app"; +import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; +import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { ChatView } from "../chat/ChatView"; import { SignInButton } from "../onboarding/SignIn"; +import { BotRoutines } from "../routines/RoutineList"; import { BotHeader } from "./BotHeader"; import { stateView } from "./BotStateBadge"; -/** A bot's conversation, with its details in a side panel (spec 15.1). */ +type Pane = "chat" | "routines"; + +/** + * A bot's conversation and its routines, with its details in a side panel + * (spec 15.1, 20.9). + */ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const [details, setDetails] = useState(false); + const [pane, setPane] = useState("chat"); + const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); const view = stateView(bot, crew.paused, t); + const tabs: Tab[] = [ + { id: "chat", label: t.routines.chatTab }, + { + id: "routines", + label: routines.length > 0 ? `${t.routines.tab} (${routines.length})` : t.routines.tab, + }, + ]; return (
@@ -24,8 +43,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { onToggleDetails={() => setDetails(!details)} /> + label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
- +
+ {pane === "chat" ? ( + + ) : ( + + )} +
{details &&
setDetails(false)} />}
diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 84e41f93..a22ac124 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -1,7 +1,7 @@ // A message to the bot, in its chat: the owner's on the right in a bubble, // another bot's or Botloft's on the left under its name (spec 15.3). -import { ListTodo, Reply } from "lucide-react"; +import { AlarmClock, ListTodo, Reply } from "lucide-react"; import { useT } from "../../i18n"; import { when } from "../../lib/format"; import type { Bot, Message } from "../../lib/protocol.gen"; @@ -43,6 +43,17 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { function TaskTag({ message }: { message: Message }) { const t = useT(); + const routine = useApp((state) => + message.routineId ? state.routines[message.routineId] : undefined, + ); + if (message.kind === "routine") { + return ( + + + {t.routines.tag(routine?.name ?? t.routines.tab)} + + ); + } if (message.kind !== "task" && message.kind !== "result") { return null; } diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index e8da4af7..fd54ad7f 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -36,6 +36,7 @@ const inbound = (body: string, at?: number) => kind: "note", body, taskId: null, + routineId: null, attachments: [], createdAt: at ?? start, }, diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index ce4fb906..bc53019c 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -23,12 +23,13 @@ import { attempt } from "../../ui/toast"; import { BotDialog } from "../bots/BotDialog"; import { Composer } from "../messages/Composer"; import { Timeline } from "../messages/Timeline"; +import { CrewRoutines } from "../routines/RoutineList"; import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; import { CrewDialog } from "./CrewDialog"; type Open = "bot" | "rename" | "archive" | { move: string } | null; -type Pane = "bots" | "timeline" | "tasks"; +type Pane = "bots" | "timeline" | "tasks" | "routines"; export function CrewView({ crew }: { crew: Crew }) { const t = useT(); @@ -44,6 +45,7 @@ export function CrewView({ crew }: { crew: Crew }) { { id: "bots", label: words.tabs.bots }, { id: "timeline", label: words.tabs.timeline }, { id: "tasks", label: words.tabs.tasks }, + { id: "routines", label: t.routines.tab }, ]; const openFolder = () => attempt(words.failed.openFolder, () => host.openPath(crew.workFolder)); @@ -144,6 +146,16 @@ export function CrewView({ crew }: { crew: Crew }) { )} + {pane === "routines" && ( +
+ +
+ )} + {open === "bot" && } {open === "rename" && } {typeof open === "object" && open !== null && ( diff --git a/app/src/features/routines/MoreOptions.tsx b/app/src/features/routines/MoreOptions.tsx new file mode 100644 index 00000000..994fe16d --- /dev/null +++ b/app/src/features/routines/MoreOptions.tsx @@ -0,0 +1,79 @@ +// "More options" in the routine form (spec 20.9): the time zone, what to +// do when the last run has not finished or the computer was off, and a +// cron expression for those who want one. + +import { ChevronRight } from "lucide-react"; +import { useT } from "../../i18n"; +import { Choices } from "../../ui/Choices"; +import { SelectField, TextField } from "../../ui/Field"; +import { allZones, zoneLabel } from "./describe"; +import type { RoutineForm } from "./form"; + +export function MoreOptions({ + form, + change, +}: { + form: RoutineForm; + change(patch: Partial): void; +}) { + const d = useT().routines.dialog; + const zones = allZones(); + return ( +
+ + + {d.more} + +
+ change({ timezone: event.target.value })} + options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))} + /> +
+

{d.overlap}

+ change({ overlap })} + /> +
+
+

{d.missed}

+ change({ missed })} + /> +

{d.wakeNote}

+
+ + {form.useCron && ( + change({ cron: event.target.value })} + placeholder="0 9 * * 1-5" + hint={d.cronHint} + /> + )} +
+
+ ); +} diff --git a/app/src/features/routines/RoutineDialog.tsx b/app/src/features/routines/RoutineDialog.tsx new file mode 100644 index 00000000..e016ea01 --- /dev/null +++ b/app/src/features/routines/RoutineDialog.tsx @@ -0,0 +1,102 @@ +// Creating a routine for a bot, or editing one (spec 20.9): its name, +// what the bot should do, and when. A refusal the daemon explains with a +// reason is worded here, in the owner's language. + +import { type FormEvent, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { type Bot, FIELD_LIMITS, type Routine } from "../../lib/protocol.gen"; +import { RpcError } from "../../lib/rpc"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Dialog } from "../../ui/Dialog"; +import { TextArea, TextField } from "../../ui/Field"; +import { blankForm, formOf, type RoutineForm, scheduleOf } from "./form"; +import { MoreOptions } from "./MoreOptions"; +import { ScheduleFields } from "./ScheduleFields"; + +type Props = { bot: Bot; onClose(): void } & ({ routine?: undefined } | { routine: Routine }); + +export function RoutineDialog({ bot, routine, onClose }: Props) { + const t = useT(); + const d = t.routines.dialog; + const api = useApi(); + const putRoutine = useApp((state) => state.putRoutine); + const [form, setForm] = useState(() => (routine ? formOf(routine) : blankForm())); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const change = (patch: Partial) => setForm((current) => ({ ...current, ...patch })); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + const fields = { + name: form.name, + prompt: form.prompt, + schedule: scheduleOf(form), + timezone: form.timezone, + overlap: form.overlap, + missed: form.missed, + }; + try { + putRoutine( + routine + ? await api.call("routines.update", { routineId: routine.id, ...fields }) + : await api.call("routines.create", { botId: bot.id, ...fields }), + ); + onClose(); + } 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 ?? errorText(failure)); + setBusy(false); + } + }; + + const formId = "routine-form"; + return ( + + + + + } + > +
+ change({ name: event.target.value })} + placeholder={d.namePlaceholder} + autoFocus + required + /> +