mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
518d37f6af
commit
0ea234e093
3 changed files with 353 additions and 164 deletions
292
plugins/kanban/dashboard/dist/index.js
vendored
292
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
plugins/kanban/dashboard/dist/style.css
vendored
48
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1739,157 +1739,56 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
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_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()
|
||||
|
||||
# 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 "t.body || \"\"" in dist
|
||||
assert "t.result || \"\"" in dist
|
||||
assert "t.latest_summary || \"\"" in dist
|
||||
assert "t.summary || \"\"" 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_bulk_actions_include_reclaim_first():
|
||||
"""Bulk action bar must expose reclaim_first checkbox and expanded status buttons."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
style = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text()
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").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."
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _dashboard_dist_path(name: str) -> Path:
|
||||
def test_dashboard_shift_click_range_selection_exists():
|
||||
"""Shift-click must trigger range selection via toggleRange."""
|
||||
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
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
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 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_dashboard_multi_move_bulk_exists():
|
||||
"""Dragging a selected card with other selections must use /tasks/bulk."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
|
||||
assert "onMoveSelected" in dist
|
||||
assert "props.onMoveSelected" in dist
|
||||
assert "`${API}/tasks/bulk`" in dist
|
||||
|
||||
|
||||
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_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()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
|
||||
def test_run_metadata_disclosure_indicator_styled():
|
||||
"""Native disclosure marker is hidden + replaced with a CSS-only chevron.
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue