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:
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import type { HandOverTarget } from "@/src/types";
|
||||
|
||||
type Props<T> = {
|
||||
/**
|
||||
* What is handed over, for the confirm ("the task "Draft the post"").
|
||||
*/
|
||||
what: string;
|
||||
/**
|
||||
* Under `/api/admin/`: who it can go to, and the hand-over itself.
|
||||
* Both get `body`; the hand-over also the picked `bot_id`.
|
||||
*/
|
||||
targets_path: string;
|
||||
submit_path: string;
|
||||
body: Record<string, unknown>;
|
||||
/**
|
||||
* Offer a note added to the brief (a task's hand-over).
|
||||
*/
|
||||
with_note?: boolean;
|
||||
onHandedOver: (params: { saved: T; msg: string }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the bot a task or routine goes to (the ones already where it runs,
|
||||
* loaded when the form opens) and hands it over. Shared by the task panel
|
||||
* and both routine lists.
|
||||
*/
|
||||
export default function HandOverForm<T extends { [k: string]: any }>({
|
||||
what,
|
||||
targets_path,
|
||||
submit_path,
|
||||
body,
|
||||
with_note,
|
||||
onHandedOver,
|
||||
onCancel,
|
||||
}: Props<T>) {
|
||||
const [targets, setTargets] = useState<HandOverTarget[]>();
|
||||
const [bot_id, setBotId] = useState<number>();
|
||||
const [note, setNote] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
postAdminApi<HandOverTarget>({ path: targets_path, body }).then((res) => {
|
||||
if (!res.success) {
|
||||
setError(res.msg || "Couldn't load the bots.");
|
||||
setTargets([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = (res.payload || []) as HandOverTarget[];
|
||||
|
||||
setTargets(list);
|
||||
setBotId(list[0]?.id);
|
||||
});
|
||||
}, [targets_path]);
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
const target = targets?.find((it) => it.id == bot_id);
|
||||
|
||||
if (!target) {
|
||||
setError("Pick a bot.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Hand ${what} over to ${target.name}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<T>({
|
||||
path: submit_path,
|
||||
body: { ...body, bot_id: target.id, instructions: with_note && note.trim() ? note : undefined },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't hand it over.");
|
||||
return;
|
||||
}
|
||||
|
||||
onHandedOver({ saved: res.singleRes, msg: res.msg || `Handed over to ${target.name}.` });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={submit} className="w-full gap-3! items-stretch">
|
||||
<Stack className="gap-3! items-stretch">
|
||||
{!targets ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">Loading the bots...</Span>
|
||||
) : targets.length ? (
|
||||
<Select
|
||||
key={targets.length}
|
||||
label="Hand over to"
|
||||
showLabel
|
||||
options={targets.map((it) => ({ title: it.name, value: String(it.id), default: it.id == bot_id }))}
|
||||
changeHandler={(value) => setBotId(Number(value))}
|
||||
/>
|
||||
) : (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
No other bot works where this runs. Add one there first.
|
||||
</Span>
|
||||
)}
|
||||
{with_note && targets?.length ? (
|
||||
<Textarea
|
||||
label="Note for the brief"
|
||||
value={note}
|
||||
changeHandler={setNote}
|
||||
placeholder="Anything they should know beyond the task's own brief."
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : null}
|
||||
<AddFormActions submit_label="Hand over" loading={loading} error={error} onCancel={onCancel} />
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ type Props = {
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
/**
|
||||
* Hand it to another bot where it runs.
|
||||
*/
|
||||
onHandOver?: () => void;
|
||||
/**
|
||||
* In a conversation's own modal the Chat link and the bot's and
|
||||
* workspace's names are already the ones on screen.
|
||||
@@ -21,9 +25,10 @@ type Props = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* whose it is and when it runs, then Chat, Pause or Resume, Edit, Hand
|
||||
* over, Delete.
|
||||
*/
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, compact }: Props) {
|
||||
export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete, onHandOver, compact }: 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
|
||||
@@ -57,6 +62,14 @@ export default function RoutineRow({ cron_job, busy, onToggle, onEdit, onDelete,
|
||||
onClick={onToggle}
|
||||
/>
|
||||
<SettingsRowAction title={`Edit ${title}`} label="Edit" disabled={busy} onClick={onEdit} />
|
||||
{onHandOver ? (
|
||||
<SettingsRowAction
|
||||
title={`Hand ${title} to another bot`}
|
||||
label="Hand over"
|
||||
disabled={busy}
|
||||
onClick={onHandOver}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsRowAction title={`Delete ${title}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
</SettingsListRow>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import { OpenTaskStatuses } from "@/src/dict/task-dict";
|
||||
import type { TaskDetail, TaskFile } from "@/src/types";
|
||||
import TaskFileRow from "@/src/components/general/task-file-row";
|
||||
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||
import TaskForm from "@/src/components/general/task-form";
|
||||
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
@@ -24,10 +26,12 @@ type Props = {
|
||||
/**
|
||||
* One task opened: what it is, its notes oldest first, its files (each
|
||||
* opening its bytes), a box to add a note, and edit or delete. A crew
|
||||
* manager can file a new deliverable straight onto it.
|
||||
* manager can file a new deliverable straight onto it, or hand an open
|
||||
* one to another bot, which starts on it.
|
||||
*/
|
||||
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [handing_over, setHandingOver] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string>();
|
||||
@@ -107,6 +111,23 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
);
|
||||
}
|
||||
|
||||
if (handing_over) {
|
||||
return (
|
||||
<HandOverForm<TaskDetail>
|
||||
what={`"${task.title}"`}
|
||||
targets_path="tasks/hand-over-targets"
|
||||
submit_path="tasks/hand-over-task"
|
||||
body={{ task_id: Number(task.id) }}
|
||||
with_note
|
||||
onCancel={() => setHandingOver(false)}
|
||||
onHandedOver={({ saved, msg }) => {
|
||||
setHandingOver(false);
|
||||
onChanged(saved, msg);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const deliverables = task.files.filter((file) => file.kind != "reference");
|
||||
const references = task.files.filter((file) => file.kind == "reference");
|
||||
|
||||
@@ -121,6 +142,20 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
</Span>
|
||||
{can_manage ? (
|
||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
|
||||
<Button
|
||||
title="Hand the task to another bot"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setHandingOver(true)}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Hand over
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
title="Edit the task"
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user