# NousResearch/hermes-agent · Kanban dashboard batch actions quality-of-life upgrades

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #23550. Verify it addresses your case.
- **Recommended:** read PR #23550 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-23550`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #23239: feat(kanban): dashboard batch QOL upgrade — multi-select, drag, expanded bulk actions, search

## Prior & competing PRs

- **#23550: feat(kanban): dashboard batch QOL upgrade (salvage of #23240)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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.py`
  - fix hunks:
  - `plugins/kanban/dashboard/dist/index.js`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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;
    ```

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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;
    ```

    ```diff
    @@ -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" },
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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`, {
    ```

    ```diff
    @@ -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); }, []),
    ```

    ```diff
    @@ -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")),
         );
       }
     
    ```

    ```diff
    @@ -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" },
    ```

    ```diff
    @@ -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, {
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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();
    ```

    ```diff
    @@ -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 () {
    ```

    ```diff
    @@ -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),
    ```

    ```diff
    @@ -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,
                         });
                       }),
    ```

    ```diff
    @@ -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,
                     });
                   }),
    ```

    ```diff
    @@ -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);
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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;
    ```

    ```diff
    @@ -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 ------------------------------------------------ */
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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().
     
    ```

    ```diff
    @@ -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
    ```
- **#23240: feat(kanban): dashboard batch QOL upgrade**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `518d37f6a`, `0ea234e09`, `98c499b23`, `a88f201cd`, `69053832e`, `3df7e3024`, `b60462a20`, `#23550`

## Fix status

- Merged to main in PR #23550 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-11`.
