/** * Hermes Kanban — Dashboard Plugin * * Board view for the multi-agent collaboration board backed by * ~/.hermes/kanban.db. Calls the plugin's backend at /api/plugins/kanban/ * and tails task_events over a WebSocket for live updates. * * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React + * shadcn primitives; HTML5 drag-and-drop for card movement on desktop and * a pointer-based fallback for touch. */ (function () { "use strict"; const SDK = window.__HERMES_PLUGIN_SDK__; if (!SDK) return; const { React } = SDK; const h = React.createElement; const { Card, CardContent, Badge, Button, Input, Label, Select, SelectOption, } = SDK.components; const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; const { cn, timeAgo } = SDK.utils; // Newer host dashboards expose a DS-styled Checkbox on the plugin SDK. // Fall back to a native shim so older hosts that // predate the design-system rollout still render. The shim normalises // Radix's onCheckedChange(checked) signature to native onChange(event). const Checkbox = SDK.components.Checkbox || function (props) { const { checked, onCheckedChange, className, onClick, ...rest } = props; return h("input", Object.assign({ type: "checkbox", checked: !!checked, className: className, onClick: onClick, onChange: function (e) { if (onCheckedChange) onCheckedChange(e.target.checked); }, }, rest)); }; // useI18n is a hook each component calls locally. Older host dashboards // may not expose it yet; fall back to a shim so the bundle still renders // English against an older host SDK. English fallback strings live // alongside each call site (passed as the third arg of tx()). const useI18n = SDK.useI18n || function () { return { t: { kanban: null }, locale: "en" }; }; // Resolve a translation by dotted path under the kanban namespace // (e.g. "columnLabels.triage"); fall back to the English string passed in. function tx(t, path, fallback, vars) { let node = t && t.kanban; if (node) { const parts = path.split("."); for (let i = 0; i < parts.length; i++) { if (node && typeof node === "object" && parts[i] in node) { node = node[parts[i]]; } else { node = null; break; } } } let str = (typeof node === "string") ? node : fallback; if (vars) { for (const k in vars) { str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); } } return str; } // ``fetchJSON`` throws ``Error(": ")`` on non-2xx, and // FastAPI bodies look like ``{"detail":""}``. Pull the // human-readable message out so banners/toasts don't have to leak HTTP // plumbing at the user (e.g. ``409: {"detail":"…"}``). See #26744. function parseApiErrorMessage(err) { const raw = (err && err.message) ? String(err.message) : String(err || ""); const m = raw.match(/^(\d{3}):\s*(.*)$/s); const body = m ? m[2] : raw; try { const parsed = JSON.parse(body); if (parsed && typeof parsed.detail === "string") return parsed.detail; if (parsed && parsed.detail && typeof parsed.detail.message === "string") { return parsed.detail.message; } } catch (_e) { /* not JSON — fall through to raw body */ } return body || raw; } // Board column display order; any backend status not listed here renders after these. const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "review", "done"]; // English fallback dictionaries — used when the i18n catalog is missing // a key, and as defaults for the get*() helpers below so callers running // outside any React component (where there's no `t`) still get sane text. const FALLBACK_COLUMN_LABEL = { triage: "Triage", todo: "Todo", ready: "Ready", running: "In Progress", blocked: "Blocked", review: "Review", done: "Done", archived: "Archived", }; const FALLBACK_COLUMN_HELP = { triage: "Raw ideas — a specifier will flesh out the spec", todo: "Waiting on dependencies or unassigned", ready: "Dependencies satisfied; assign a profile to dispatch", running: "Claimed by a worker — in-flight", blocked: "Worker asked for human input", review: "Implementation complete — awaiting review", done: "Completed", archived: "Archived", }; const FALLBACK_DESTRUCTIVE = { done: "Mark this task as done? The worker's claim is released and dependent children become ready.", archived: "Archive this task? It disappears from the default board view.", blocked: "Mark this task as blocked? The worker's claim is released.", }; // Pluralized variants used by getDestructiveConfirm() when count > 1. // Each entry may use {n} as a placeholder for the count. const FALLBACK_DESTRUCTIVE_MANY = { done: "Mark {n} tasks as done? The workers' claims are released and dependent children become ready.", archived: "Archive {n} tasks? They disappear from the default board view.", blocked: "Mark {n} tasks as blocked? The workers' claims are released.", }; const FALLBACK_DIAGNOSTIC_EVENT_LABELS = { completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids", suspected_hallucinated_references: "⚠ Prose referenced phantom card ids", }; const FALLBACK_TRASH = { label: "Trash", title: "Drag a card here to permanently delete it", confirm: "Permanently delete this task? This cannot be undone.", dropHint: "Drop to delete", }; const DIAGNOSTIC_EVENT_KIND_KEYS = { completion_blocked_hallucination: "completionBlockedHallucination", suspected_hallucinated_references: "suspectedHallucinatedReferences", }; const DESTRUCTIVE_KEYS = { done: "confirmDone", archived: "confirmArchive", blocked: "confirmBlocked", }; function getColumnLabel(t, status) { return tx(t, "columnLabels." + status, FALLBACK_COLUMN_LABEL[status] || status); } function getColumnHelp(t, status) { return tx(t, "columnHelp." + status, FALLBACK_COLUMN_HELP[status] || ""); } function getDestructiveConfirm(t, status, count) { const key = DESTRUCTIVE_KEYS[status]; if (!key) return null; // For bulk operations, use the *Many variant of the i18n key so the // copy pluralizes correctly ("Mark 3 tasks as done?" instead of // "Mark this task as done?"). Falls back to the singular English // string if a translation for the *Many key isn't shipped. if (count && count > 1) { const manyKey = key + "Many"; const manyFallback = FALLBACK_DESTRUCTIVE_MANY[status] || FALLBACK_DESTRUCTIVE[status]; return tx(t, manyKey, manyFallback, { n: count }); } return tx(t, key, FALLBACK_DESTRUCTIVE[status]); } function getDiagnosticEventLabel(t, kind) { const key = DIAGNOSTIC_EVENT_KIND_KEYS[kind]; if (!key) return null; return tx(t, key, FALLBACK_DIAGNOSTIC_EVENT_LABELS[kind]); } const COLUMN_DOT = { triage: "hermes-kanban-dot-triage", todo: "hermes-kanban-dot-todo", ready: "hermes-kanban-dot-ready", running: "hermes-kanban-dot-running", blocked: "hermes-kanban-dot-blocked", review: "hermes-kanban-dot-review", done: "hermes-kanban-dot-done", archived: "hermes-kanban-dot-archived", }; function isDiagnosticEvent(kind) { return Object.prototype.hasOwnProperty.call(FALLBACK_DIAGNOSTIC_EVENT_LABELS, kind); } function phantomIdsFromEvent(ev) { if (!ev || !ev.payload) return []; const p = ev.payload; return p.phantom_cards || p.phantom_refs || []; } // Helpers for the dialog state machine used by `useKanbanDialogs` below. // The dialog API is Promise-based so call sites can preserve their // synchronous-ish flow: ``await kanbanDialogs.request(...)`` and then // continue with the optimistic UI + PATCH. See #50547. function dialogLabelForCount(count, t) { return count && count > 1 ? tx(t, "selectedTasks", "{n} selected tasks", { n: count }) : tx(t, "thisTask", "this task"); } /** * Hook owning the kanban plugin's modal dialog state. Returns * - `request(req)` — imperative API. Resolves to * `{ confirmed: false }` if the user cancels, or * `{ confirmed: true, summary?: string }` if they confirm. * - `dialogState` — current dialog descriptor for rendering, or null. * - `dialogProps` — onConfirm/onCancel handlers bound to the current * request. * * `req` shapes: * { kind: "confirm", title, description, confirmLabel, destructive } * * The "completion" kind (textarea prompt) is deferred: the host's * ConfirmDialog hardcodes onClick → unmount, preventing validation- * state retention. See KanbanDialogs doc comment. */ function useKanbanDialogs(t) { const [dialogState, setDialogState] = React.useState(null); const resolverRef = React.useRef(null); const request = React.useCallback(function (req) { return new Promise(function (resolve) { resolverRef.current = resolve; setDialogState(req); }); }, []); const close = React.useCallback(function (confirmed, extras) { const resolve = resolverRef.current; resolverRef.current = null; setDialogState(null); if (resolve) { resolve(Object.assign({ confirmed: confirmed }, extras || {})); } }, []); const onConfirm = React.useCallback(function (maybeSummary) { close(true, maybeSummary ? { summary: maybeSummary } : null); }, [close]); const onCancel = React.useCallback(function () { close(false, null); }, [close]); // Wrap the ConfirmDialog props so call sites can hand them straight // to . Title/description/confirmLabel are // sourced from the current dialog state. For "completion" the dialog // body (textarea + dual-validation) is rendered separately. const dialogProps = React.useMemo(function () { if (!dialogState) return null; return { open: true, title: dialogState.title || "", description: dialogState.description, confirmLabel: dialogState.confirmLabel || (dialogState.kind === "completion" ? tx(t, "confirm", "Confirm") : tx(t, "ok", "OK")), destructive: !!dialogState.destructive, onConfirm: function () { onConfirm(); }, onCancel: onCancel, }; }, [dialogState, t, onConfirm, onCancel]); return { dialogState: dialogState, dialogProps: dialogProps, request: request }; } const API = "/api/plugins/kanban"; const MIME_TASK = "text/x-hermes-task"; // Docs link — surfaced as a `?` icon next to the board switcher and as // `title=` hints on unlabelled controls. Kept in one place so rebrands or // path changes are a single edit. const DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban"; const DOCS_TUTORIAL_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban-tutorial"; // localStorage key for the user's selected board. Independent of the // CLI's on-disk ``/kanban/current`` pointer so browser users // can inspect any board without shifting the CLI's active board out // from under a terminal they left open. const LS_BOARD_KEY = "hermes.kanban.selectedBoard"; function readSelectedBoard() { try { const v = window.localStorage.getItem(LS_BOARD_KEY); return (v || "").trim() || null; } catch (_e) { return null; } } function writeSelectedBoard(slug) { try { // Persist the user's dashboard-side board pin even for "default". // Previously this stripped "default" to keep localStorage empty, // but the fetch layer read that absence as "no opinion" and fell // through to the server-side ``current`` file — which the board // switcher also writes. Result: selecting the default tab after // creating a new board with "switch" checked showed the new // board's (wrong) data because the URL omitted ``?board=`` and // the backend happily returned whichever board was "current". // Persisting every selection keeps the dashboard's board opinion // independent of the CLI's active board, which was the original // design intent. Regression: #20879. if (slug) window.localStorage.setItem(LS_BOARD_KEY, slug); else window.localStorage.removeItem(LS_BOARD_KEY); } catch (_e) { /* ignore quota / private mode */ } } function withBoard(url, board) { // Always append ?board= when we have one picked — including // "default". Omitting the param would fall through to the backend's // resolution chain (env var → ``current`` file → default), which // means the dashboard's tab selection gets silently overridden by // whatever board the CLI or "switch" checkbox last activated. // Regression: #20879. if (!board) return url; const sep = url.indexOf("?") >= 0 ? "&" : "?"; return `${url}${sep}board=${encodeURIComponent(board)}`; } // The SDK's Select component fires ``onValueChange(value)`` directly // (it's a shadcn-style popup, not a native