Let a conversation's menu show the routines that run in it
The header menu had Tasks but nothing for the schedules whose results land in that conversation. It now has Schedules, right under Tasks, opened in a modal that lists only what runs there: a bot's own chat holds the routines that run nowhere, a workspace only that bot's routines in it (`routineScopeSql`, tested against SQLite). The list loads when the modal opens, through `api/admin/bots/list-routines`, which checks the user chats in that conversation first, so the page props stay small. `grabCronJobs` gained an optional `workspace_id`: left out it still means everywhere. The routines page's row, form and edit modal moved to `src/components/general/`, and its subtitle to `src/utils/`, since both the page and the modal use them.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Load only once the modal opens, so the conversation's page props stay
|
||||
* small.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's routines: the schedules that run there, a bot's own in
|
||||
* its chat or the ones that run in the workspace. Loaded on open and
|
||||
* refreshed after every change, never part of the page props.
|
||||
*/
|
||||
export default function useBotRoutines({ bot_id, workspace_id, enabled = true }: Params) {
|
||||
const [routines, setRoutines] = useState<CronJobWithNames[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const query = new URLSearchParams({ bot_id: String(bot_id) });
|
||||
|
||||
if (workspace_id) query.set("workspace_id", String(workspace_id));
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<CronJobWithNames>>(
|
||||
`/api/admin/bots/list-routines?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success) {
|
||||
setError(res?.msg || `Couldn't load the routines.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines(res.payload || []);
|
||||
}, [bot_id, workspace_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load().catch(() => {});
|
||||
}, [enabled, load]);
|
||||
|
||||
return { routines, setRoutines, loading, error, reload: load };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Clock } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "@/src/components/general/routine-form";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import { dropRoutine, replaceRoutine } from "@/src/utils/routine-list-changes";
|
||||
import useBotRoutines from "../(hooks)/use-bot-routines";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
/**
|
||||
* A row in the conversation header's menu instead of an icon beside it.
|
||||
*/
|
||||
menu?: boolean;
|
||||
on_open?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The routines that run in this conversation: the schedules whose result
|
||||
* lands here, a bot's own on its chat or the ones it runs in this
|
||||
* workspace. Open it from the header menu; the list loads then, so the
|
||||
* page's props stay small. The user pauses, resumes, edits and deletes
|
||||
* their own; the bot makes them when asked in chat.
|
||||
*/
|
||||
export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const [open, set_open] = useState(false);
|
||||
const [editing, set_editing] = useState<CronJobWithNames>();
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<Status>();
|
||||
const { routines, setRoutines, loading, error } = useBotRoutines({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const name = bot.name || "This bot";
|
||||
const place = workspace?.name || "this workspace";
|
||||
const title = workspace?.id ? `${place} routines` : `${name}'s routines`;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) on_open?.();
|
||||
}, [open]);
|
||||
|
||||
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
|
||||
setRoutines((prev) => replaceRoutine({ routines: prev, cron_job }));
|
||||
|
||||
const toggle = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, enabled: !cron_job.enabled },
|
||||
});
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ cron_job: res.singleRes });
|
||||
set_status({ success: true, msg: cron_job.enabled ? "Paused." : "Resumed." });
|
||||
};
|
||||
|
||||
const remove = async ({ cron_job }: { cron_job: CronJobWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete the routine "${cron_job.title}"? A run already started carries on.`)) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await postAdminApi({ path: "routines/delete-routine", body: { cron_job_id: cron_job.id } });
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success) {
|
||||
set_status({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: cron_job.id }));
|
||||
set_status({ success: true, msg: "Deleted." });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
menu ? (
|
||||
<HeaderMenuItem icon={Clock} title="Schedules" description={title} loading={loading} />
|
||||
) : (
|
||||
<Button
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
|
||||
>
|
||||
<Clock size={18} aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={(next) => {
|
||||
set_open(next);
|
||||
|
||||
if (!next) {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}
|
||||
}}
|
||||
className="max-w-xl bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{editing ? editing.title || "Routine" : title}
|
||||
</H2>
|
||||
{editing ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_editing(undefined);
|
||||
set_status(undefined);
|
||||
}}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{editing
|
||||
? `${name} gets this brief as a task at every run, and the result lands here.`
|
||||
: workspace?.id
|
||||
? `Work ${name} does on a schedule in ${place}. Each run is a task, and its result lands here.`
|
||||
: `Work ${name} does on its own, on a schedule. Each run is a task, and its result lands here.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
|
||||
{editing ? (
|
||||
<RoutineForm
|
||||
key={editing.id}
|
||||
cron_job={editing}
|
||||
onCancel={() => set_editing(undefined)}
|
||||
onSaved={({ cron_job }) => {
|
||||
replace({ cron_job });
|
||||
set_editing(undefined);
|
||||
set_status({ success: true, msg: "Saved." });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SettingsList
|
||||
is_empty={!routines.length}
|
||||
empty={loading ? "Loading routines..." : error || "No routines here yet."}
|
||||
>
|
||||
{routines.map((cron_job) => (
|
||||
<RoutineRow
|
||||
key={cron_job.id}
|
||||
cron_job={cron_job}
|
||||
compact
|
||||
busy={busy}
|
||||
onToggle={() => toggle({ cron_job }).catch(() => {})}
|
||||
onEdit={() => set_editing(cron_job)}
|
||||
onDelete={() => remove({ cron_job }).catch(() => {})}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack className="mt-3 gap-1! items-stretch">
|
||||
<SettingsStatus status={status} />
|
||||
{editing ? null : (
|
||||
<Span className="text-[12px] leading-4 text-ink-secondary">
|
||||
{`Ask ${name} in chat to set one up, or change one here.`}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotComputersModal from "./bot-computers-modal";
|
||||
import BotFilesModal from "./bot-files-modal";
|
||||
import BotTasksModal from "./bot-tasks-modal";
|
||||
import BotRoutinesModal from "./bot-routines-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
@@ -108,6 +109,12 @@ export default function ConversationHeaderMenu({
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotRoutinesModal
|
||||
menu
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotComputersModal
|
||||
menu
|
||||
bot={bot}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import RoutineForm from "./routine-form";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Open while set.
|
||||
*/
|
||||
cron_job?: CronJobWithNames;
|
||||
onClose: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits a routine in a modal: its name, schedule and brief.
|
||||
*/
|
||||
export default function RoutineEditModal({ cron_job, onClose, onSaved }: Props) {
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(cron_job)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`Edit ${cron_job?.title || "the routine"}`}
|
||||
description={`${cron_job?.bot_name || "The bot"} gets the brief as a task at every run.`}
|
||||
>
|
||||
{cron_job ? <RoutineForm key={cron_job.id} cron_job={cron_job} onCancel={onClose} onSaved={onSaved} /> : null}
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
onCancel: () => void;
|
||||
onSaved: (params: { cron_job: CronJobWithNames }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A routine's name, schedule (a cron expression in UTC) and brief.
|
||||
*/
|
||||
export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
||||
const [title, setTitle] = useState(cron_job.title || "");
|
||||
const [schedule, setSchedule] = useState(cron_job.schedule || "");
|
||||
const [instructions, setInstructions] = useState(cron_job.instructions || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(`Save ${title || "this routine"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CronJobWithNames>({
|
||||
path: "routines/update-routine",
|
||||
body: { cron_job_id: cron_job.id, title, schedule, instructions },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the routine.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved({ cron_job: res.singleRes });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id="routine-title"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="routine-schedule"
|
||||
title="Schedule"
|
||||
label="Schedule"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
placeholder="0 9 * * MON-FRI"
|
||||
defaultValue={schedule}
|
||||
info="Cron, in UTC: minute hour day month weekday, or @hourly, @daily, @weekly."
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-40"
|
||||
title="Brief"
|
||||
content={instructions}
|
||||
info="What the bot does at every run, and how it knows it's done."
|
||||
onChange={(value) => setInstructions(value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import routineSubtitle from "../(utils)/routine-subtitle";
|
||||
|
||||
type Props = {
|
||||
cron_job: CronJobWithNames;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One routine: its bot's avatar, its name (Paused or Done when it is),
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete }: Props) {
|
||||
const title = cron_job.title || `Routine ${cron_job.id}`;
|
||||
const done = Boolean(cron_job.max_runs && Number(cron_job.run_count || 0) >= cron_job.max_runs);
|
||||
const chat_url = cron_job.workspace_id
|
||||
? `/admin/workspaces/${cron_job.workspace_id}/bots/${cron_job.bot_id}`
|
||||
: `/admin/bots/${cron_job.bot_id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={
|
||||
<BotAvatar
|
||||
image_media_id={cron_job.bot_image_media_id}
|
||||
{...botStoneProps({ color: cron_job.bot_color, stone: cron_job.bot_stone, hue: cron_job.bot_hue })}
|
||||
face={(cron_job.bot_face || "idle") as BotFace}
|
||||
/>
|
||||
}
|
||||
title={title}
|
||||
badge={cron_job.enabled ? undefined : done ? "Done" : "Paused"}
|
||||
subtitle={routineSubtitle({ cron_job })}
|
||||
>
|
||||
<SettingsRowAction title={`Chat with ${cron_job.bot_name || "the bot"}`} label="Chat" href={chat_url} />
|
||||
<SettingsRowAction
|
||||
title={cron_job.enabled ? `Pause ${title}` : `Resume ${title}`}
|
||||
label={cron_job.enabled ? "Pause" : "Resume"}
|
||||
disabled={busy}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import RoutineEditModal from "../(partials)/routine-edit-modal";
|
||||
import RoutineRow from "../(partials)/routine-row";
|
||||
import RoutineEditModal from "@/src/components/general/routine-edit-modal";
|
||||
import RoutineRow from "@/src/components/general/routine-row";
|
||||
|
||||
/**
|
||||
* Every routine the user's bots have: chat with the bot, pause or resume
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import routineSubtitle from "./routine-subtitle";
|
||||
|
||||
const now = Date.UTC(2026, 9, 19, 8, 30);
|
||||
|
||||
describe("routineSubtitle", () => {
|
||||
test("names the bot, workspace, schedule and next run", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: {
|
||||
bot_name: "Scout",
|
||||
workspace_name: "Launch",
|
||||
schedule: "0 9 * * *",
|
||||
enabled: 1,
|
||||
next_run_at: now + 30 * 60 * 1000,
|
||||
},
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · Launch · 0 9 * * * UTC · next in 30m");
|
||||
});
|
||||
|
||||
test("shows the last error instead, and no next run when paused", () => {
|
||||
expect(
|
||||
routineSubtitle({
|
||||
cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now, last_error: "Skipped a run." },
|
||||
now,
|
||||
}),
|
||||
).toBe("Scout · @daily UTC · Skipped a run.");
|
||||
expect(routineSubtitle({ cron_job: { bot_name: "Scout", schedule: "@daily", enabled: 0, next_run_at: now }, now })).toBe(
|
||||
"Scout · @daily UTC",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { CronJobWithNames } from "@/src/types";
|
||||
import timeUntil from "@/src/utils/time-until";
|
||||
|
||||
type Params = {
|
||||
cron_job: CronJobWithNames;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The line under a routine's name: whose it is, where it runs, its
|
||||
* schedule, then when it runs next or why it last couldn't.
|
||||
*/
|
||||
export default function routineSubtitle({ cron_job, now = Date.now() }: Params) {
|
||||
const next =
|
||||
cron_job.enabled && cron_job.next_run_at ? `next ${timeUntil({ at: cron_job.next_run_at, now })}` : "";
|
||||
|
||||
return [
|
||||
cron_job.bot_name || "A bot",
|
||||
cron_job.workspace_name || "",
|
||||
`${cron_job.schedule} UTC`,
|
||||
cron_job.last_error || next,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
Reference in New Issue
Block a user