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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
65 changes: 65 additions & 0 deletions app/src/dev/seedRoutines.ts
Original file line number Diff line number Diff line change
@@ -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<Routine> & Pick<Routine, "name" | "schedule">) =>
fake.routines.handlers()["routines.create"]({
botId,
name: fields.name,
prompt: fields.prompt ?? "",
schedule: fields.schedule,
timezone: zone,
});
const lastRun = (routine: Routine, run: Partial<RoutineRun>) => {
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;
}
30 changes: 28 additions & 2 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
@@ -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<Pane>("chat");
const routines = useApp(useShallow((state) => routinesOf(state, bot.id)));
const view = stateView(bot, crew.paused, t);
const tabs: Tab<Pane>[] = [
{ id: "chat", label: t.routines.chatTab },
{
id: "routines",
label: routines.length > 0 ? `${t.routines.tab} (${routines.length})` : t.routines.tab,
},
];

return (
<section aria-label={bot.name} className="flex min-h-0 flex-1 flex-col">
Expand All @@ -24,8 +43,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
onToggleDetails={() => setDetails(!details)}
/>
<Notices bot={bot} crew={crew} view={view} />
<Tabs<Pane> label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
<div className="flex min-h-0 flex-1">
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
{pane === "chat" ? (
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
) : (
<BotRoutines bot={bot} />
)}
</div>
{details && <Details bot={bot} onClose={() => setDetails(false)} />}
</div>
</section>
Expand Down
13 changes: 12 additions & 1 deletion app/src/features/chat/InboundRow.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<span className="inline-flex items-center gap-1 rounded-md border border-accent/40 px-1.5 text-accent text-xs">
<AlarmClock aria-hidden size={11} />
{t.routines.tag(routine?.name ?? t.routines.tab)}
</span>
);
}
if (message.kind !== "task" && message.kind !== "result") {
return null;
}
Expand Down
1 change: 1 addition & 0 deletions app/src/features/chat/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const inbound = (body: string, at?: number) =>
kind: "note",
body,
taskId: null,
routineId: null,
attachments: [],
createdAt: at ?? start,
},
Expand Down
14 changes: 13 additions & 1 deletion app/src/features/crews/CrewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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));
Expand Down Expand Up @@ -144,6 +146,16 @@ export function CrewView({ crew }: { crew: Crew }) {
</div>
)}

{pane === "routines" && (
<div
role="tabpanel"
aria-labelledby={tabId("routines")}
className="flex min-h-0 flex-1 flex-col"
>
<CrewRoutines crew={crew} />
</div>
)}

{open === "bot" && <BotDialog crewId={crew.id} onClose={close} />}
{open === "rename" && <CrewDialog crew={crew} onClose={close} />}
{typeof open === "object" && open !== null && (
Expand Down
79 changes: 79 additions & 0 deletions app/src/features/routines/MoreOptions.tsx
Original file line number Diff line number Diff line change
@@ -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<RoutineForm>): void;
}) {
const d = useT().routines.dialog;
const zones = allZones();
return (
<details className="group rounded-xl border border-line">
<summary className="flex cursor-default items-center gap-1.5 px-3 py-2 font-medium text-ink-soft text-sm hover:text-ink">
<ChevronRight aria-hidden size={14} className="transition-transform group-open:rotate-90" />
{d.more}
</summary>
<div className="flex flex-col gap-4 border-line border-t px-3 py-3">
<SelectField
label={d.timezone}
value={form.timezone}
onChange={(event) => change({ timezone: event.target.value })}
options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))}
/>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-ink-soft text-sm">{d.overlap}</p>
<Choices
label={d.overlap}
value={form.overlap}
options={[
{ value: "skip", label: d.overlapSkip },
{ value: "queue", label: d.overlapQueue },
]}
onChange={(overlap) => change({ overlap })}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-ink-soft text-sm">{d.missed}</p>
<Choices
label={d.missed}
value={form.missed}
options={[
{ value: "run_once", label: d.missedRunOnce },
{ value: "skip", label: d.missedSkip },
]}
onChange={(missed) => change({ missed })}
/>
<p className="text-muted text-xs">{d.wakeNote}</p>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.useCron}
onChange={(event) => change({ useCron: event.target.checked })}
/>
{d.advanced}
</label>
{form.useCron && (
<TextField
label={d.cron}
value={form.cron}
onChange={(event) => change({ cron: event.target.value })}
placeholder="0 9 * * 1-5"
hint={d.cronHint}
/>
)}
</div>
</details>
);
}
Loading
Loading