feat(kanban): dashboard batch QOL upgrade

- Shift-click range selection, column select-all, select-all-visible
- Multi-card drag/drop via selectedIds + /tasks/bulk
- Expanded bulk actions: todo/ready/blocked/unblock/complete/archive,
  priority setter, reassign with reclaim_first checkbox
- Partial failure card highlight (failedIds + hermes-kanban-card--failed)
- Search expanded to body, result, latest_summary, summary
- Clear filters button + reset all filters on board switch
- Accessibility: larger checkbox hit target, tabIndex/role/aria-label,
  Enter/Space/Esc keyboard handlers
- Fix temporal-dead-zone bug: move clearSelected before moveSelected
This commit is contained in:
Yi Lok Enoch Lam 2026-05-10 12:28:43 +02:00 committed by Teknium
parent 518d37f6af
commit 0ea234e093
3 changed files with 353 additions and 164 deletions

View file

@ -439,6 +439,8 @@
const [selectedTaskId, setSelectedTaskId] = useState(null);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [lastSelectedId, setLastSelectedId] = useState(null);
const [failedIds, setFailedIds] = useState(() => new Set());
// 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 +591,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.summary || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
@ -633,6 +635,55 @@
});
}, [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());
}
clearSelected();
loadBoard();
}).catch(function (err) {
setError(`Move failed: ${err.message || err}`);
setFailedIds(new Set(selectedIds));
loadBoard();
});
}, [selectedIds, loadBoard, clearSelected, board]);
const createTask = useCallback(function (body) {
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
method: "POST",
@ -659,8 +710,66 @@
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.has(toId) ? next.delete(toId) : next.add(toId);
return next;
}
const aIdx = order.indexOf(anchor);
const bIdx = order.indexOf(toId);
if (aIdx === -1 || bIdx === -1) {
next.has(toId) ? next.delete(toId) : 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;
});
}, [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 next = new Set(selectedIds);
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;
@ -679,12 +788,18 @@
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();
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));
});
}, [selectedIds, loadBoard, clearSelected, board]);
// --- board switching ----------------------------------------------------
const switchBoard = useCallback(function (nextSlug) {
@ -697,7 +812,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 +901,19 @@
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,
toggleSelected,
toggleRange,
selectAllInColumn,
onMove: moveTask,
onMoveSelected: moveSelected,
onOpen: setSelectedTaskId,
onCreate: createTask,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
@ -1500,7 +1626,17 @@
onClick: props.onRefresh,
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")),
}, "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).",
}, "Clear filters"),
);
}
@ -1511,14 +1647,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" },
@ -1534,7 +1689,26 @@
},
size: "sm",
title: "Archive selected tasks. They disappear from the default board view but remain in the database.",
}, tx(t, "archive", "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: "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",
}, "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 +1725,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 +1733,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",
@ -1580,8 +1767,12 @@
column: col,
laneByProfile: props.laneByProfile,
selectedIds: props.selectedIds,
failedIds: props.failedIds,
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,7 +1793,12 @@
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);
@ -1619,7 +1815,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 () {
@ -1649,7 +1850,19 @@
onDrop: handleDrop,
},
h("div", { className: "hermes-kanban-column-header",
title: colHelp || "" },
title: COLUMN_HELP[props.column.name] || "" },
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),
@ -1685,9 +1898,11 @@
),
lane.tasks.map(function (tk) {
return h(TaskCard, {
key: tk.id, task: tk,
selected: props.selectedIds.has(tk.id),
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
});
}),
@ -1695,9 +1910,11 @@
})
: props.column.tasks.map(function (tk) {
return h(TaskCard, {
key: tk.id, task: tk,
selected: props.selectedIds.has(tk.id),
key: t.id, task: t,
selected: props.selectedIds.has(t.id),
failed: props.failedIds && props.failedIds.has(t.id),
toggleSelected: props.toggleSelected,
toggleRange: props.toggleRange,
onOpen: props.onOpen,
});
}),
@ -1744,15 +1961,29 @@
e.dataTransfer.effectAllowed = "move";
};
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();
props.toggleSelected(t.id, e.ctrlKey || e.metaKey);
if (props.toggleRange) props.toggleRange(t.id);
return;
}
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
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);
@ -1765,23 +1996,34 @@
className: cn(
"hermes-kanban-card",
props.selected ? "hermes-kanban-card--selected" : "",
props.failed ? "hermes-kanban-card--failed" : "",
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,
h("label", {
className: "hermes-kanban-card-check-wrap",
title: "Select for bulk actions",
onClick: function (e) { e.stopPropagation(); },
title: tx(i18n, "selectForBulk", "Select for bulk actions"),
}),
},
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

View file

@ -1417,3 +1417,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;
}