Open a task in its own modal from its title, with status moves, and edit its brief in the code editor

The modal shows who has the task and where, who added it and when it
moved, its files and notes (each note naming its bot). The task form's
fields all show their labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 05:49:42 +01:00
co-authored by Claude Opus 5.5
parent 74da091dd4
commit c547e556ec
13 changed files with 462 additions and 240 deletions
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import taskPlaceLabel from "@/src/utils/task-place-label";
import TaskDetailPanel from "./task-detail-panel";
type Props = {
/**
* The list row clicked; open while set. Its notes and files load when
* it opens, so the list stays small.
*/
task?: TaskWithNames | null;
/**
* A crew manager can change it, file onto it and delete it.
*/
can_manage?: boolean;
onClose: () => void;
onChanged: (params: { task: TaskDetail; msg?: string }) => void;
onDeleted: (params: { task_id: number }) => void;
};
/**
* A task opened from a list in a modal of its own: everything about it, and
* for a crew manager its edit, hand-over, files and delete.
*/
export default function TaskDetailModal({ task, can_manage, onClose, onChanged, onDeleted }: Props) {
const [detail, set_detail] = useState<TaskDetail | null>(null);
const [error, set_error] = useState<string>();
const task_id = task?.id ? Number(task.id) : 0;
useEffect(() => {
set_detail(null);
set_error(undefined);
if (!task_id) return;
let live = true;
fetchApi<{}, APIResponseObject<TaskDetail>>(`/api/admin/tasks/get-task?task_id=${task_id}`, {
method: "GET",
})
.catch(() => null)
.then((res) => {
if (!live) return;
if (!res?.success || !res.singleRes) {
set_error(res?.msg || "Couldn't open the task.");
return;
}
set_detail(res.singleRes);
});
return () => {
live = false;
};
}, [task_id]);
const shown = detail || task;
return (
<AddModalShell
open={Boolean(task_id)}
setOpen={(open) => !open && onClose()}
title={shown?.title || (task_id ? `Task ${task_id}` : "Task")}
description={shown ? taskPlaceLabel({ task: shown }) : undefined}
>
<Stack className="w-full mt-4 gap-0! items-stretch">
{detail ? (
<TaskDetailPanel
key={detail.id}
task={detail}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_detail(saved);
onChanged({ task: saved, msg });
}}
onDeleted={() => onDeleted({ task_id })}
/>
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{error || "Loading the task..."}
</Span>
)}
</Stack>
</AddModalShell>
);
}
+91 -21
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useContext, 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";
@@ -10,6 +10,14 @@ 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 TaskStatusMoves from "@/src/components/general/task-status-moves";
import BotAvatar from "@/src/components/general/bot-avatar";
import TaskFactRow from "@/src/components/general/task-fact-row";
import TaskNoteRow from "@/src/components/general/task-note-row";
import type { BotFace } from "@/src/dict/bot-dict";
import { AppContext } from "@/src/pages/__root";
import botStoneProps from "@/src/utils/bot-stone-props";
import relativeTime from "@/src/utils/relative-time";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -24,12 +32,14 @@ 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
* One task opened: its status (a crew manager moves it along), who has it and where, who added it and
* when it last moved, what it is, its files (each opening its bytes), its
* notes oldest first, a box to add a note, and edit or delete. A crew
* 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 { pageProps } = useContext(AppContext);
const [editing, setEditing] = useState(false);
const [handing_over, setHandingOver] = useState(false);
const [note, setNote] = useState("");
@@ -135,13 +145,21 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
<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">
<TaskStatusMoves
status={task.status}
disabled={busy}
onMove={(next) =>
run({
path: "tasks/save-task",
body: { task_id: Number(task.id), status: next },
done: "Status changed.",
})
}
/>
) : null}
{can_manage ? (
<Row className="ml-auto shrink-0 gap-1! flex-nowrap">
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
<Button
title="Hand the task to another bot"
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null}
</Row>
<Stack className="w-full gap-1.5! items-stretch">
<TaskFactRow label="Who has it">
{task.bot_id ? (
<>
<BotAvatar
image_media_id={task.bot_image_media_id}
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
face={(task.bot_face || "idle") as BotFace}
size={18}
/>
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
</>
) : (
<Span className="text-ink-secondary">Shared, nobody has taken it up yet</Span>
)}
</TaskFactRow>
<TaskFactRow label="Where">
<Span className="min-w-0 truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: `${task.bot_name || "The bot"}'s own list`}
</Span>
</TaskFactRow>
<TaskFactRow label="Added">
<Span className="min-w-0 truncate">
{[
task.created_by_bot_id
? `By ${task.created_by_bot_name || `bot ${task.created_by_bot_id}`}`
: task.created_by_user_id
? task.created_by_user_id == pageProps?.user?.id
? "By you"
: `By ${task.created_by_user_name || "a user"}`
: task.origin == "bot"
? "By a bot"
: "By hand",
task.created_at ? relativeTime({ at: Number(task.created_at) }) : null,
]
.filter(Boolean)
.join(" · ")}
</Span>
</TaskFactRow>
{task.updated_at ? (
<TaskFactRow label="Last moved">
<Span>{relativeTime({ at: Number(task.updated_at) })}</Span>
</TaskFactRow>
) : null}
{task.completed_at ? (
<TaskFactRow label="Finished">
<Span>{relativeTime({ at: Number(task.completed_at) })}</Span>
</TaskFactRow>
) : null}
</Stack>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null}
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null}
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.files.length ? `Files (${task.files.length})` : "Files"}
</Span>
{deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => (
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
</Stack>
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.notes.length ? `Notes (${task.notes.length})` : "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>
))
<Stack className="w-full gap-0! items-stretch">
{task.notes.map((entry) => (
<TaskNoteRow key={entry.id} note={entry} />
))}
</Stack>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
)}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
label: string;
children: ReactNode;
};
/**
* One fact about an opened task: a label, then its value.
*/
export default function TaskFactRow({ label, children }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-center">
<Span className="w-24 shrink-0 text-[12px] leading-[18px] text-ink-secondary">{label}</Span>
<Row className="grow min-w-0 gap-2! flex-nowrap items-center text-[13px] leading-[18px] text-ink">
{children}
</Row>
</Row>
);
}
+46 -27
View File
@@ -6,6 +6,7 @@ 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 CodeEditor from "@/src/components/general/code-editor";
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";
@@ -26,15 +27,25 @@ type Props = {
};
/**
* The one task form: a title, the brief, the status and, while it's
* The one task form: a title, the brief (in the code editor), 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) {
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 [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>();
@@ -49,21 +60,22 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
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,
},
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);
@@ -77,25 +89,28 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return (
<Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch">
<Stack className="gap-6! items-stretch">
<Input
title="Title"
label="Title"
showLabel
value={title}
changeHandler={setTitle}
placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength}
disabled={loading}
/>
<Textarea
label="Brief"
value={description}
changeHandler={setDescription}
<CodeEditor
mode="markdown"
className="h-[500px]"
title="Brief"
placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength}
rows={5}
disabled={loading}
content={description}
info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
onChange={setDescription}
/>
<Select
title="Status"
label="Status"
showLabel
options={TaskStatuses.map((it) => ({
@@ -103,11 +118,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
value: it.value,
default: it.value == status,
}))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
changeHandler={(value) =>
setStatus(String(value) as TaskStatus)
}
/>
{status == "blocked" ? (
<Textarea
title="Waiting on the user for"
label="Waiting on the user for"
showLabel
value={blocked_reason}
changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on."
+11 -27
View File
@@ -1,4 +1,5 @@
import { useContext } from "react";
import { twMerge } from "tailwind-merge";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
@@ -8,18 +9,19 @@ import { AppContext } from "@/src/pages/__root";
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 { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label";
import { Trash2 } from "lucide-react";
import RowIconButton from "./row-icon-button";
import TaskStatusChip from "./task-status-chip";
import TaskStatusMoves from "./task-status-moves";
type Props = {
task: TaskWithNames;
/**
* Opens the task to read its notes and files, and to edit it.
* Opens the task in its modal: everything about it, and for a crew
* manager its edit, hand-over, files and delete. The title opens it.
*/
onOpen?: () => void;
/**
@@ -36,13 +38,12 @@ type Props = {
/**
* One task: whose it is (a bot's stone), its title and status, where it
* is and who has it, what it's blocked on, its latest note and how long ago it
* moved.
* moved. Its title opens it in a modal.
*/
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, 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">
@@ -55,14 +56,15 @@ export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, b
<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"}
title={`Open ${task.title || `task ${task.id}`}`}
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!"
}`}
className={twMerge(
"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>
@@ -95,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, b
<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>
<TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="pt-0.5" />
) : null}
</Stack>
</Row>
+31
View File
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { TaskNote } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
type Props = {
note: TaskNote;
};
/**
* One note on an opened task: who wrote it (the user, or the bot by name),
* how long ago, and what it says.
*/
export default function TaskNoteRow({ note }: Props) {
const author = note.author == "user" ? "You" : note.bot_name || "Bot";
return (
<Stack className="w-full gap-0.5! items-stretch py-1.5 border-0 border-b border-solid border-hairline last:border-b-0">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="text-[12px] font-bold leading-4 text-ink">{author}</Span>
{note.created_at ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(note.created_at) })}
</Span>
) : null}
</Row>
<Span className="whitespace-pre-wrap text-[13px] leading-[18px] text-ink">{note.text}</Span>
</Stack>
);
}
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
value: string;
disabled?: boolean;
onMove: (status: string) => void;
};
/**
* Moves a task to one status: To do, Doing or Done.
*/
export default function TaskStatusMoveButton({ title, value, disabled, onMove }: Props) {
return (
<Button
title={`Mark it ${title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled}
onClick={() => onMove(value)}
className="px-1.5! text-[12px]"
>
{title}
</Button>
);
}
@@ -0,0 +1,36 @@
import Row from "@moduletrace/twui/layout/Row";
import { twMerge } from "tailwind-merge";
import { TaskStatuses } from "@/src/dict/task-dict";
import TaskStatusMoveButton from "./task-status-move-button";
type Props = {
/**
* The task's status now; its own button is left out.
*/
status?: string | null;
disabled?: boolean;
onMove: (status: string) => void;
className?: string;
};
const MoveStatuses = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
/**
* The buttons that move a task along (To do, Doing, Done), the one it's
* at left out. Shown to crew managers on a list row and in the task modal.
*/
export default function TaskStatusMoves({ status, disabled, onMove, className }: Props) {
return (
<Row className={twMerge("gap-1! flex-nowrap items-center", className)}>
{MoveStatuses.filter((it) => it.value != status).map((it) => (
<TaskStatusMoveButton
key={it.value}
title={it.title}
value={it.value}
disabled={disabled}
onMove={onMove}
/>
))}
</Row>
);
}