Let the coordinator and workspace admins hand over tasks and routines

delegate_task takes a task_id to hand over a task already on a list: it
moves to the worker (a workspace's task stays on its list), goes to
doing, and the job is linked back so its end is noted on the task.
hand_over_schedule moves a bot's own routine to another bot, keeping its
workspace. Users get Hand over in the Tasks UI and both routine lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 11:23:26 +01:00
co-authored by Claude Opus 5.5
parent b4283b607e
commit 93896646c7
36 changed files with 1334 additions and 94 deletions
@@ -8,6 +8,7 @@ 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 HandOverForm from "@/src/components/general/hand-over-form";
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";
@@ -33,12 +34,13 @@ type Props = {
* 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.
* page's props stay small. The user pauses, resumes, edits, hands over
* 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 [handing, set_handing] = useState<CronJobWithNames>();
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<Status>();
const { routines, setRoutines, loading, error } = useBotRoutines({
@@ -124,6 +126,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
if (!next) {
set_editing(undefined);
set_handing(undefined);
set_status(undefined);
}
}}
@@ -132,9 +135,9 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
<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}
{editing ? editing.title || "Routine" : handing ? `Hand over ${handing.title || "the routine"}` : title}
</H2>
{editing ? (
{editing || handing ? (
<Button
title="Back to the list"
type="button"
@@ -143,6 +146,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
size="small"
onClick={() => {
set_editing(undefined);
set_handing(undefined);
set_status(undefined);
}}
className="px-2! text-[12px]"
@@ -154,7 +158,9 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
<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
: handing
? `It keeps its schedule and where it runs; from then on its results land in the new bot's conversation.`
: 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>
@@ -172,6 +178,20 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
set_status({ success: true, msg: "Saved." });
}}
/>
) : handing ? (
<HandOverForm<CronJobWithNames>
key={handing.id}
what={`"${handing.title}"`}
targets_path="routines/hand-over-targets"
submit_path="routines/hand-over-routine"
body={{ cron_job_id: handing.id }}
onCancel={() => set_handing(undefined)}
onHandedOver={({ saved, msg }) => {
setRoutines((prev) => dropRoutine({ routines: prev, cron_job_id: saved.id }));
set_handing(undefined);
set_status({ success: true, msg });
}}
/>
) : (
<SettingsList
is_empty={!routines.length}
@@ -185,6 +205,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
busy={busy}
onToggle={() => toggle({ cron_job }).catch(() => {})}
onEdit={() => set_editing(cron_job)}
onHandOver={() => set_handing(cron_job)}
onDelete={() => remove({ cron_job }).catch(() => {})}
/>
))}
@@ -194,7 +215,7 @@ export default function BotRoutinesModal({ bot, workspace, menu, on_open }: Prop
<Stack className="mt-3 gap-1! items-stretch">
<SettingsStatus status={status} />
{editing ? null : (
{editing || handing ? null : (
<Span className="text-[12px] leading-4 text-ink-secondary">
{`Ask ${name} in chat to set one up, or change one here.`}
</Span>
@@ -181,9 +181,19 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_status(msg);
/**
* Handed to another bot, it's on that bot's list now.
*/
if (!workspace?.id && saved.bot_id != bot.id) {
setTasks((prev) => prev.filter((item) => item.id != saved.id));
set_open_task(null);
return;
}
set_open_task(saved);
replace({ task: saved });
set_status(msg);
}}
onDeleted={() => {
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
@@ -6,12 +6,14 @@ 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 AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import HandOverForm from "@/src/components/general/hand-over-form";
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
* one, edit it, delete it. Bots create them when asked in chat.
* one, edit it, hand it to another bot, delete it. Bots create them when asked in chat.
*/
export default function RoutinesSection() {
const { pageProps } = useContext(AppContext);
@@ -19,6 +21,7 @@ export default function RoutinesSection() {
items: pageProps?.cron_jobs || [],
});
const [editing, setEditing] = useState<CronJobWithNames>();
const [handing, setHanding] = useState<CronJobWithNames>();
const replace = ({ cron_job }: { cron_job: CronJobWithNames }) =>
setItems((prev) => prev.map((item) => (item.id == cron_job.id ? cron_job : item)));
@@ -53,6 +56,7 @@ export default function RoutinesSection() {
busy={busy}
onToggle={() => toggle({ cron_job })}
onEdit={() => setEditing(cron_job)}
onHandOver={() => setHanding(cron_job)}
onDelete={() =>
remove({
id: cron_job.id,
@@ -74,6 +78,28 @@ export default function RoutinesSection() {
setStatus({ success: true, msg: "Saved." });
}}
/>
<AddModalShell
open={Boolean(handing)}
setOpen={(open) => !open && setHanding(undefined)}
title={`Hand over ${handing?.title || "the routine"}`}
description="It keeps its schedule and where it runs; its results land in the new bot's conversation."
>
{handing ? (
<HandOverForm<CronJobWithNames>
key={handing.id}
what={`"${handing.title}"`}
targets_path="routines/hand-over-targets"
submit_path="routines/hand-over-routine"
body={{ cron_job_id: handing.id }}
onCancel={() => setHanding(undefined)}
onHandedOver={({ saved, msg }) => {
replace({ cron_job: saved });
setHanding(undefined);
setStatus({ success: true, msg });
}}
/>
) : null}
</AddModalShell>
</SettingsGroup>
);
}