Kanban dashboard batch actions quality-of-life upgrades
Cluster 23239. Data as of
Nothing open on GitHub. Fix merged in #23550 on 11 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #23550. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #23550. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple related pull requests proposing or attempting to land the same set of kanban dashboard improvements, including multi-select, drag support, expanded bulk actions, and search. The repetition suggests iteration or salvage efforts around a single feature addition.
Open issues0
Open PRs0
Fixes on record
-
#23550 feat(kanban): dashboard batch QOL upgrade (salvage of #23240)
files:
plugins/kanban/dashboard/dist/index.js,plugins/kanban/dashboard/dist/style.css,plugins/kanban/dashboard/plugin_api.py,scripts/release.py,tests/plugins/test_kanban_dashboard_plugin.pyShow the diff
-
plugins/kanban/dashboard/dist/index.js@@ -439,6 +439,11 @@ const [selectedTaskId, setSelectedTaskId] = useState(null); const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [lastSelectedId, setLastSelectedId] = useState(null); + const [failedIds, setFailedIds] = useState(() => new Set()); + const [draggingTaskId, setDraggingTaskId] = useState(null); + const handleDragStart = useCallback(function (taskId) { setDraggingTaskId(taskId); }, []); + const handleDragEnd = useCallback(function () { setDraggingTaskId(null); }, []); // Per-task event counter incremented whenever the WS stream reports // a new event for that task id. TaskDrawer useEffect-depends on its // own task's counter so it reloads itself on live events instead of @@ -589,7 +594,7 @@ if (tenantFilter && t.tenant !== tenantFilter) return false; if (assigneeFilter && t.assignee !== assigneeFilter) return false; if (q) { - const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); + const hay = `${t.id} ${t.title || ""} ${t.body || ""} ${t.result || ""} ${t.latest_summary || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); if (hay.indexOf(q) === -1) return false; } return true; @@ -633,6 +638,56 @@ }); }, [loadBoard, board, t]); + const clearSelected = useCallback(function () { + setSelectedIds(new Set()); + setLastSelectedId(null); + setFailedIds(new Set()); + }, []); + const moveSelected = useCallback(function (newStatus) { + const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; + if (confirmMsg && !window.confirm(confirmMsg)) return; + if (selectedIds.size === 0) return; + const patch = withCompletionSummary({ status: newStatus }, selectedIds.size); + if (!patch) return; + const ids = Array.from(selectedIds); + // Optimistic UI: remove selected from all columns and prepend to target. + setBoardData(function (b) { + if (!b) return b; + const moved = []; + const columns = b.columns.map(function (col) { + const kept = []; + for (const t of col.tasks) { + if (selectedIds.has(t.id)) moved.push(Object.assign({}, t, { status: newStatus })); + else kept.push(t); + } + return Object.assign({}, col, { tasks: kept }); + }); + const dest = columns.find(function (c) { return c.name === newStatus; }); + if (dest) dest.tasks = moved.concat(dest.tasks); + return Object.assign({}, b, { columns }); + }); + SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(Object.assign({ ids }, patch)), + }).then(function (res) { + const failed = (res.results || []).filter(function (r) { return !r.ok; }); + if (failed.length > 0) { + setError(`Bulk move: ${failed.length} of ${res.results.length} failed`); + setFailedIds(new Set(failed.map(function (f) { return f.id; }))); + } else { + setFailedIds(new Set()); + } + setSelectedIds(new Set()); + setLastSelectedId(null); + loadBoard(); + }).catch(function (err) { + setError(`Move failed: ${err.message || err}`); + setFailedIds(new Set(selectedIds)); + loadBoard(); + }); + }, [selectedIds, loadBoard, board]); + const createTask = useCallback(function (body) { return SDK.fetchJSON(withBoard(`${API}/tasks`, board), { method: "POST", @@ -659,8 +714,72 @@ else next.add(id); return next; }); + setLastSelectedId(id); + setFailedIds(function (prev) { + if (prev.has(id)) { + const next = new Set(prev); + next.delete(id); + return next; + } + return prev; + }); }, []); - const clearSelected = useCallback(function () { setSelectedIds(new Set()); }, []); + + const toggleRange = useCallback(function (toId) { + // Build flat visible task order from filteredBoard columns. + setSelectedIds(function (prev) { + const next = new Set(prev); + if (!filteredBoard || !filteredBoard.columns) return next; + const order = []; + for (const col of filteredBoard.columns) { + for (const t of col.tasks || []) order.push(t.id); + } + const anchor = lastSelectedId; + if (!anchor || anchor === toId) { + next.add(toId); + return next; + } + const aIdx = order.indexOf(anchor); + const bIdx = order.indexOf(toId); + if (aIdx === -1 || bIdx === -1) { + next.add(toId); + return next; + } + const lo = Math.min(aIdx, bIdx); + const hi = Math.max(aIdx, bIdx); + for (let i = lo; i <= hi; i++) next.add(order[i]); + return next; + }); + setLastSelectedId(toId); + }, [filteredBoard, lastSelectedId]); + + const selectAllVisible = useCallback(function () { + if (!filteredBoard || !filteredBoard.columns) return; + const next = new Set(); + for (const col of filteredBoard.columns) { + for (const t of col.tasks || []) next.add(t.id); + } + setSelectedIds(next); + if (next.size > 0) { + const first = Array.from(next)[0]; + setLastSelectedId(first); + } + }, [filteredBoard]); + + const selectAllInColumn = useCallback(function (columnName) { + if (!filteredBoard || !filteredBoard.columns) return; + const col = filteredBoard.columns.find(function (c) { return c.name === columnName; }); + if (!col) return; + const allSelected = col.tasks && col.tasks.length > 0 && col.tasks.every(function (t) { return selectedIds.has(t.id); }); + const next = new Set(selectedIds); + if (allSelected) { + for (const t of col.tasks || []) next.delete(t.id); + } else { + for (const t of col.tasks || []) next.add(t.id); + } + setSelectedIds(next); + if (col.tasks && col.tasks.length > 0) setLastSelectedId(col.tasks[0].id); + }, [filteredBoard, selectedIds]); const applyBulk = useCallback(function (patch, confirmMsg) { if (selectedIds.size === 0) return; @@ -668,6 +787,24 @@ const finalPatch = withCompletionSummary(patch, selectedIds.size, t); if (!finalPatch) return; const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch); + // Optimistic UI for status moves (same pattern as moveSelected). + if (finalPatch.status) { + setBoardData(function (b) { + if (!b) return b; + const moved = []; + const columns = b.columns.map(function (col) { + const kept = []; + for (const t of col.tasks) { + if (selectedIds.has(t.id)) moved.push(Object.assign({}, t, { status: finalPatch.status })); + else kept.push(t); + } + return Object.assign({}, col, { tasks: kept }); + }); + const dest = columns.find(function (c) { return c.name === finalPatch.status; }); + if (dest) dest.tasks = moved.concat(dest.tasks); + return Object.assign({}, b, { columns }); + }); + } SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -679,12 +816,20 @@ setError(tx(t, "bulkFailed", "Bulk: ") + `${failed.length} of ${res.results.length} failed: ` + failed.slice(0, 3).map(function (f) { return `${f.id} (${f.error})`; }).join("; ")); + setFailedIds(new Set(failed.map(function (f) { return f.id; }))); + } else { + setFailedIds(new Set()); } - clearSelected(); + setSelectedIds(new Set()); + setLastSelectedId(null); loadBoard(); }) - .catch(function (e) { setError(String(e.message || e)); }); - }, [selectedIds, loadBoard, clearSelected, board, t]); + .catch(function (e) { + setError(String(e.message || e)); + setFailedIds(new Set(selectedIds)); + loadBoard(); + }); + }, [selectedIds, loadBoard, board, t]); // --- board switching ---------------------------------------------------- const switchBoard = useCallback(function (nextSlug) { @@ -697,7 +842,13 @@ setLoading(true); setBoard(nextSlug); writeSelectedBoard(nextSlug); - }, [board]); + // Reset filters so stale search/tenant/assignee don't persist across boards. + setSearch(""); + setTenantFilter(""); + setAssigneeFilter(""); + setIncludeArchived(false); + clearSelected(); + }, [board, clearSelected]); const createNewBoard = useCallback(function (payload) { return SDK.fetchJSON(`${API}/boards`, { @@ -780,14 +931,22 @@ assignees: (boardData && boardData.assignees) || [], onApply: applyBulk, onClear: clearSelected, + onSelectAllVisible: selectAllVisible, }) : null, error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, h(BoardColumns, { board: filteredBoard, laneByProfile, selectedIds, + failedIds, + draggingTaskId, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, toggleSelected, + toggleRange, + selectAllInColumn, onMove: moveTask, + onMoveSelected: moveSelected, onOpen: setSelectedTaskId, onCreate: createTask, allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), @@ -1501,6 +1660,16 @@ size: "sm", title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.", }, tx(t, "refresh", "Refresh")), + h(Button, { + onClick: function () { + props.setSearch(""); + props.setTenantFilter(""); + props.setAssigneeFilter(""); + props.setIncludeArchived(false); + }, + size: "sm", + title: "Clear all active filters (search, tenant, assignee, archived).", + }, tx(t, "clearFilters", "Clear filters")), ); } @@ -1511,14 +1680,33 @@ function BulkActionBar(props) { const { t } = useI18n(); const [assignee, setAssignee] = useState(""); + const [reclaimFirst, setReclaimFirst] = useState(false); + const [priority, setPriority] = useState(""); return h("div", { className: "hermes-kanban-bulk" }, h("span", { className: "hermes-kanban-bulk-count" }, `${props.count} ${tx(t, "selected", "selected")}`), + h(Button, { + onClick: function () { props.onApply({ status: "todo" }); }, + size: "sm", + title: "Move selected tasks to Todo.", + }, "→ todo"), h(Button, { onClick: function () { props.onApply({ status: "ready" }); }, size: "sm", title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.", }, "→ ready"), + h(Button, { + onClick: function () { props.onApply({ status: "blocked" }, + `Block ${props.count} task(s)?`); }, + size: "sm", + title: "Block selected tasks. Releases any active claims.", + }, "Block"), + h(Button, { + onClick: function () { props.onApply({ status: "ready" }, + `Unblock ${props.count} task(s)?`); }, + size: "sm", + title: "Unblock selected tasks (promote to Ready).", + }, "Unblock"), h(Button, { onClick: function () { props.onApply({ status: "done" }, @@ -1535,6 +1723,25 @@ size: "sm", title: "Archive selected tasks. They disappear from the default board view but remain in the database.", }, tx(t, "archive", "Archive")), + h("div", { className: "hermes-kanban-bulk-priority", + title: "Set priority on selected tasks. Higher = claimed first." }, + h(Input, { + type: "number", + value: priority, + onChange: function (e) { setPriority(e.target.value); }, + placeholder: tx(t, "priority", "pri"), + className: "h-7 text-xs w-16", + }), + h(Button, { + onClick: function () { + if (priority === "") return; + props.onApply({ priority: Number(priority) }); + setPriority(""); + }, + disabled: priority === "", + size: "sm", + }, tx(t, "setPriority", "Set priority")), + ), h("div", { className: "hermes-kanban-bulk-reassign", title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." }, h(Select, { @@ -1551,7 +1758,7 @@ h(Button, { onClick: function () { if (!assignee) return; - props.onApply({ assignee: assignee === "__none__" ? "" : assignee }); + props.onApply({ assignee: assignee === "__none__" ? "" : assignee, reclaim_first: reclaimFirst }); setAssignee(""); }, disabled: !assignee, @@ -1559,7 +1766,20 @@ title: "Apply the selected assignee to all selected tasks.", }, tx(t, "apply", "Apply")), ), + h("label", { className: "hermes-kanban-bulk-reclaim-first", title: "Reclaim any active claims before reassigning" }, + h("input", { + type: "checkbox", + checked: reclaimFirst, + onChange: function (e) { setReclaimFirst(e.target.checked); }, + }), + "Reclaim first", + ), h("div", { className: "flex-1" }), + h(Button, { + onClick: props.onSelectAllVisible, + size: "sm", + title: "Select all visible cards across columns.", + }, "Select all visible"), h(Button, { onClick: props.onClear, size: "sm", @@ -1573,15 +1793,29 @@ // ------------------------------------------------------------------------- function BoardColumns(props) { - return h("div", { className: "hermes-kanban-columns" }, + const handleDragStart = useCallback(function (e) { + const card = e.target.closest && e.target.closest(".hermes-kanban-card"); + if (!card) return; + const taskId = card.getAttribute("data-task-id"); + if (taskId && props.onDragStart) props.onDragStart(taskId); + }, [props.onDragStart]); + const handleDragEnd = useCallback(function () { + if (props.onDragEnd) props.onDragEnd(); + }, [props.onDragEnd]); + return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd }, props.board.columns.map(function (col) { return h(Column, { key: col.name, column: col, laneByProfile: props.laneByProfile, selectedIds: props.selectedIds, + failedIds: props.failedIds, + draggingTaskId: props.draggingTaskId, toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, + selectAllInColumn: props.selectAllInColumn, onMove: props.onMove, + onMoveSelected: props.onMoveSelected, onOpen: props.onOpen, onCreate: props.onCreate, allTasks: props.allTasks, @@ -1602,12 +1836,17 @@ const el = colRef.current; function onTouchDrop(e) { if (e.detail && e.detail.status === props.column.name) { - props.onMove(e.detail.taskId, props.column.name); + const taskId = e.detail.taskId; + if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1 && props.onMoveSelected) { + props.onMoveSelected(props.column.name); + } else { + props.onMove(taskId, props.column.name); + } } } el.addEventListener("hermes-kanban:drop", onTouchDrop); return function () { el.removeEventListener("hermes-kanban:drop", onTouchDrop); }; - }, [props.column.name, props.onMove]); + }, [props.column.name, props.onMove, props.selectedIds, props.onMoveSelected]); const handleDragOver = function (e) { e.preventDefault(); @@ -1619,7 +1858,12 @@ e.preventDefault(); setDragOver(false); const taskId = e.dataTransfer.getData(MIME_TASK); - if (taskId) props.onMove(taskId, props.column.name); + if (!taskId) return; + if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1) { + if (props.onMoveSelected) props.onMoveSelected(props.column.name); + } else { + props.onMove(taskId, props.column.name); + } }; const lanes = useMemo(function () { @@ -1650,6 +1894,18 @@ }, h("div", { className: "hermes-kanban-column-header", title: colHelp || "" }, + h("input", { + type: "checkbox", + className: "hermes-kanban-col-check", + title: "Select all tasks in this column", + "aria-label": `Select all tasks in ${COLUMN_LABEL[props.column.name] || props.column.name}`, + checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }), + onChange: function (e) { + e.stopPropagation(); + if (props.selectAllInColumn) props.selectAllInColumn(props.column.name); + }, + onClick: function (e) { e.stopPropagation(); }, + }), h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }), h("span", { className: "hermes-kanban-column-label" }, colLabel || props.column.name), @@ -1687,7 +1943,11 @@ return h(TaskCard, { key: tk.id, task: tk, selected: props.selectedIds.has(tk.id), + failed: props.failedIds && props.failedIds.has(tk.id), + draggingTaskId: props.draggingTaskId, + draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(tk.id), toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, onOpen: props.onOpen, }); }), @@ -1697,7 +1957,11 @@ return h(TaskCard, { key: tk.id, task: tk, selected: props.selectedIds.has(tk.id), + failed: props.failedIds && props.failedIds.has(tk.id), + draggingTaskId: props.draggingTaskId, + draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(tk.id), toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, onOpen: props.onOpen, }); }), @@ -1742,17 +2006,42 @@ const handleDragStart = function (e) { e.dataTransfer.setData(MIME_TASK, t.id); e.dataTransfer.effectAllowed = "move"; + const selectedCards = document.querySelectorAll(".hermes-kanban-card--selected"); + if (selectedCards.length > 1 && props.selected) { + const ghost = document.createElement("div"); + ghost.className = "hermes-kanban-drag-ghost"; + ghost.textContent = selectedCards.length + " cards"; + document.body.appendChild(ghost); + e.dataTransfer.setDragImage(ghost, 0, 0); + requestAnimationFrame(function () { + if (ghost.parentNode) document.body.removeChild(ghost); + }); + } }; const handleClick = function (e) { - // Shift-click or ctrl/cmd-click toggles selection instead of opening. - if (e.shiftKey || e.ctrlKey || e.metaKey) { + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + if (props.toggleRange) props.toggleRange(t.id); + return; + } + if (e.ctrlKey || e.metaKey) { e.preventDefault(); e.stopPropagation(); - props.toggleSelected(t.id, e.ctrlKey || e.metaKey); + props.toggleSelected(t.id, true); return; } props.onOpen(t.id); }; + const handleKeyDown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + props.onOpen(t.id); + } + if (e.key === "Escape") { + if (props.toggleSelected) props.toggleSelected(t.id, false); + } + }; const handleCheckbox = function (e) { e.stopPropagation(); props.toggleSelected(t.id, true); @@ -1762,26 +2051,39 @@ return h("div", { ref: cardRef, + "data-task-id": t.id, className: cn( "hermes-kanban-card", props.selected ? "hermes-kanban-card--selected" : "", + props.failed ? "hermes-kanban-card--failed" : "", + props.draggingSource ? "hermes-kanban-card--dragging-source" : "", stalenessClass(t), ), draggable: true, + tabIndex: 0, + role: "button", + "aria-label": `${t.title || "untitled"} — ${t.id} — ${t.status}`, onDragStart: handleDragStart, onClick: handleClick, + onKeyDown: handleKeyDown, }, h(Card, null, h(CardContent, { className: "hermes-kanban-card-content" }, h("div", { className: "hermes-kanban-card-row" }, - h("input", { - type: "checkbox", - className: "hermes-kanban-card-check", - checked: props.selected, - onChange: handleCheckbox, - onClick: function (e) { e.stopPropagation(); }, + h("label", { + className: "hermes-kanban-card-check-wrap", title: tx(i18n, "selectForBulk", "Select for bulk actions"), - }), + onClick: function (e) { e.stopPropagation(); }, + }, + h("input", { + type: "checkbox", + className: "hermes-kanban-card-check", + checked: props.selected, + onChange: handleCheckbox, + onClick: function (e) { e.stopPropagation(); }, + "aria-label": `Select task ${t.id}`, + }), + ), h("span", { className: "hermes-kanban-card-id", title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id), t.warnings && t.warnings.count > 0 -
plugins/kanban/dashboard/dist/style.css@@ -538,6 +538,15 @@ background: color-mix(in srgb, var(--color-ring) 6%, var(--color-card)); } +/* Batch drag source styling — cards that are part of the current multi-drag. + The browser ghost image floats; we dim the original DOM nodes so the user + sees the whole set is in-flight. */ +.hermes-kanban-card--dragging-source :where(.hermes-kanban-card-content) { + opacity: 0.45; + filter: grayscale(0.6); + transition: opacity 120ms ease, filter 120ms ease; +} + .hermes-kanban-card-check { width: 0.85rem; height: 0.85rem; @@ -776,6 +785,22 @@ transition: none; } +/* ---- Multi-drag ghost ----------------------------------------------- */ + +.hermes-kanban-drag-ghost { + position: fixed; + left: -9999px; + padding: 0.45rem 0.8rem; + background: var(--color-card); + border: 2px solid var(--color-ring); + border-radius: var(--radius); + font-size: 0.85rem; + font-weight: 600; + color: var(--color-foreground); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + pointer-events: none; + opacity: 0.95; +} /* ---- Staleness tiers ------------------------------------------------ */ @@ -1417,3 +1442,51 @@ color: #ff8b6b; border: 1px solid rgba(255, 107, 61, 0.3); } +/* ---- Partial failure highlight --------------------------------------- */ +.hermes-kanban-card--failed :where(.hermes-kanban-card-content) { + box-shadow: 0 0 0 2px var(--color-destructive, #d14a4a) inset, + 0 0 8px color-mix(in srgb, var(--color-destructive, #d14a4a) 30%, transparent); +} + +/* ---- Larger checkbox hit target -------------------------------------- */ +.hermes-kanban-card-check-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin: -0.3rem; + cursor: pointer; +} +.hermes-kanban-card-check { + width: 0.95rem; + height: 0.95rem; + margin: 0; + cursor: pointer; + accent-color: var(--color-ring); +} + +/* ---- Column select-all checkbox -------------------------------------- */ +.hermes-kanban-col-check { + width: 0.9rem; + height: 0.9rem; + margin: 0 0.15rem 0 0; + cursor: pointer; + accent-color: var(--color-ring); +} + +/* ---- Bulk action bar extras ------------------------------------------ */ +.hermes-kanban-bulk-priority { + display: flex; + align-items: center; + gap: 0.25rem; + padding-left: 0.5rem; + border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); +} +.hermes-kanban-bulk-reclaim-first { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + cursor: pointer; +} -
plugins/kanban/dashboard/plugin_api.py@@ -823,6 +823,7 @@ class BulkTaskBody(BaseModel): result: Optional[str] = None summary: Optional[str] = None metadata: Optional[dict] = None + reclaim_first: bool = False @router.post("/tasks/bulk") @@ -877,9 +878,16 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): entry.update(ok=False, error=f"transition to {s!r} refused") if payload.assignee is not None: try: - if not kanban_db.assign_task( - conn, tid, payload.assignee or None, - ): + if payload.reclaim_first: + ok = kanban_db.reassign_task( + conn, tid, payload.assignee or None, + reclaim_first=True, + ) + else: + ok = kanban_db.assign_task( + conn, tid, payload.assignee or None, + ) + if not ok: entry.update(ok=False, error="assign refused") except RuntimeError as e: entry.update(ok=False, error=str(e)) -
scripts/release.py@@ -873,6 +873,7 @@ "[redacted:email]": "lhysdl", "[redacted:email]": "SherlockShemol", "[redacted:email]": "eloklam", + "[redacted:email]": "eloklam", "[redacted:email]": "fmercurio", "[redacted:email]": "Ricardo-M-L", "[redacted:email]": "leozeli", -
tests/plugins/test_kanban_dashboard_plugin.py@@ -534,6 +534,9 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) + monkeypatch.delenv("HERMES_KANBAN_DB", raising=False) + monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False) monkeypatch.setattr(Path, "home", lambda: tmp_path) # Deliberately DO NOT call kb.init_db(). @@ -1739,157 +1742,55 @@ def test_dashboard_requests_default_board_explicitly(): assert "}, [loadBoardList, switchBoard, board]);" in dist -def test_dashboard_assignee_inputs_preserve_casing(): - """Assignee/specifier inputs must disable browser auto-capitalization. +def test_dashboard_search_includes_body_and_result(): + """Client-side search must match body, result, latest_summary, and summary + so full card contents are findable.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() - Hermes profile names are case-sensitive — the dispatcher uses the - assignee string as a literal directory/profile lookup. Mobile browsers - (iOS/Android) and some IMEs auto-capitalize the first letter of any - text input by default, so a user typing ``analyst`` ends up submitting - ``Analyst`` and the dispatcher fails to spawn a matching profile, - leading to the crash loop reported in #21320. + assert "t.body || \"\"" in dist + assert "t.result || \"\"" in dist + assert "t.latest_summary || \"\"" in dist - The fix sets ``autoCapitalize="none"``, ``autoCorrect="off"``, - ``spellCheck=false``, and ``style={textTransform: "none"}`` on the - two assignee ``<Input>`` elements (inline triage/lane create-task - input + task-edit panel "(empty = unassign)" input). - This test pins those attributes in the compiled dist bundle so a - future rebuild that loses them fails CI immediately. - """ +def test_dashboard_bulk_actions_include_reclaim_first(): + """Bulk action bar must expose reclaim_first checkbox and expanded status buttons.""" repo_root = Path(__file__).resolve().parents[2] dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() - # Both sites should have all four attributes. Count occurrences to - # ensure both inputs got the treatment, not just one. - assert dist.count('autoCapitalize: "none"') >= 2, ( - "Expected autoCapitalize=\"none\" on both assignee inputs (inline " - "create + task-edit panel)" - ) - assert dist.count('autoCorrect: "off"') >= 2 - assert dist.count("spellCheck: false") >= 2 - assert dist.count('textTransform: "none"') >= 2 - + assert "reclaim_first: reclaimFirst" in dist + assert "hermes-kanban-bulk-reclaim-first" in dist + assert '"→ todo"' in dist + assert '"Block"' in dist + assert '"Unblock"' in dist -def test_dashboard_lane_head_preserves_assignee_casing(): - """Lane headers must not visually uppercase profile names. - The previous CSS rule ``.hermes-kanban-lane-head { text-transform: - uppercase; letter-spacing: 0.08em }`` made a valid ``analyst`` profile - appear as ``ANALYST`` in column headers; users then copied the - uppercase form back into edits, hitting the same crash loop as the - auto-capitalization path. The fix removes ``text-transform: uppercase`` - from the rule and tightens letter-spacing. - - Static-asset regression test for the rule contents. - """ +def test_dashboard_shift_click_range_selection_exists(): + """Shift-click must trigger range selection via toggleRange.""" repo_root = Path(__file__).resolve().parents[2] - style = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text() - - # Locate the .hermes-kanban-lane-head block. Use a generous slice to - # keep this resilient to nearby unrelated CSS edits. - marker = ".hermes-kanban-lane-head {" - idx = style.find(marker) - assert idx != -1, "could not locate .hermes-kanban-lane-head rule" - end = style.find("}", idx) - assert end != -1 - rule = style[idx:end] - - assert "text-transform: uppercase" not in rule, ( - "Lane head must not visually uppercase profile names — see #21320 " - "and the explanatory comment in the CSS rule." - ) + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() - -# --------------------------------------------------------------------------- -# Built-asset regressions for the dashboard's run-history rendering -# (issue #19548 — completed-run metadata used to render as a large pale box -# that read like a crash dump). The plugin ships built-only, so we lock in -# the rendered shape with static assertions on dist/index.js + dist/style.css. -# --------------------------------------------------------------------------- + assert "function toggleRange" in dist or "const toggleRange =" in dist + assert "props.toggleRange(t.id)" in dist or "props.toggleRange" in dist + assert "e.shiftKey" in dist -def _dashboard_dist_path(name: str) -> Path: +def test_dashboard_multi_move_bulk_exists(): + """Dragging a selected card with other selections must use /tasks/bulk.""" repo_root = Path(__file__).resolve().parents[2] - p = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / name - assert p.exists(), f"dashboard asset missing: {p}" - return p - - -def test_run_metadata_pretty_printed_with_label(): - """Run-history metadata is pretty-printed JSON inside a labeled sub-block.""" - js = _dashboard_dist_path("index.js").read_text(encoding="utf-8") - # Pretty-printed JSON (indent=2) so a writer task's changed_files + - # URLs blob doesn't render as one wall-of-text monoline. - assert "JSON.stringify(r.metadata, null, 2)" in js - # Explicit label so the panel reads as auxiliary detail, not a crash dump. - assert '"hermes-kanban-run-meta-label"' in js - assert '"Metadata"' in js - # Wrapped in the labelled meta block container. - assert '"hermes-kanban-run-meta-block"' in js - - -def test_run_metadata_secondary_styling(): - """Metadata block is capped, transparent, and visually secondary.""" - css = _dashboard_dist_path("style.css").read_text(encoding="utf-8") - # The label class exists with muted-foreground treatment. - assert ".hermes-kanban-run-meta-label" in css - # Container styling: thin left rule, no opaque highlighted fill that - # could be mistaken for an error/warning panel. - assert ".hermes-kanban-run-meta-block" in css - block_start = css.index(".hermes-kanban-run-meta-block {") - block_decl = css[block_start : block_start + 400] - assert "background: transparent" in block_decl - assert "border-left" in block_decl - # Cap meta height so verbose JSON doesn't sprawl across the run row. - meta_start = css.index(".hermes-kanban-run-meta {") - meta_decl = css[meta_start : meta_start + 400] - assert "max-height" in meta_decl - assert "overflow: auto" in meta_decl - assert "color: var(--color-muted-foreground)" in meta_decl - - -def test_run_metadata_uses_native_collapse(): - """Metadata panel uses <details>/<summary> for zero-JS collapse. - - Native <details> means the browser handles state — no event handlers, - no React-state coupling, accessible by default (keyboard navigable, - screen-reader announces the disclosure state). Default-open state is - decided per-render based on payload length. - """ - js = _dashboard_dist_path("index.js").read_text(encoding="utf-8") - # Element must be <details> / <summary>, not plain <div>s. - assert 'h("details"' in js - assert 'h("summary"' in js - # The open prop is computed from json length (collapsed when verbose). - assert "open: !collapsed" in js or "open:!collapsed" in js - assert "json.length > 300" in js - - -def test_run_metadata_skips_empty_object(): - """Empty `{}` metadata renders nothing — no useless labeled block. + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() - `r.metadata && {} && ...` would render a "Metadata" labeled block - containing just `{}`, which is visual noise. The render predicate now - also checks Object.keys(r.metadata).length > 0. - """ - js = _dashboard_dist_path("index.js").read_text(encoding="utf-8") - assert "Object.keys(r.metadata).length > 0" in js + assert "onMoveSelected" in dist + assert "props.onMoveSelected" in dist + assert "`${API}/tasks/bulk`" in dist -def test_run_metadata_disclosure_indicator_styled(): - """Native disclosure marker is hidden + replaced with a CSS-only chevron. +def test_dashboard_failed_card_highlight_class_exists(): + """Partial bulk failures must highlight failing cards.""" + repo_root = Path(__file__).resolve().parents[2] + js = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + css = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text() - Browsers render an OS-specific arrow next to <summary> by default. For a - consistent look across OSes the hermes dashboard hides that marker and - renders a CSS ::before chevron that rotates on [open]. Pin it so a - future CSS rebuild can't silently lose it (which would put two markers - side-by-side on Firefox/WebKit). - """ - css = _dashboard_dist_path("style.css").read_text(encoding="utf-8") - # Default markers suppressed. - assert "list-style: none" in css - assert "::-webkit-details-marker" in css - # CSS-only chevron present + animates on open state. - assert ".hermes-kanban-run-meta-block[open]" in css - assert "rotate(90deg)" in css + assert "hermes-kanban-card--failed" in js + assert "hermes-kanban-card--failed" in css + assert "failedIds" in js
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#23240 feat(kanban): dashboard batch QOL upgrade · merged via another change →
518d37f,0ea234e,98c499b,a88f201,6905383,3df7e30,b60462a,#23550
Closed issues · 1
- Closed#23239feat(kanban): dashboard batch QOL upgrade — multi-select, drag, expanded bulk actions, search
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #23550
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 11 May 2026