diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index f0b31b06e1d..b0a076480ac 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3368,6 +3368,29 @@ def delete_archived_task(conn: sqlite3.Connection, task_id: str) -> bool: return cur.rowcount == 1 +def delete_task(conn: sqlite3.Connection, task_id: str) -> bool: + """Hard-delete a task and cascade to all related rows. + + Because the schema does not use ``ON DELETE CASCADE`` foreign keys, + we explicitly delete from child tables first, then the task row. + This keeps the operation atomic (single ``write_txn``). + + Returns ``True`` if the task existed and was deleted, ``False`` + if the task was not found. + """ + with write_txn(conn): + cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + if cur.rowcount != 1: + return False + conn.execute("DELETE FROM task_links WHERE parent_id = ? OR child_id = ?", (task_id, task_id)) + conn.execute("DELETE FROM task_comments WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM task_runs WHERE task_id = ?", (task_id,)) + conn.execute("DELETE FROM kanban_notify_subs WHERE task_id = ?", (task_id,)) + recompute_ready(conn) + return True + + # --------------------------------------------------------------------------- # Workspace resolution # --------------------------------------------------------------------------- diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 803d45c2a4f..04810d629f7 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -83,6 +83,12 @@ completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids", suspected_hallucinated_references: "⚠ Prose referenced phantom card ids", }; + const FALLBACK_TRASH = { + label: "Trash", + title: "Drag a card here to permanently delete it", + confirm: "Permanently delete this task? This cannot be undone.", + dropHint: "Drop to delete", + }; const DIAGNOSTIC_EVENT_KIND_KEYS = { completion_blocked_hallucination: "completionBlockedHallucination", suspected_hallucinated_references: "suspectedHallucinatedReferences", @@ -331,10 +337,12 @@ const under = document.elementFromPoint(ev.clientX, ev.clientY); proxy.style.display = ""; const col = under && under.closest && under.closest("[data-kanban-column]"); - if (col !== lastTarget) { + const trash = under && under.closest && under.closest("[data-kanban-trash]"); + const target = col || trash; + if (target !== lastTarget) { if (lastTarget) lastTarget.classList.remove("hermes-kanban-column--drop"); - if (col) col.classList.add("hermes-kanban-column--drop"); - lastTarget = col; + if (target) target.classList.add("hermes-kanban-column--drop"); + lastTarget = target; } } function up() { @@ -344,10 +352,18 @@ if (lastTarget) { lastTarget.classList.remove("hermes-kanban-column--drop"); const status = lastTarget.getAttribute("data-kanban-column"); - lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", { - detail: { taskId, status }, - bubbles: true, - })); + const isTrash = lastTarget.hasAttribute("data-kanban-trash"); + if (isTrash) { + lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:delete", { + detail: { taskId }, + bubbles: true, + })); + } else if (status) { + lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", { + detail: { taskId, status }, + bubbles: true, + })); + } } proxy.remove(); } @@ -878,6 +894,32 @@ }); }, [board, loadBoardList, switchBoard]); + const deleteTask = useCallback(function (taskId) { + if (!window.confirm(tx(t, "trash.confirm", FALLBACK_TRASH.confirm))) return Promise.resolve(); + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { + method: "DELETE", + }).then(function () { + loadBoard(); + setSelectedIds(function (prev) { + const next = new Set(prev); + next.delete(taskId); + return next; + }); + }).catch(function (e) { setError(String(e.message || e)); }); + }, [board, loadBoard, t]); + + const deleteSelected = useCallback(function (count) { + if (selectedIds.size === 0) return Promise.resolve(); + if (!window.confirm(tx(t, "trash.confirmMany", "Permanently delete {n} selected tasks? This cannot be undone.", { n: count }))) return Promise.resolve(); + const ids = Array.from(selectedIds); + setSelectedIds(new Set()); + return Promise.all(ids.map(function (id) { + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(id)}`, { method: "DELETE" }); + })).then(function () { + loadBoard(); + }).catch(function (e) { setError(String(e.message || e)); }); + }, [selectedIds, board, loadBoard, t]); + // --- render ------------------------------------------------------------- if (loading && !boardData) { return h("div", { className: "p-8 text-sm text-muted-foreground" }, @@ -932,13 +974,14 @@ }, onRefresh: loadBoard, }), - selectedIds.size > 0 ? h(BulkActionBar, { - count: selectedIds.size, - assignees: (boardData && boardData.assignees) || [], - onApply: applyBulk, - onClear: clearSelected, - onSelectAllVisible: selectAllVisible, - }) : null, + selectedIds.size > 0 ? h(BulkActionBar, { + count: selectedIds.size, + assignees: (boardData && boardData.assignees) || [], + onApply: applyBulk, + onClear: clearSelected, + onSelectAllVisible: selectAllVisible, + onDelete: deleteSelected, + }) : null, error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, h(BoardColumns, { board: filteredBoard, @@ -953,6 +996,7 @@ selectAllInColumn, onMove: moveTask, onMoveSelected: moveSelected, + onDelete: deleteTask, onOpen: setSelectedTaskId, onCreate: createTask, allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), @@ -2009,6 +2053,14 @@ size: "sm", title: "Archive selected tasks. They disappear from the default board view but remain in the database.", }, tx(t, "archive", "Archive")), + h(Button, { + onClick: function () { + props.onDelete(props.count); + }, + size: "sm", + variant: "destructive", + title: "Permanently delete selected tasks. This cannot be undone.", + }, tx(t, "delete", "Delete")), h("div", { className: "hermes-kanban-bulk-priority", title: "Set priority on selected tasks. Higher = claimed first." }, h(Input, { @@ -2073,6 +2125,65 @@ ); } + // ------------------------------------------------------------------------- + // Trash Drop Zone + // ------------------------------------------------------------------------- + + function TrashDropZone(props) { + const { t } = useI18n(); + const [dragOver, setDragOver] = useState(false); + const zoneRef = useRef(null); + + useEffect(function () { + if (!zoneRef.current) return undefined; + const el = zoneRef.current; + function onTouchDelete(e) { + const taskId = e.detail && e.detail.taskId; + if (taskId && props.onDelete) props.onDelete(taskId); + } + el.addEventListener("hermes-kanban:delete", onTouchDelete); + return function () { el.removeEventListener("hermes-kanban:delete", onTouchDelete); }; + }, [props.onDelete]); + + const handleDragOver = function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (!dragOver) setDragOver(true); + }; + const handleDragLeave = function () { setDragOver(false); }; + const handleDrop = function (e) { + e.preventDefault(); + setDragOver(false); + const taskId = e.dataTransfer.getData(MIME_TASK); + if (!taskId) return; + if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1) { + if (window.confirm(tx(t, "trash.confirmMany", "Permanently delete {n} selected tasks? This cannot be undone.", { n: props.selectedIds.size }))) { + const ids = Array.from(props.selectedIds); + Promise.all(ids.map(function (id) { return props.onDelete(id); })).catch(function () {}); + } + } else { + props.onDelete(taskId); + } + }; + + return h("div", { + ref: zoneRef, + "data-kanban-trash": "true", + className: cn( + "hermes-kanban-trash", + dragOver ? "hermes-kanban-trash--drop" : "", + props.draggingTaskId ? "hermes-kanban-trash--active" : "", + ), + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + h("span", { className: "hermes-kanban-trash-icon" }, "🗑️"), + h("span", { className: "hermes-kanban-trash-label" }, + tx(t, "trash.dropHint", FALLBACK_TRASH.dropHint)), + ); + } + // ------------------------------------------------------------------------- // Columns // ------------------------------------------------------------------------- @@ -2106,6 +2217,11 @@ allTasks: props.allTasks, }); }), + h(TrashDropZone, { + draggingTaskId: props.draggingTaskId, + selectedIds: props.selectedIds, + onDelete: props.onDelete, + }), ); } diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 405203784a6..052fa4622c5 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -1498,3 +1498,44 @@ font-size: 0.7rem; cursor: pointer; } + +/* ---- Trash drop zone ------------------------------------------------- */ + +.hermes-kanban-trash { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.75rem 0.5rem; + border: 2px dashed var(--color-border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--color-card) 85%, transparent); + color: var(--color-muted-foreground); + font-size: 0.75rem; + min-height: 80px; + opacity: 0.5; + transition: opacity 120ms ease, border-color 120ms ease, background-color 120ms ease; + user-select: none; + pointer-events: none; +} + +.hermes-kanban-trash--active { + opacity: 1; + pointer-events: auto; +} + +.hermes-kanban-trash--drop { + border-color: var(--color-destructive, #d14a4a); + background: color-mix(in srgb, var(--color-destructive, #d14a4a) 8%, var(--color-card)); + color: var(--color-destructive, #d14a4a); +} + +.hermes-kanban-trash-icon { + font-size: 1.25rem; + line-height: 1; +} + +.hermes-kanban-trash-label { + font-weight: 500; +} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index bc20d823cad..52436b611ed 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -730,6 +730,23 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu conn.close() +# --------------------------------------------------------------------------- +# DELETE /tasks/:id +# --------------------------------------------------------------------------- + +@router.delete("/tasks/{task_id}") +def delete_task(task_id: str, board: Optional[str] = Query(None)): + board = _resolve_board(board) + conn = _conn(board=board) + try: + ok = kanban_db.delete_task(conn, task_id) + if not ok: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + return {"deleted": True, "task_id": task_id} + finally: + conn.close() + + def _set_status_direct( conn: sqlite3.Connection, task_id: str, new_status: str, ) -> bool: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index f7d069c7ddb..026d6cb4072 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -816,6 +816,34 @@ def test_list_tasks_order_by(kanban_home): except ValueError as e: assert "order_by must be one of" in str(e) +def test_delete_task_removes_task_and_cascades(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="to-delete", assignee="alice") + kb.add_comment(conn, t, "user", "comment") + kb.add_comment(conn, t, "user", "another") + assert kb.delete_task(conn, t) + assert kb.get_task(conn, t) is None + assert len(kb.list_comments(conn, t)) == 0 + assert len(kb.list_events(conn, t)) == 0 + assert len(kb.list_runs(conn, t)) == 0 + + +def test_delete_task_returns_false_for_missing_task(kanban_home): + with kb.connect() as conn: + assert not kb.delete_task(conn, "t_nonexistent") + + +def test_delete_task_cascades_links(kanban_home): + with kb.connect() as conn: + p = kb.create_task(conn, title="parent") + c = kb.create_task(conn, title="child", parents=[p]) + child = kb.get_task(conn, c) + assert child is not None and child.status == "todo" + kb.delete_task(conn, p) + assert kb.get_task(conn, p) is None + child_after = kb.get_task(conn, c) + assert child_after is not None and child_after.status == "ready" + # --------------------------------------------------------------------------- # Comments / events / worker context diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 63ef38a9924..31da8c22a7e 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -485,6 +485,33 @@ def test_patch_status_running_rejected(client): assert statuses.get(t["id"]) != "running" +# --------------------------------------------------------------------------- +# DELETE /tasks/:id +# --------------------------------------------------------------------------- + +def test_delete_task(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "to-delete"}).json()["task"] + r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}") + assert r.status_code == 200 + assert r.json()["deleted"] is True + assert r.json()["task_id"] == t["id"] + + # Gone from board + board = client.get("/api/plugins/kanban/board").json() + all_ids = [tt["id"] for col in board["columns"] for tt in col["tasks"]] + assert t["id"] not in all_ids + + # Gone from detail + r = client.get(f"/api/plugins/kanban/tasks/{t['id']}") + assert r.status_code == 404 + + +def test_delete_task_not_found(client): + r = client.delete("/api/plugins/kanban/tasks/t_nonexistent") + assert r.status_code == 404 + assert "not found" in r.json()["detail"] + + # --------------------------------------------------------------------------- # Comments + Links # ---------------------------------------------------------------------------