NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23550 feat(kanban): dashboard batch QOL upgrade (salvage of #23240)

    merged to main

    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

    Show 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.00 means 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 Lab

At a glance

Merged PRs
#23550
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
11 May 2026

For your agent

The same facts, typed.