Add tasks: the crew's shared to-do list
Bots and workspaces each keep a list of work tracked until it's done, and the bots move it along themselves as they work. - tasks (one collection per bot or per workspace), task_notes (the progress log) and task_media (a deliverable or a reference file, its bytes a private media row served at /media/private/<id>) - statuses todo/doing/blocked/done/cancelled, with blocked_reason while only the user can unblock it - visibility as SQL in src/utils/task-access: a user's own bots and workspaces, a run's own list plus its workspaces, everything for the coordinator; every tool and endpoint resolves through it - list_tasks, create_task and update_task on every run, so a chat reply, a task and a routine all read the list, log progress and file what they produced - TasksNote on every run and TaskReminderNote in chat, so a greeting or a proposed next step brings pending work up in one measured line - tasks group on the workspace dashboard and a tasks modal in the conversation header, sharing one form, row and detail panel - a bot's or workspace's own chat uploads are read before the delete so none are stranded; bot files keep their folder Also corrects the README's RAM guidance and table alignment.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
|
||||
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
|
||||
import TaskForm from "@/src/components/general/task-form";
|
||||
import TaskListItem from "@/src/components/general/task-list-item";
|
||||
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import useBotTasks from "../(hooks)/use-bot-tasks";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
|
||||
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 tasks this conversation works on: a bot's own list, or in a workspace
|
||||
* the workspace's shared one. Open it from the header menu; the list loads
|
||||
* then, so the page's props stay small. A crew manager adds, moves, edits
|
||||
* and deletes; a task opens to show its notes and files.
|
||||
*/
|
||||
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [adding, set_adding] = useState(false);
|
||||
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<string>();
|
||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||
const { tasks, setTasks, loading, error, reload } = useBotTasks({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id ? Number(workspace.id) : null,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const name = bot.name || "This bot";
|
||||
const title = workspace?.id ? `${workspace.name || "Workspace"} tasks` : `${name}'s tasks`;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) on_open?.();
|
||||
}, [open]);
|
||||
|
||||
const replace = ({ task }: { task: TaskWithNames }) =>
|
||||
setTasks((prev) => [task, ...prev.filter((item) => item.id != task.id)]);
|
||||
|
||||
const move = async ({ task, next }: { task: TaskWithNames; next: string }) => {
|
||||
if (busy) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
|
||||
"/api/admin/tasks/save-task",
|
||||
{ method: "POST", body: { task_id: Number(task.id), status: next } },
|
||||
);
|
||||
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
set_status(res.msg || "That didn't work.");
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ task: res.singleRes });
|
||||
};
|
||||
|
||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
||||
set_status(undefined);
|
||||
|
||||
/**
|
||||
* The list already has the task; the detail endpoint serves its
|
||||
* notes and files.
|
||||
*/
|
||||
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
|
||||
`/api/admin/tasks/get-task?task_id=${task.id}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
|
||||
if (!detail?.success || !detail.singleRes) {
|
||||
set_status(detail?.msg || "Couldn't open the task.");
|
||||
return;
|
||||
}
|
||||
|
||||
set_open_task(detail.singleRes);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
menu ? (
|
||||
<HeaderMenuItem
|
||||
icon={ListChecks}
|
||||
title="Tasks"
|
||||
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"
|
||||
>
|
||||
<ListChecks size={18} aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
set_open_task(null);
|
||||
set_adding(false);
|
||||
}
|
||||
}}
|
||||
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">
|
||||
{open_task ? open_task.title || "Task" : title}
|
||||
</H2>
|
||||
{open_task ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => set_open_task(null)}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : can_manage ? (
|
||||
<Button
|
||||
title="Add a task"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_adding(true);
|
||||
set_open_task(null);
|
||||
}}
|
||||
>
|
||||
Add task
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
{!open_task ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{workspace?.id
|
||||
? `The shared to-do list of ${workspace.name || "this workspace"}. Every bot here sees it, and they keep it up to date as they work.`
|
||||
: `What ${name} is working through, and what it's waiting on.`}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
|
||||
{open_task ? (
|
||||
<TaskDetailPanel
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
onChanged={(saved, msg) => {
|
||||
set_open_task(saved);
|
||||
replace({ task: saved });
|
||||
set_status(msg);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
|
||||
set_open_task(null);
|
||||
set_status("Task deleted.");
|
||||
}}
|
||||
/>
|
||||
) : adding ? (
|
||||
<TaskForm
|
||||
bot_id={workspace?.id ? null : Number(bot.id)}
|
||||
workspace_id={workspace?.id ? Number(workspace.id) : null}
|
||||
onCancel={() => set_adding(false)}
|
||||
onSaved={(saved, msg) => {
|
||||
replace({ task: saved });
|
||||
set_adding(false);
|
||||
set_status(msg);
|
||||
}}
|
||||
/>
|
||||
) : tasks.length ? (
|
||||
tasks.map((task) => (
|
||||
<TaskListItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={busy}
|
||||
onOpen={() => show({ task }).catch(() => {})}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{loading ? "Loading tasks..." : error || "No tasks yet."}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{status ? (
|
||||
<Span role="status" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{status}
|
||||
</Span>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
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 BotModelsModal from "./bot-models-modal";
|
||||
import HeaderMenuItem from "./header-menu-item";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
@@ -101,6 +102,12 @@ export default function ConversationHeaderMenu({
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotTasksModal
|
||||
menu
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<BotComputersModal
|
||||
menu
|
||||
bot={bot}
|
||||
|
||||
Reference in New Issue
Block a user