diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 913ff8fe932..d4d5df528c4 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -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 diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 1179e80d934..fdf0c5b9e42 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -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; +} diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index be57fb549e9..3a23e9804c9 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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 ```` 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
/ for zero-JS collapse. - - Native
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
/ , not plain
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 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