Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2987,6 +2987,29 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
return True


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
# ---------------------------------------------------------------------------
Expand Down
144 changes: 130 additions & 14 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
}
Expand Down Expand Up @@ -873,6 +889,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" },
Expand Down Expand Up @@ -927,13 +969,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,
Expand All @@ -948,6 +991,7 @@
selectAllInColumn,
onMove: moveTask,
onMoveSelected: moveSelected,
onDelete: deleteTask,
onOpen: setSelectedTaskId,
onCreate: createTask,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
Expand Down Expand Up @@ -2006,6 +2050,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, {
Expand Down Expand Up @@ -2071,6 +2123,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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -2104,6 +2215,11 @@
allTasks: props.allTasks,
});
}),
h(TrashDropZone, {
draggingTaskId: props.draggingTaskId,
selectedIds: props.selectedIds,
onDelete: props.onDelete,
}),
);
}

Expand Down
41 changes: 41 additions & 0 deletions plugins/kanban/dashboard/dist/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
17 changes: 17 additions & 0 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,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:
Expand Down
29 changes: 29 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,35 @@ def test_archive_hides_from_default_list(kanban_home):
assert len(kb.list_tasks(conn, include_archived=True)) == 1


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
# ---------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,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
# ---------------------------------------------------------------------------
Expand Down