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