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:
2026-10-22 05:57:50 +01:00
parent 2d108b05bf
commit 7cb26c4e36
60 changed files with 3793 additions and 52 deletions
@@ -0,0 +1,266 @@
import { useRef, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
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 type { TaskDetail, TaskFile } from "@/src/types";
import TaskFileRow from "@/src/components/general/task-file-row";
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";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Props = {
task: TaskDetail;
/**
* A crew manager can change it, file onto it and delete it.
*/
can_manage?: boolean;
onChanged: (task: TaskDetail, msg?: string) => void;
onDeleted: () => void;
};
/**
* 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.
*/
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
const [editing, setEditing] = useState(false);
const [note, setNote] = useState("");
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string>();
const file_input = useRef<HTMLInputElement>(null);
const run = async (params: { path: string; body: Record<string, unknown>; done: string }) => {
if (busy) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
`/api/admin/${params.path}`,
{ method: "POST", body: params.body },
);
setBusy(false);
if (!res.success || !res.singleRes) {
setStatus(res.msg || "That didn't work.");
return;
}
setStatus(params.done);
onChanged(res.singleRes, params.done);
};
const addNote = async () => {
if (!note.trim()) return;
await run({ path: "tasks/add-note", body: { task_id: Number(task.id), text: note }, done: "Note added." });
setNote("");
};
const attach = async (file: File) => {
if (busy) return;
const data_url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
}).catch(() => "");
if (!data_url) {
setStatus(`${file.name} couldn't be read.`);
return;
}
await run({
path: "tasks/attach-file",
body: { task_id: Number(task.id), data_url, name: file.name, kind: "deliverable" },
done: `${file.name} added as a deliverable.`,
});
};
const detach = async ({ file }: { file: TaskFile }) => {
if (!window.confirm(`Unlink ${file.name || "this file"} from the task?`)) return;
await run({
path: "tasks/detach-file",
body: { task_id: Number(task.id), task_media_id: Number(file.id) },
done: "File unlinked.",
});
};
if (editing) {
return (
<TaskForm
task={task}
onCancel={() => setEditing(false)}
onSaved={(saved, msg) => {
setEditing(false);
onChanged(saved, msg);
}}
/>
);
}
const deliverables = task.files.filter((file) => file.kind != "reference");
const references = task.files.filter((file) => file.kind == "reference");
return (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<TaskStatusChip status={task.status} />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{can_manage ? (
<Row className="shrink-0 gap-1! flex-nowrap">
<Button
title="Edit the task"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => setEditing(true)}
className="px-2! text-[12px]"
>
Edit
</Button>
<Button
title="Delete the task"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Delete "${task.title}"? Its notes and links go with it.`)) return;
setBusy(true);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
setBusy(false);
if (!res.success) {
setStatus(res.msg || "That didn't work.");
return;
}
onDeleted();
}}
className="px-2! text-[12px]"
>
Delete
</Button>
</Row>
) : null}
</Row>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null}
{task.description ? (
<Span className="whitespace-pre-wrap text-[13px] leading-[19px] text-ink">{task.description}</Span>
) : null}
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
{deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => (
<TaskFileRow
key={file.id}
file={file}
busy={busy}
onDetach={can_manage ? () => detach({ file }) : undefined}
/>
))}
</Stack>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">Nothing filed yet.</Span>
)}
{can_manage ? (
<Row className="gap-2! flex-nowrap items-center">
<input
ref={file_input}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) attach(file).catch(() => {});
event.target.value = "";
}}
/>
<Button
title="Add a file as a deliverable"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => file_input.current?.click()}
className="px-2! text-[12px]"
>
Add a file
</Button>
</Row>
) : null}
</Stack>
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
{task.notes.length ? (
task.notes.map((entry) => (
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start">
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary">
{entry.author == "user" ? "You" : "Bot"}
</Span>
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
{entry.text}
</Span>
</Row>
))
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
)}
<Textarea
placeholder="Add a note"
value={note}
changeHandler={setNote}
rows={2}
disabled={busy}
/>
<Row className="justify-end">
<Button
title="Add the note"
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy || !note.trim()}
onClick={() => addNote().catch(() => {})}
>
Add note
</Button>
</Row>
</Stack>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</Stack>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import { FileText, Image as ImageIcon } from "lucide-react";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskFile } from "@/src/types";
type Props = {
file: TaskFile;
/**
* A crew manager can unlink it.
*/
onDetach?: () => void;
busy?: boolean;
};
/**
* One file on a task: its name, opening the bytes at
* `/media/private/<id>`, and its `[file:ID]` chip when it lives in a bot's
* or workspace's folder (so the file's real home is reachable too).
*/
export default function TaskFileRow({ file, onDetach, busy }: Props) {
if (file.missing) {
return (
<Row className="w-full min-h-10 gap-2! flex-nowrap items-center px-1 py-1">
<FileText size={16} className="shrink-0 text-ink-disabled" aria-hidden />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-disabled truncate">
{file.name || `File ${file.media_id}`} (gone)
</Span>
</Row>
);
}
return (
<Row className="w-full min-h-10 gap-2! flex-nowrap items-center px-1 py-1">
{file.media_type == "image" ? (
<ImageIcon size={16} className="shrink-0 text-ink-secondary" aria-hidden />
) : (
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
)}
<Link
href={`/media/private/${file.media_id}`}
target="_blank"
rel="noreferrer"
title={file.name || "Open the file"}
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
>
{file.name || `File ${file.media_id}`}
</Link>
{file.kind == "reference" ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
) : null}
{file.bot_file_id ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{`[file:${file.bot_file_id}]`}
</Span>
) : null}
{onDetach ? (
<Span
role="button"
tabIndex={0}
aria-disabled={busy}
title="Unlink this file"
onClick={() => (busy ? undefined : onDetach())}
onKeyDown={(event) => {
if (busy) return;
if (event.key == "Enter" || event.key == " ") onDetach();
}}
className="shrink-0 px-1 text-[11px] leading-4 text-ink-secondary cursor-pointer hover:text-ink"
>
Unlink
</Span>
) : null}
</Row>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { TaskDetail } from "@/src/types";
type Props = {
/**
* The list it's added to: a bot's own, or a workspace's.
*/
bot_id?: number | null;
workspace_id?: number | null;
/**
* Edits this task; adds one when not given.
*/
task?: TaskDetail | null;
onSaved: (task: TaskDetail, msg: string) => void;
onCancel: () => void;
};
/**
* The one task form: a title, the brief, the status and, while it's
* blocked, what the user has to do. The workspace's tasks group and the
* conversation's tasks modal both add and edit with it.
*/
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) {
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo");
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (!title.trim()) {
setError("A task needs a title.");
return;
}
setLoading(true);
setError(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{
method: "POST",
body: {
task_id: task?.id ? Number(task.id) : undefined,
bot_id: task ? undefined : bot_id || undefined,
workspace_id: task ? undefined : workspace_id || undefined,
title,
description,
status,
blocked_reason: status == "blocked" ? blocked_reason || null : null,
},
},
);
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the task.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch">
<Input
label="Title"
value={title}
changeHandler={setTitle}
placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength}
disabled={loading}
/>
<Textarea
label="Brief"
value={description}
changeHandler={setDescription}
placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength}
rows={5}
disabled={loading}
/>
<Select
label="Status"
showLabel
options={TaskStatuses.map((it) => ({
title: it.title,
value: it.value,
default: it.value == status,
}))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
/>
{status == "blocked" ? (
<Textarea
label="Waiting on the user for"
value={blocked_reason}
changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on."
maxLength={AppData.TaskNoteMaxLength}
rows={2}
disabled={loading}
/>
) : null}
<AddFormActions
submit_label={task ? "Save" : "Add task"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Stack>
</Form>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useContext } from "react";
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 type { TaskWithNames } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time";
import TaskStatusChip from "./task-status-chip";
type Props = {
task: TaskWithNames;
/**
* Opens the task to read its notes and files, and to edit it.
*/
onOpen?: () => void;
/**
* Moves it to one status; only crew managers get the controls.
*/
onStatusChange?: (status: string) => void;
busy?: boolean;
};
/**
* One task: whose it is (a bot's stone), its title and status, the
* workspace, what it's blocked on, its latest note and how long ago it
* moved.
*/
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled";
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
return (
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
<BotAvatar
image_media_id={task.bot_image_media_id}
hue={(task.bot_kind == "coordinator" ? null : task.bot_hue || null) as BotHue | null}
face={(task.bot_face || "idle") as BotFace}
size={28}
/>
<Stack className="grow min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Button
title={task.title || "Open the task"}
type="button"
variant="ghost"
color="text"
onClick={onOpen}
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${
done ? "text-ink-secondary! line-through" : "text-ink!"
}`}
>
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button>
<TaskStatusChip status={task.status} />
</Row>
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="min-w-0 text-xs leading-4 text-ink truncate">
{`Waiting on you: ${task.blocked_reason}`}
</Span>
) : null}
{task.updated_at ? (
<Span className="ml-auto shrink-0 text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(task.updated_at) })}
</Span>
) : null}
</Row>
{task.latest_note ? (
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
) : null}
{can_manage && onStatusChange ? (
<Row className="gap-1! flex-nowrap items-center pt-0.5">
{moves
.filter((it) => it.value != task.status)
.map((it) => (
<Button
key={it.value}
title={`Mark it ${it.title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => onStatusChange(it.value)}
className="px-1.5! text-[12px]"
>
{it.title}
</Button>
))}
</Row>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,29 @@
import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
type Props = {
status?: TaskStatus | string | null;
};
/**
* A task's status as one word. The tone follows the state: a blocked task
* is the only one that asks for the user, so it's the only one that reads
* as a call.
*/
export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone =
value == "blocked"
? "text-ink border-ink"
: value == "doing"
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
{title}
</Tag>
);
}