Compare commits

..
4 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 4e20106c59 Release v0.7.4
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 c547e556ec 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]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 74da091dd4 Release v0.7.3
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
tbenandClaude Opus 5.5 a33593460b Delete a task from its list row, keep the status chip unfilled in light mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
15 changed files with 523 additions and 233 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
### Tasks (the to-do tracker) ### Tasks (the to-do tracker)
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page. `tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
### Envs ### Envs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.7.2", "version": "0.7.4",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -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 Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; 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 HandOverForm from "@/src/components/general/hand-over-form";
import TaskForm from "@/src/components/general/task-form"; import TaskForm from "@/src/components/general/task-form";
import TaskStatusChip from "@/src/components/general/task-status-chip"; 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 fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; 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 * One task opened: its status (a crew manager moves it along), who has it and where, who added it and
* opening its bytes), a box to add a note, and edit or delete. A crew * 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 * manager can file a new deliverable straight onto it, or hand an open
* one to another bot, which starts on it. * one to another bot, which starts on it.
*/ */
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) { export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
const { pageProps } = useContext(AppContext);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [handing_over, setHandingOver] = useState(false); const [handing_over, setHandingOver] = useState(false);
const [note, setNote] = useState(""); 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"> <Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<TaskStatusChip status={task.status} /> <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 ? ( {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 || "") ? ( {(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
<Button <Button
title="Hand the task to another bot" title="Hand the task to another bot"
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null} ) : null}
</Row> </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 ? ( {task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span> <Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null} ) : null}
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null} ) : null}
<Stack className="w-full gap-1! items-stretch"> <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 ? ( {deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch"> <Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => ( {[...deliverables, ...references].map((file) => (
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
</Stack> </Stack>
<Stack className="w-full gap-1! items-stretch"> <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.length ? (
task.notes.map((entry) => ( <Stack className="w-full gap-0! items-stretch">
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start"> {task.notes.map((entry) => (
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary"> <TaskNoteRow key={entry.id} note={entry} />
{entry.author == "user" ? "You" : "Bot"} ))}
</Span> </Stack>
<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> <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 Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea"; import Textarea from "@moduletrace/twui/form/Textarea";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict"; import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; 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 * blocked, what the user has to do. The workspace's tasks group and the
* conversation's tasks modal both add and edit with it. * 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 [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || ""); const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo"); const [status, setStatus] = useState<TaskStatus>(
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || ""); (task?.status as TaskStatus) || "todo",
);
const [blocked_reason, setBlockedReason] = useState(
task?.blocked_reason || "",
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -49,21 +60,22 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>( const res = await fetchApi<
"/api/admin/tasks/save-task", Record<string, unknown>,
{ APIResponseObject<TaskDetail>
method: "POST", >("/api/admin/tasks/save-task", {
body: { method: "POST",
task_id: task?.id ? Number(task.id) : undefined, body: {
bot_id: task ? undefined : bot_id || undefined, task_id: task?.id ? Number(task.id) : undefined,
workspace_id: task ? undefined : workspace_id || undefined, bot_id: task ? undefined : bot_id || undefined,
title, workspace_id: task ? undefined : workspace_id || undefined,
description, title,
status, description,
blocked_reason: status == "blocked" ? blocked_reason || null : null, status,
}, blocked_reason:
status == "blocked" ? blocked_reason || null : null,
}, },
); });
setLoading(false); setLoading(false);
@@ -77,25 +89,28 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return ( return (
<Form onSubmit={submit} className="gap-3!"> <Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch"> <Stack className="gap-6! items-stretch">
<Input <Input
title="Title"
label="Title" label="Title"
showLabel
value={title} value={title}
changeHandler={setTitle} changeHandler={setTitle}
placeholder="What needs doing?" placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength} maxLength={AppData.TaskTitleMaxLength}
disabled={loading} disabled={loading}
/> />
<Textarea <CodeEditor
label="Brief" mode="markdown"
value={description} className="h-[500px]"
changeHandler={setDescription} title="Brief"
placeholder="What to do, where, and how you'll know it's done." placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength} content={description}
rows={5} info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
disabled={loading} onChange={setDescription}
/> />
<Select <Select
title="Status"
label="Status" label="Status"
showLabel showLabel
options={TaskStatuses.map((it) => ({ options={TaskStatuses.map((it) => ({
@@ -103,11 +118,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
value: it.value, value: it.value,
default: it.value == status, default: it.value == status,
}))} }))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)} changeHandler={(value) =>
setStatus(String(value) as TaskStatus)
}
/> />
{status == "blocked" ? ( {status == "blocked" ? (
<Textarea <Textarea
title="Waiting on the user for"
label="Waiting on the user for" label="Waiting on the user for"
showLabel
value={blocked_reason} value={blocked_reason}
changeHandler={setBlockedReason} changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on." placeholder="What the user has to do before this can go on."
+26 -28
View File
@@ -1,4 +1,5 @@
import { useContext } from "react"; import { useContext } from "react";
import { twMerge } from "tailwind-merge";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
@@ -8,35 +9,41 @@ import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace } from "@/src/dict/bot-dict"; import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props"; 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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label"; 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 TaskStatusChip from "./task-status-chip";
import TaskStatusMoves from "./task-status-moves";
type Props = { type Props = {
task: TaskWithNames; 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; onOpen?: () => void;
/** /**
* Moves it to one status; only crew managers get the controls. * Moves it to one status; only crew managers get the controls.
*/ */
onStatusChange?: (status: string) => void; onStatusChange?: (status: string) => void;
/**
* Deletes it after asking; only crew managers get the button.
*/
onDelete?: () => void;
busy?: boolean; busy?: boolean;
}; };
/** /**
* One task: whose it is (a bot's stone), its title and status, where it * 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 * 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, busy }: Props) { export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled"; const done = task.status == "done" || task.status == "cancelled";
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
return ( 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"> <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">
@@ -49,18 +56,27 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Stack className="grow min-w-0 gap-0.5! items-stretch"> <Stack className="grow min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Button <Button
title={task.title || "Open the task"} title={`Open ${task.title || `task ${task.id}`}`}
type="button" type="button"
variant="ghost" variant="ghost"
color="text" color="text"
onClick={onOpen} 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 ${ className={twMerge(
done ? "text-ink-secondary! line-through" : "text-ink!" "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> <Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button> </Button>
<TaskStatusChip status={task.status} /> <TaskStatusChip status={task.status} />
{can_manage && onDelete ? (
<RowIconButton
icon={Trash2}
title={`Delete ${task.title || `task ${task.id}`}`}
disabled={busy}
onClick={onDelete}
/>
) : null}
</Row> </Row>
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate"> <Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
@@ -81,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span> <Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
) : null} ) : null}
{can_manage && onStatusChange ? ( {can_manage && onStatusChange ? (
<Row className="gap-1! flex-nowrap items-center pt-0.5"> <TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="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} ) : null}
</Stack> </Stack>
</Row> </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>
);
}
+14 -8
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict"; import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus; const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do"; const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone = /**
value == "blocked" * A `Tag` is a filled primary button by default (ink in light mode), so
? "text-ink border-ink" * the chip clears its fill and sets its own text: only the outline speaks.
: value == "doing" */
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
return ( return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}> <Tag
className={twMerge(
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
value == "blocked" && "text-ink! dark:text-ink! border-ink",
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
)}
>
{title} {title}
</Tag> </Tag>
); );
@@ -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>
);
}
+12 -6
View File
@@ -1,6 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db"; import type { TaskDetail, TaskNote } from "@/src/types";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import botFaceSql from "@/src/utils/bot-face-sql"; import botFaceSql from "@/src/utils/bot-face-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql"; import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
@@ -16,7 +15,8 @@ type Params = {
}; };
/** /**
* One task with its notes (oldest first) and files, only if the user * One task with its notes (oldest first, each with the bot that wrote it),
* its files and who added it, only if the user
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same * reaches the bot or workspace it belongs to (`taskScopesSql`, the same
* condition the lists use). * condition the lists use).
*/ */
@@ -26,10 +26,14 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
const row_res = await BunSQLite.sql<TaskDetail>({ const row_res = await BunSQLite.sql<TaskDetail>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone, sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
cb.name AS created_by_bot_name,
NULLIF(TRIM(COALESCE(cu.first_name, '') || ' ' || COALESCE(cu.last_name, '')), '') AS created_by_user_name
FROM tasks t FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id LEFT JOIN workspaces w ON w.id = t.workspace_id
LEFT JOIN bots cb ON cb.id = t.created_by_bot_id
LEFT JOIN users cu ON cu.id = t.created_by_user_id
WHERE t.id = ? AND ${access.sql} WHERE t.id = ? AND ${access.sql}
LIMIT 1`, LIMIT 1`,
values: [...face.values, Number(task_id), ...access.values], values: [...face.values, Number(task_id), ...access.values],
@@ -42,8 +46,10 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
} }
const [notes_res, files] = await Promise.all([ const [notes_res, files] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({ BunSQLite.sql<TaskNote>({
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`, sql: `SELECT n.*, nb.name AS bot_name FROM task_notes n
LEFT JOIN bots nb ON nb.id = n.bot_id
WHERE n.task_id = ? ORDER BY n.id ASC`,
values: [Number(task_id)], values: [Number(task_id)],
}), }),
grabTaskMedia({ task_id }), grabTaskMedia({ task_id }),
@@ -10,7 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types"; import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel"; import TaskDetailModal from "@/src/components/general/task-detail-modal";
import TaskForm from "@/src/components/general/task-form"; import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item"; import TaskListItem from "@/src/components/general/task-list-item";
@@ -33,13 +33,14 @@ type Props = {
* The tasks this conversation works on: a bot's own list, or in a workspace * The tasks this conversation works on: a bot's own list, or in a workspace
* the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads * the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
* then, so the page's props stay small. A crew manager adds, moves, edits * 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. * and deletes; a card opens the task in a modal of its own with everything
* about it.
*/ */
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) { export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [adding, set_adding] = useState(false); const [adding, set_adding] = useState(false);
const [open_task, set_open_task] = useState<TaskDetail | null>(null); const [open_task, set_open_task] = useState<TaskWithNames | null>(null);
const [busy, set_busy] = useState(false); const [busy, set_busy] = useState(false);
const [status, set_status] = useState<string>(); const [status, set_status] = useState<string>();
const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const can_manage = canManageCrew({ user_types: pageProps?.user_types });
@@ -80,24 +81,27 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
replace({ task: res.singleRes }); replace({ task: res.singleRes });
}; };
const show = async ({ task }: { task: TaskWithNames }) => { const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
set_busy(true);
set_status(undefined); set_status(undefined);
/** const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
* The list already has the task; the detail endpoint serves its "/api/admin/tasks/delete-task",
* notes and files. { method: "POST", body: { task_id: Number(task.id) } },
*/ );
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_busy(false);
set_status(detail?.msg || "Couldn't open the task.");
if (!res.success) {
set_status(res.msg || "That didn't work.");
return; return;
} }
set_open_task(detail.singleRes); setTasks((prev) => prev.filter((item) => item.id != task.id));
set_status("Task deleted.");
}; };
return ( return (
@@ -136,72 +140,30 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
<Stack className="w-full gap-1! items-stretch"> <Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink"> <H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
{open_task ? open_task.title || "Task" : title} {title}
</H2> </H2>
{open_task ? ( {can_manage ? (
<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 <Button
title="Add a task" title="Add a task"
type="button" type="button"
variant="outlined" variant="outlined"
color="gray" color="gray"
size="small" size="small"
onClick={() => { onClick={() => set_adding(true)}
set_adding(true);
set_open_task(null);
}}
> >
Add task Add task
</Button> </Button>
) : null} ) : null}
</Row> </Row>
{!open_task ? ( <Span className="text-[13px] leading-[18px] text-ink-secondary">
<Span className="text-[13px] leading-[18px] text-ink-secondary"> {workspace?.id
{workspace?.id ? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.` : `What ${name} is working through, and what it's waiting on.`}
: `What ${name} is working through, and what it's waiting on.`} </Span>
</Span>
) : null}
</Stack> </Stack>
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]"> <Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
{open_task ? ( {adding ? (
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_status(msg);
/**
* Handed to another bot, it's that bot's now.
*/
if (saved.bot_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 });
}}
onDeleted={() => {
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
) : adding ? (
<TaskForm <TaskForm
bot_id={Number(bot.id)} bot_id={Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null} workspace_id={workspace?.id ? Number(workspace.id) : null}
@@ -218,8 +180,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
key={task.id} key={task.id}
task={task} task={task}
busy={busy} busy={busy}
onOpen={() => show({ task }).catch(() => {})} onOpen={() => set_open_task(task)}
onStatusChange={(next) => move({ task, next })} onStatusChange={(next) => move({ task, next })}
onDelete={() => remove({ task }).catch(() => {})}
/> />
)) ))
) : ( ) : (
@@ -234,6 +197,31 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
{status} {status}
</Span> </Span>
) : null} ) : null}
<TaskDetailModal
task={open_task}
can_manage={can_manage}
onClose={() => set_open_task(null)}
onChanged={({ task: saved, msg }) => {
set_status(msg);
/**
* Handed to another bot, it's that bot's now.
*/
if (saved.bot_id && saved.bot_id != bot.id) {
setTasks((prev) => prev.filter((item) => item.id != saved.id));
set_open_task(null);
return;
}
replace({ task: saved });
}}
onDeleted={({ task_id }) => {
setTasks((prev) => prev.filter((item) => item.id != task_id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
</Modal> </Modal>
); );
} }
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import type { TaskDetail, TaskWithNames } from "@/src/types"; import type { TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel"; import TaskDetailModal from "@/src/components/general/task-detail-modal";
import TaskForm from "@/src/components/general/task-form"; import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item"; import TaskListItem from "@/src/components/general/task-list-item";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell"; import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
@@ -35,7 +35,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>(); const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [open_task, setOpenTask] = useState<TaskDetail | null>(null); const [open_task, setOpenTask] = useState<TaskWithNames | null>(null);
const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const replace = ({ task }: { task: TaskWithNames }) => const replace = ({ task }: { task: TaskWithNames }) =>
@@ -62,20 +62,27 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
replace({ task: res.singleRes }); replace({ task: res.singleRes });
}; };
const show = async ({ task }: { task: TaskWithNames }) => { const remove = async ({ task }: { task: TaskWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
setBusy(true);
setStatus(undefined); setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>( const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
`/api/admin/tasks/get-task?task_id=${task.id}`, "/api/admin/tasks/delete-task",
{ method: "GET" }, { method: "POST", body: { task_id: Number(task.id) } },
).catch(() => null); );
if (!res?.success || !res.singleRes) { setBusy(false);
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
if (!res.success) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return; return;
} }
setOpenTask(res.singleRes); setItems((prev) => prev.filter((item) => item.id != task.id));
setStatus({ success: true, msg: "Task deleted." });
}; };
return ( return (
@@ -85,70 +92,48 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet." description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
> >
<Stack className="w-full gap-2! items-stretch"> <Stack className="w-full gap-2! items-stretch">
{open_task ? ( {can_manage ? (
<Stack className="w-full gap-3! items-stretch"> <Row className="justify-end">
<Row className="w-full gap-2! flex-nowrap items-center"> <Button
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate"> title="Add a task"
{open_task.title || "Task"} type="button"
</Span> variant="outlined"
<Button color="gray"
title="Back to the list" size="small"
type="button" onClick={() => setAdding(true)}
variant="ghost" >
color="gray" Add task
size="small" </Button>
onClick={() => setOpenTask(null)} </Row>
className="px-2! text-[12px]" ) : null}
> <SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
Back {items.map((task) => (
</Button> <TaskListItem
</Row> key={task.id}
<TaskDetailPanel task={task}
task={open_task} busy={busy}
can_manage={can_manage} onOpen={() => setOpenTask(task)}
onChanged={(saved, msg) => { onStatusChange={(next) => move({ task, next })}
setOpenTask(saved); onDelete={() => remove({ task }).catch(() => {})}
replace({ task: saved });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={() => {
setItems((prev) => prev.filter((item) => item.id != open_task.id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
/> />
</Stack> ))}
) : ( </SettingsList>
<>
{can_manage ? (
<Row className="justify-end">
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
>
Add task
</Button>
</Row>
) : null}
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
{items.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))}
</SettingsList>
</>
)}
<SettingsStatus status={status} /> <SettingsStatus status={status} />
</Stack> </Stack>
<TaskDetailModal
task={open_task}
can_manage={can_manage}
onClose={() => setOpenTask(null)}
onChanged={({ task, msg }) => {
replace({ task });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={({ task_id }) => {
setItems((prev) => prev.filter((item) => item.id != task_id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
/>
<AddModalShell <AddModalShell
open={adding} open={adding}
setOpen={setAdding} setOpen={setAdding}
+12 -3
View File
@@ -236,11 +236,20 @@ export type TaskFile = BUN_SQLITE_COBBLE_TASK_MEDIA & {
}; };
/** /**
* A task with its notes and files: what the task modal and the workspace's * A note on a task, with the name of the bot that wrote it.
* tasks group render. */
export type TaskNote = BUN_SQLITE_COBBLE_TASK_NOTES & {
bot_name?: string | null;
};
/**
* A task with its notes and files and who added it: what the task modal
* renders.
*/ */
export type TaskDetail = TaskWithNames & { export type TaskDetail = TaskWithNames & {
notes: BUN_SQLITE_COBBLE_TASK_NOTES[]; created_by_bot_name?: string | null;
created_by_user_name?: string | null;
notes: TaskNote[];
files: TaskFile[]; files: TaskFile[];
}; };