/** * 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; // Order matches BOARD_COLUMNS in plugin_api.py. const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "done"]; const COLUMN_LABEL = { triage: "Triage", todo: "Todo", ready: "Ready", running: "In Progress", blocked: "Blocked", done: "Done", archived: "Archived", }; const COLUMN_HELP = { triage: "Raw ideas — a specifier will flesh out the spec", todo: "Waiting on dependencies or unassigned", ready: "Assigned and waiting for a dispatcher tick", running: "Claimed by a worker — in-flight", blocked: "Worker asked for human input", done: "Completed", archived: "Archived", }; 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", done: "hermes-kanban-dot-done", archived: "hermes-kanban-dot-archived", }; const DESTRUCTIVE_TRANSITIONS = { 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.", }; const API = "/api/plugins/kanban"; const MIME_TASK = "text/x-hermes-task"; // ------------------------------------------------------------------------- // Minimal safe markdown renderer. // // Recognises a small subset (headings, bold, italic, inline code, fenced // code, links, bullet lists, paragraphs). HTML escaping first, then // inline replacements against the escaped string — no raw HTML from the // user is ever executed. // ------------------------------------------------------------------------- function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderInline(esc) { // Fenced code has already been extracted before this runs; process // inline replacements on the escaped string. return esc // inline code .replace(/`([^`\n]+)`/g, (_m, c) => `${c}`) // bold .replace(/\*\*([^*\n]+)\*\*/g, "$1") // italic .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") // safe links — only http(s) and mailto .replace( /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `${text}`, ); } function renderMarkdown(src) { if (!src) return ""; // Split out fenced code blocks first so their contents aren't mangled. const blocks = []; let working = String(src).replace(/```([\s\S]*?)```/g, (_m, code) => { blocks.push(code); return `\u0000CODE${blocks.length - 1}\u0000`; }); const escaped = escapeHtml(working); const lines = escaped.split(/\r?\n/); const out = []; let inList = false; for (const raw of lines) { const line = raw; const bullet = /^\s*[-*]\s+(.*)$/.exec(line); const heading = /^(#{1,4})\s+(.*)$/.exec(line); if (bullet) { if (!inList) { out.push(""); inList = false; } if (heading) { const level = heading[1].length; out.push(`${renderInline(heading[2])}`); } else if (line.trim() === "") { out.push(""); } else { out.push(`

${renderInline(line)}

`); } } if (inList) out.push(""); let html = out.join("\n"); // Re-insert fenced code blocks. html = html.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => `
${escapeHtml(blocks[Number(i)])}
`, ); return html; } function MarkdownBlock(props) { const enabled = props.enabled !== false; if (!enabled) { return h("pre", { className: "hermes-kanban-pre" }, props.source || ""); } return h("div", { className: "hermes-kanban-md", dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || "") }, }); } // ------------------------------------------------------------------------- // Touch drag-drop helper. // // HTML5 DnD is desktop-only. On touch devices we attach a pointerdown // handler that simulates a drag proxy and fires a custom event on the // column under the finger when released. Columns listen for both the // standard `drop` event and our `hermes-kanban:drop` event. // ------------------------------------------------------------------------- function attachTouchDrag(el, taskId) { if (!el) return; function onDown(e) { if (e.pointerType !== "touch") return; e.preventDefault(); const proxy = el.cloneNode(true); proxy.classList.add("hermes-kanban-touch-proxy"); document.body.appendChild(proxy); let lastTarget = null; function move(ev) { proxy.style.left = `${ev.clientX - proxy.offsetWidth / 2}px`; proxy.style.top = `${ev.clientY - 24}px`; proxy.style.display = "none"; const under = document.elementFromPoint(ev.clientX, ev.clientY); proxy.style.display = ""; const col = under && under.closest && under.closest("[data-kanban-column]"); if (col !== lastTarget) { if (lastTarget) lastTarget.classList.remove("hermes-kanban-column--drop"); if (col) col.classList.add("hermes-kanban-column--drop"); lastTarget = col; } } function up() { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("pointercancel", up); if (lastTarget) { lastTarget.classList.remove("hermes-kanban-column--drop"); const status = lastTarget.getAttribute("data-kanban-column"); lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", { detail: { taskId, status }, bubbles: true, })); } proxy.remove(); } // Kick off proxy at the pointer origin. proxy.style.position = "fixed"; proxy.style.pointerEvents = "none"; proxy.style.opacity = "0.85"; proxy.style.zIndex = "9999"; proxy.style.width = `${el.offsetWidth}px`; proxy.style.left = `${e.clientX - el.offsetWidth / 2}px`; proxy.style.top = `${e.clientY - 24}px`; document.addEventListener("pointermove", move); document.addEventListener("pointerup", up); document.addEventListener("pointercancel", up); } el.addEventListener("pointerdown", onDown); return function () { el.removeEventListener("pointerdown", onDown); }; } // ------------------------------------------------------------------------- // Error boundary // ------------------------------------------------------------------------- class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, info) { // eslint-disable-next-line no-console console.error("Kanban plugin crashed:", error, info); } render() { if (this.state.error) { return h(Card, null, h(CardContent, { className: "p-6 text-sm" }, h("div", { className: "text-destructive font-semibold mb-1" }, "Kanban tab hit a rendering error"), h("div", { className: "text-muted-foreground text-xs mb-3" }, String(this.state.error && this.state.error.message || this.state.error)), h(Button, { onClick: () => this.setState({ error: null }), className: "h-7 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Reload view"), ), ); } return this.props.children; } } // ------------------------------------------------------------------------- // Root page // ------------------------------------------------------------------------- function KanbanPage() { const [board, setBoard] = useState(null); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tenantFilter, setTenantFilter] = useState(""); const [assigneeFilter, setAssigneeFilter] = useState(""); const [includeArchived, setIncludeArchived] = useState(false); const [search, setSearch] = useState(""); const [laneByProfile, setLaneByProfile] = useState(true); const [configApplied, setConfigApplied] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(null); const [selectedIds, setSelectedIds] = 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 // showing stale data. const [taskEventTick, setTaskEventTick] = useState({}); const cursorRef = useRef(0); const reloadTimerRef = useRef(null); const wsRef = useRef(null); const wsBackoffRef = useRef(1000); const wsClosedRef = useRef(false); // --- load config once --------------------------------------------------- useEffect(function () { SDK.fetchJSON(`${API}/config`) .then(function (c) { setConfig(c); if (!configApplied) { if (c.default_tenant) setTenantFilter(c.default_tenant); if (typeof c.lane_by_profile === "boolean") setLaneByProfile(c.lane_by_profile); if (typeof c.include_archived_by_default === "boolean") setIncludeArchived(c.include_archived_by_default); setConfigApplied(true); } }) .catch(function () { setConfig({ render_markdown: true }); }); }, []); // eslint-disable-line react-hooks/exhaustive-deps // --- fetch full board --------------------------------------------------- const loadBoard = useCallback(() => { const qs = new URLSearchParams(); if (tenantFilter) qs.set("tenant", tenantFilter); if (includeArchived) qs.set("include_archived", "true"); const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`; return SDK.fetchJSON(url) .then(function (data) { setBoard(data); cursorRef.current = data.latest_event_id || 0; setError(null); }) .catch(function (err) { setError(String(err && err.message ? err.message : err)); }) .finally(function () { setLoading(false); }); }, [tenantFilter, includeArchived]); const scheduleReload = useCallback(function () { if (reloadTimerRef.current) return; reloadTimerRef.current = setTimeout(function () { reloadTimerRef.current = null; loadBoard(); }, 250); }, [loadBoard]); useEffect(function () { loadBoard(); return function () { if (reloadTimerRef.current) { clearTimeout(reloadTimerRef.current); reloadTimerRef.current = null; } }; }, [loadBoard]); // --- WebSocket --------------------------------------------------------- useEffect(function () { if (!board) return undefined; wsClosedRef.current = false; function openWs() { if (wsClosedRef.current) return; const token = window.__HERMES_SESSION_TOKEN__ || ""; const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams({ since: String(cursorRef.current || 0), token: token, }); const url = `${proto}//${window.location.host}${API}/events?${qs}`; let ws; try { ws = new WebSocket(url); } catch (_e) { return; } wsRef.current = ws; ws.onopen = function () { wsBackoffRef.current = 1000; }; ws.onmessage = function (ev) { try { const msg = JSON.parse(ev.data); if (msg && Array.isArray(msg.events) && msg.events.length > 0) { cursorRef.current = msg.cursor || cursorRef.current; // Stamp per-task signal so the TaskDrawer can reload itself. setTaskEventTick(function (prev) { const next = Object.assign({}, prev); for (const e of msg.events) { if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1; } return next; }); scheduleReload(); } } catch (_e) { /* ignore */ } }; ws.onclose = function (ev) { if (wsClosedRef.current) return; if (ev && ev.code === 1008) { setError("WebSocket auth failed — reload the page to refresh the session token."); return; } const delay = Math.min(wsBackoffRef.current, 30000); wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000); setTimeout(openWs, delay); }; } openWs(); return function () { wsClosedRef.current = true; try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ } }; }, [!!board, scheduleReload]); // --- filtering ---------------------------------------------------------- const filteredBoard = useMemo(function () { if (!board) return null; const q = search.trim().toLowerCase(); const filterTask = function (t) { if (assigneeFilter && t.assignee !== assigneeFilter) return false; if (q) { const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); if (hay.indexOf(q) === -1) return false; } return true; }; return Object.assign({}, board, { columns: board.columns.map(function (col) { return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) }); }), }); }, [board, assigneeFilter, search]); // --- actions ------------------------------------------------------------ const moveTask = useCallback(function (taskId, newStatus) { const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; if (confirmMsg && !window.confirm(confirmMsg)) return; setBoard(function (b) { if (!b) return b; let moved = null; const columns = b.columns.map(function (col) { const next = col.tasks.filter(function (t) { if (t.id === taskId) { moved = Object.assign({}, t, { status: newStatus }); return false; } return true; }); return Object.assign({}, col, { tasks: next }); }); if (moved) { 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(`${API}/tasks/${encodeURIComponent(taskId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: newStatus }), }).catch(function (err) { setError(`Move failed: ${err.message || err}`); loadBoard(); }); }, [loadBoard]); const createTask = useCallback(function (body) { return SDK.fetchJSON(`${API}/tasks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).then(function (res) { // Surface dispatcher-presence warnings (e.g. "no gateway is // running") via the existing error banner channel. Not fatal — // the task was created successfully — but the user should know // their ready task will sit idle until the gateway is up. if (res && res.warning) { setError("Task created, but: " + res.warning); } loadBoard(); return res; }); }, [loadBoard]); const toggleSelected = useCallback(function (id, additive) { setSelectedIds(function (prev) { const next = new Set(additive ? prev : []); if (prev.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const clearSelected = useCallback(function () { setSelectedIds(new Set()); }, []); const applyBulk = useCallback(function (patch, confirmMsg) { if (selectedIds.size === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; const body = Object.assign({ ids: Array.from(selectedIds) }, patch); SDK.fetchJSON(`${API}/tasks/bulk`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) .then(function (res) { const failed = (res.results || []).filter(function (r) { return !r.ok; }); if (failed.length > 0) { setError(`Bulk: ${failed.length} of ${res.results.length} failed: ` + failed.slice(0, 3).map(function (f) { return `${f.id} (${f.error})`; }).join("; ")); } clearSelected(); loadBoard(); }) .catch(function (e) { setError(String(e.message || e)); }); }, [selectedIds, loadBoard, clearSelected]); // --- render ------------------------------------------------------------- if (loading && !board) { return h("div", { className: "p-8 text-sm text-muted-foreground" }, "Loading Kanban board…"); } if (error && !board) { return h(Card, null, h(CardContent, { className: "p-6" }, h("div", { className: "text-sm text-destructive" }, "Failed to load Kanban board: ", error), h("div", { className: "text-xs text-muted-foreground mt-2" }, "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs."), ), ); } if (!filteredBoard) return null; const renderMd = !config || config.render_markdown !== false; return h(ErrorBoundary, null, h("div", { className: "hermes-kanban flex flex-col gap-4" }, h(BoardToolbar, { board: board, tenantFilter, setTenantFilter, assigneeFilter, setAssigneeFilter, includeArchived, setIncludeArchived, laneByProfile, setLaneByProfile, search, setSearch, onNudgeDispatch: function () { SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" }) .then(loadBoard) .catch(function (e) { setError(String(e.message || e)); }); }, onRefresh: loadBoard, }), selectedIds.size > 0 ? h(BulkActionBar, { count: selectedIds.size, assignees: (board && board.assignees) || [], onApply: applyBulk, onClear: clearSelected, }) : null, error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, h(BoardColumns, { board: filteredBoard, laneByProfile, selectedIds, toggleSelected, onMove: moveTask, onOpen: setSelectedTaskId, onCreate: createTask, allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), }), selectedTaskId ? h(TaskDrawer, { taskId: selectedTaskId, onClose: function () { setSelectedTaskId(null); }, onRefresh: loadBoard, renderMarkdown: renderMd, allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), eventTick: taskEventTick[selectedTaskId] || 0, }) : null, ), ); } // ------------------------------------------------------------------------- // Toolbar // ------------------------------------------------------------------------- function BoardToolbar(props) { const tenants = (props.board && props.board.tenants) || []; const assignees = (props.board && props.board.assignees) || []; return h("div", { className: "flex flex-wrap items-end gap-3" }, h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs text-muted-foreground" }, "Search"), h(Input, { placeholder: "Filter cards…", value: props.search, onChange: function (e) { props.setSearch(e.target.value); }, className: "w-56 h-8", }), ), h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"), h(Select, { value: props.tenantFilter, onChange: function (e) { props.setTenantFilter(e.target.value); }, className: "h-8", }, h(SelectOption, { value: "" }, "All tenants"), tenants.map(function (t) { return h(SelectOption, { key: t, value: t }, t); }), ), ), h("div", { className: "flex flex-col gap-1" }, h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"), h(Select, { value: props.assigneeFilter, onChange: function (e) { props.setAssigneeFilter(e.target.value); }, className: "h-8", }, h(SelectOption, { value: "" }, "All profiles"), assignees.map(function (a) { return h(SelectOption, { key: a, value: a }, a); }), ), ), h("label", { className: "flex items-center gap-2 text-xs" }, h("input", { type: "checkbox", checked: props.includeArchived, onChange: function (e) { props.setIncludeArchived(e.target.checked); }, }), "Show archived", ), h("label", { className: "flex items-center gap-2 text-xs", title: "Group the Running column by assigned profile" }, h("input", { type: "checkbox", checked: props.laneByProfile, onChange: function (e) { props.setLaneByProfile(e.target.checked); }, }), "Lanes by profile", ), h("div", { className: "flex-1" }), h(Button, { onClick: props.onNudgeDispatch, className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Nudge dispatcher"), h(Button, { onClick: props.onRefresh, className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Refresh"), ); } // ------------------------------------------------------------------------- // Bulk action bar (appears when >= 1 card is selected) // ------------------------------------------------------------------------- function BulkActionBar(props) { const [assignee, setAssignee] = useState(""); return h("div", { className: "hermes-kanban-bulk" }, h("span", { className: "hermes-kanban-bulk-count" }, `${props.count} selected`), h(Button, { onClick: function () { props.onApply({ status: "ready" }); }, className: "hermes-kanban-bulk-btn", }, "→ ready"), h(Button, { onClick: function () { props.onApply({ status: "done" }, `Mark ${props.count} task(s) as done?`); }, className: "hermes-kanban-bulk-btn", }, "Complete"), h(Button, { onClick: function () { props.onApply({ archive: true }, `Archive ${props.count} task(s)?`); }, className: "hermes-kanban-bulk-btn", }, "Archive"), h("div", { className: "hermes-kanban-bulk-reassign" }, h(Select, { value: assignee, onChange: function (e) { setAssignee(e.target.value); }, className: "h-7 text-xs", }, h(SelectOption, { value: "" }, "— reassign —"), h(SelectOption, { value: "__none__" }, "(unassign)"), props.assignees.map(function (a) { return h(SelectOption, { key: a, value: a }, a); }), ), h(Button, { onClick: function () { if (!assignee) return; props.onApply({ assignee: assignee === "__none__" ? "" : assignee }); setAssignee(""); }, disabled: !assignee, className: cn("hermes-kanban-bulk-btn", !assignee ? "opacity-40 cursor-not-allowed" : ""), }, "Apply"), ), h("div", { className: "flex-1" }), h(Button, { onClick: props.onClear, className: "hermes-kanban-bulk-btn", }, "Clear"), ); } // ------------------------------------------------------------------------- // Columns // ------------------------------------------------------------------------- function BoardColumns(props) { return h("div", { className: "hermes-kanban-columns" }, props.board.columns.map(function (col) { return h(Column, { key: col.name, column: col, laneByProfile: props.laneByProfile, selectedIds: props.selectedIds, toggleSelected: props.toggleSelected, onMove: props.onMove, onOpen: props.onOpen, onCreate: props.onCreate, allTasks: props.allTasks, }); }), ); } function Column(props) { const [dragOver, setDragOver] = useState(false); const [showCreate, setShowCreate] = useState(false); const colRef = useRef(null); // Listen for our synthetic touch-drop events from attachTouchDrag(). useEffect(function () { if (!colRef.current) return undefined; const el = colRef.current; function onTouchDrop(e) { if (e.detail && e.detail.status === props.column.name) { props.onMove(e.detail.taskId, props.column.name); } } el.addEventListener("hermes-kanban:drop", onTouchDrop); return function () { el.removeEventListener("hermes-kanban:drop", onTouchDrop); }; }, [props.column.name, props.onMove]); const handleDragOver = function (e) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (!dragOver) setDragOver(true); }; const handleDragLeave = function () { setDragOver(false); }; const handleDrop = function (e) { e.preventDefault(); setDragOver(false); const taskId = e.dataTransfer.getData(MIME_TASK); if (taskId) props.onMove(taskId, props.column.name); }; const lanes = useMemo(function () { if (!props.laneByProfile || props.column.name !== "running") return null; const byProfile = {}; for (const t of props.column.tasks) { const key = t.assignee || "(unassigned)"; (byProfile[key] = byProfile[key] || []).push(t); } return Object.keys(byProfile).sort().map(function (k) { return { assignee: k, tasks: byProfile[k] }; }); }, [props.column, props.laneByProfile]); return h("div", { ref: colRef, "data-kanban-column": props.column.name, className: cn( "hermes-kanban-column", dragOver ? "hermes-kanban-column--drop" : "", ), onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, }, h("div", { className: "hermes-kanban-column-header" }, h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }), h("span", { className: "hermes-kanban-column-label" }, COLUMN_LABEL[props.column.name] || props.column.name), h("span", { className: "hermes-kanban-column-count" }, props.column.tasks.length), h("button", { type: "button", className: "hermes-kanban-column-add", title: "Create task in this column", onClick: function () { setShowCreate(function (v) { return !v; }); }, }, showCreate ? "×" : "+"), ), h("div", { className: "hermes-kanban-column-sub" }, COLUMN_HELP[props.column.name] || ""), showCreate ? h(InlineCreate, { columnName: props.column.name, allTasks: props.allTasks, onSubmit: function (body) { props.onCreate(body).then(function () { setShowCreate(false); }); }, onCancel: function () { setShowCreate(false); }, }) : null, h("div", { className: "hermes-kanban-column-body" }, props.column.tasks.length === 0 ? h("div", { className: "hermes-kanban-empty" }, "— no tasks —") : lanes ? lanes.map(function (lane) { return h("div", { key: lane.assignee, className: "hermes-kanban-lane" }, h("div", { className: "hermes-kanban-lane-head" }, h("span", { className: "hermes-kanban-lane-name" }, lane.assignee), h("span", { className: "hermes-kanban-lane-count" }, lane.tasks.length), ), lane.tasks.map(function (t) { return h(TaskCard, { key: t.id, task: t, selected: props.selectedIds.has(t.id), toggleSelected: props.toggleSelected, onOpen: props.onOpen, }); }), ); }) : props.column.tasks.map(function (t) { return h(TaskCard, { key: t.id, task: t, selected: props.selectedIds.has(t.id), toggleSelected: props.toggleSelected, onOpen: props.onOpen, }); }), ), ); } // ------------------------------------------------------------------------- // Card // ------------------------------------------------------------------------- // Staleness tiers — amber after a grace window, red when clearly stuck. // Values below are seconds. const STALENESS = { ready: { amber: 1 * 60 * 60, red: 24 * 60 * 60 }, running: { amber: 10 * 60, red: 60 * 60 }, blocked: { amber: 1 * 60 * 60, red: 24 * 60 * 60 }, todo: { amber: 7 * 24 * 60 * 60, red: 30 * 24 * 60 * 60 }, }; function stalenessClass(task) { if (!task || !task.age) return ""; const age = task.status === "running" ? task.age.started_age_seconds : task.age.created_age_seconds; const tier = STALENESS[task.status]; if (!tier || age == null) return ""; if (age >= tier.red) return "hermes-kanban-card--stale-red"; if (age >= tier.amber) return "hermes-kanban-card--stale-amber"; return ""; } function TaskCard(props) { const t = props.task; const cardRef = useRef(null); useEffect(function () { return attachTouchDrag(cardRef.current, t.id); }, [t.id]); const handleDragStart = function (e) { e.dataTransfer.setData(MIME_TASK, t.id); 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) { e.preventDefault(); e.stopPropagation(); props.toggleSelected(t.id, e.ctrlKey || e.metaKey); return; } props.onOpen(t.id); }; const handleCheckbox = function (e) { e.stopPropagation(); props.toggleSelected(t.id, true); }; const progress = t.progress; return h("div", { ref: cardRef, className: cn( "hermes-kanban-card", props.selected ? "hermes-kanban-card--selected" : "", stalenessClass(t), ), draggable: true, onDragStart: handleDragStart, onClick: handleClick, }, 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, onClick: function (e) { e.stopPropagation(); }, title: "Select for bulk actions", }), h("span", { className: "hermes-kanban-card-id" }, t.id), t.priority > 0 ? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`) : null, t.tenant ? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant) : null, progress ? h("span", { className: cn( "hermes-kanban-progress", progress.done === progress.total ? "hermes-kanban-progress--full" : "", ), title: `${progress.done} of ${progress.total} child tasks done`, }, `${progress.done}/${progress.total}`) : null, ), h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"), h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, t.assignee ? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee) : h("span", { className: "hermes-kanban-unassigned" }, "unassigned"), t.comment_count > 0 ? h("span", { className: "hermes-kanban-count" }, "💬 ", t.comment_count) : null, t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0 ? h("span", { className: "hermes-kanban-count" }, "↔ ", t.link_counts.parents + t.link_counts.children) : null, h("span", { className: "hermes-kanban-ago" }, timeAgo ? timeAgo(t.created_at) : ""), ), ), ), ); } // ------------------------------------------------------------------------- // Inline create (with parent selector) // ------------------------------------------------------------------------- function InlineCreate(props) { const [title, setTitle] = useState(""); const [assignee, setAssignee] = useState(""); const [priority, setPriority] = useState(0); const [parent, setParent] = useState(""); const [skills, setSkills] = useState(""); const submit = function () { const trimmed = title.trim(); if (!trimmed) return; const body = { title: trimmed, assignee: assignee.trim() || null, priority: Number(priority) || 0, triage: props.columnName === "triage", }; if (parent) body.parents = [parent]; // Parse comma-separated skills into a clean list. Blank = no // extras (omit key so backend leaves it null). The dispatcher // always auto-loads kanban-worker; these are extras on top. const skillList = skills .split(",") .map(function (s) { return s.trim(); }) .filter(function (s) { return s.length > 0; }); if (skillList.length > 0) body.skills = skillList; props.onSubmit(body); setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills(""); }; return h("div", { className: "hermes-kanban-inline-create" }, h(Input, { value: title, onChange: function (e) { setTitle(e.target.value); }, onKeyDown: function (e) { if (e.key === "Enter") { e.preventDefault(); submit(); } if (e.key === "Escape") props.onCancel(); }, placeholder: props.columnName === "triage" ? "Rough idea — AI will spec it…" : "New task title…", autoFocus: true, className: "h-8 text-sm", }), h("div", { className: "flex gap-2" }, h(Input, { value: assignee, onChange: function (e) { setAssignee(e.target.value); }, placeholder: props.columnName === "triage" ? "specifier" : "assignee", className: "h-7 text-xs flex-1", }), h(Input, { type: "number", value: priority, onChange: function (e) { setPriority(e.target.value); }, placeholder: "pri", className: "h-7 text-xs w-16", }), ), h(Input, { value: skills, onChange: function (e) { setSkills(e.target.value); }, placeholder: "skills (optional, comma-separated): translation, github-code-review", title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).", className: "h-7 text-xs", }), h(Select, { value: parent, onChange: function (e) { setParent(e.target.value); }, className: "h-7 text-xs", }, h(SelectOption, { value: "" }, "— no parent —"), (props.allTasks || []).map(function (t) { return h(SelectOption, { key: t.id, value: t.id }, `${t.id} — ${(t.title || "").slice(0, 50)}`); }), ), h("div", { className: "flex gap-2" }, h(Button, { onClick: submit, className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer flex-1", }, "Create"), h(Button, { onClick: props.onCancel, className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Cancel"), ), ); } // ------------------------------------------------------------------------- // Task drawer // ------------------------------------------------------------------------- function TaskDrawer(props) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [newComment, setNewComment] = useState(""); const [editing, setEditing] = useState(false); const load = useCallback(function () { return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`) .then(function (d) { setData(d); setErr(null); }) .catch(function (e) { setErr(String(e.message || e)); }) .finally(function () { setLoading(false); }); }, [props.taskId]); // Reload when the WS stream reports new events for this task id // (completion, block, crash, etc. — anything that'd make the drawer // show stale data if we only loaded on mount). useEffect(function () { load(); }, [load, props.eventTick]); useEffect(function () { function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); } window.addEventListener("keydown", onKey); return function () { window.removeEventListener("keydown", onKey); }; }, [props.onClose, editing]); const handleComment = function () { const body = newComment.trim(); if (!body) return; SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body }), }).then(function () { setNewComment(""); load(); props.onRefresh(); }).catch(function (e) { setErr(String(e.message || e)); }); }; const doPatch = function (patch, opts) { if (opts && opts.confirm && !window.confirm(opts.confirm)) { return Promise.resolve(); } return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }).then(function () { load(); props.onRefresh(); }); }; const addLink = function (parentId) { return SDK.fetchJSON(`${API}/links`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }), }).then(function () { load(); props.onRefresh(); }) .catch(function (e) { setErr(String(e.message || e)); }); }; const removeLink = function (parentId) { const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId }); return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" }) .then(function () { load(); props.onRefresh(); }) .catch(function (e) { setErr(String(e.message || e)); }); }; const addChild = function (childId) { return SDK.fetchJSON(`${API}/links`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parent_id: props.taskId, child_id: childId }), }).then(function () { load(); props.onRefresh(); }) .catch(function (e) { setErr(String(e.message || e)); }); }; const removeChild = function (childId) { const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId }); return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" }) .then(function () { load(); props.onRefresh(); }) .catch(function (e) { setErr(String(e.message || e)); }); }; return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose }, h("div", { className: "hermes-kanban-drawer", onClick: function (e) { e.stopPropagation(); }, }, h("div", { className: "hermes-kanban-drawer-head" }, h("span", { className: "text-xs text-muted-foreground" }, props.taskId), h("button", { type: "button", onClick: props.onClose, className: "hermes-kanban-drawer-close", title: "Close (Esc)", }, "×"), ), loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, "Loading…") : err ? h("div", { className: "p-4 text-sm text-destructive" }, err) : data ? h(TaskDetail, { data, editing, setEditing, renderMarkdown: props.renderMarkdown, allTasks: props.allTasks, onPatch: doPatch, onAddParent: addLink, onRemoveParent: removeLink, onAddChild: addChild, onRemoveChild: removeChild, }) : null, data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, h(Input, { value: newComment, onChange: function (e) { setNewComment(e.target.value); }, onKeyDown: function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleComment(); } }, placeholder: "Add a comment… (Enter to submit)", className: "h-8 text-sm flex-1", }), h(Button, { onClick: handleComment, className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Comment"), ) : null, ), ); } function TaskDetail(props) { const t = props.data.task; const comments = props.data.comments || []; const events = props.data.events || []; const links = props.data.links || { parents: [], children: [] }; return h("div", { className: "hermes-kanban-drawer-body" }, h("div", { className: "hermes-kanban-drawer-title" }, h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[t.status]) }), props.editing ? h(TitleEditor, { initial: t.title || "", onSave: function (newTitle) { return props.onPatch({ title: newTitle }).then(function () { props.setEditing(false); }); }, onCancel: function () { props.setEditing(false); }, }) : h("span", { className: "hermes-kanban-drawer-title-text", title: "Click to edit", onClick: function () { props.setEditing(true); }, }, t.title || "(untitled)"), ), h("div", { className: "hermes-kanban-drawer-meta" }, h(MetaRow, { label: "Status", value: t.status }), h(AssigneeEditor, { task: t, onPatch: props.onPatch }), h(PriorityEditor, { task: t, onPatch: props.onPatch }), t.tenant ? h(MetaRow, { label: "Tenant", value: t.tenant }) : null, h(MetaRow, { label: "Workspace", value: `${t.workspace_kind}${t.workspace_path ? ": " + t.workspace_path : ""}`, }), (t.skills && t.skills.length > 0) ? h(MetaRow, { label: "Skills", value: t.skills.join(", "), }) : null, t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, ), h(StatusActions, { task: t, onPatch: props.onPatch }), h(BodyEditor, { task: t, renderMarkdown: props.renderMarkdown, onPatch: props.onPatch, }), h(DependencyEditor, { task: t, links, allTasks: props.allTasks, onAddParent: props.onAddParent, onRemoveParent: props.onRemoveParent, onAddChild: props.onAddChild, onRemoveChild: props.onRemoveChild, }), t.result ? h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, "Result"), h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }), ) : null, h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, `Comments (${comments.length})`), comments.length === 0 ? h("div", { className: "text-xs text-muted-foreground" }, "— no comments —") : comments.map(function (c) { return h("div", { key: c.id, className: "hermes-kanban-comment" }, h("div", { className: "hermes-kanban-comment-head" }, h("span", { className: "hermes-kanban-comment-author" }, c.author || "anon"), h("span", { className: "hermes-kanban-comment-ago" }, timeAgo ? timeAgo(c.created_at) : ""), ), h(MarkdownBlock, { source: c.body, enabled: props.renderMarkdown }), ); }), ), h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`), events.slice().reverse().slice(0, 20).map(function (e) { return h("div", { key: e.id, className: "hermes-kanban-event" }, h("span", { className: "hermes-kanban-event-kind" }, e.kind), h("span", { className: "hermes-kanban-event-ago" }, timeAgo ? timeAgo(e.created_at) : ""), e.payload ? h("code", { className: "hermes-kanban-event-payload" }, JSON.stringify(e.payload)) : null, ); }), ), h(WorkerLogSection, { taskId: t.id }), h(RunHistorySection, { runs: props.data.runs || [] }), ); } // Per-attempt history. Closed runs first (most recent last), then the // active run if any. Each row shows profile / outcome / elapsed / // summary. Collapsed by default when there are more than three runs. function RunHistorySection(props) { const runs = props.runs || []; const [expanded, setExpanded] = useState(false); if (runs.length === 0) return null; const showAll = expanded || runs.length <= 3; const visible = showAll ? runs : runs.slice(-3); const fmtElapsed = function (run) { if (!run || !run.started_at) return ""; const end = run.ended_at || Math.floor(Date.now() / 1000); const secs = Math.max(0, end - run.started_at); if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.round(secs / 60)}m`; return `${(secs / 3600).toFixed(1)}h`; }; return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, h("span", { className: "hermes-kanban-section-head" }, `Run history (${runs.length})`), !showAll ? h("button", { type: "button", onClick: function () { setExpanded(true); }, className: "hermes-kanban-edit-link", title: "Show all attempts", }, `+${runs.length - 3} earlier`) : null, ), visible.map(function (r) { const outcomeClass = r.ended_at ? `hermes-kanban-run--${r.outcome || r.status || "ended"}` : "hermes-kanban-run--active"; return h("div", { key: r.id, className: cn("hermes-kanban-run", outcomeClass) }, h("div", { className: "hermes-kanban-run-head" }, h("span", { className: "hermes-kanban-run-outcome" }, r.ended_at ? (r.outcome || r.status || "ended") : "active"), h("span", { className: "hermes-kanban-run-profile" }, r.profile ? `@${r.profile}` : "(no profile)"), h("span", { className: "hermes-kanban-run-elapsed" }, fmtElapsed(r)), h("span", { className: "hermes-kanban-run-ago" }, timeAgo ? timeAgo(r.started_at) : ""), ), r.summary ? h("div", { className: "hermes-kanban-run-summary" }, r.summary) : null, r.error ? h("div", { className: "hermes-kanban-run-error" }, r.error) : null, r.metadata ? h("code", { className: "hermes-kanban-run-meta" }, JSON.stringify(r.metadata)) : null, ); }), ); } // Worker log: loads lazily (one GET on mount), refresh button, tail cap. function WorkerLogSection(props) { const [state, setState] = useState({ loading: false, data: null, err: null }); const load = useCallback(function () { setState({ loading: true, data: null, err: null }); SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`) .then(function (d) { setState({ loading: false, data: d, err: null }); }) .catch(function (e) { setState({ loading: false, data: null, err: String(e.message || e) }); }); }, [props.taskId]); // Auto-load when the section mounts; the user opened the drawer so the // cost is one small HTTP round-trip. useEffect(function () { load(); }, [load]); const data = state.data; let body; if (state.loading) { body = h("div", { className: "text-xs text-muted-foreground" }, "Loading log…"); } else if (state.err) { body = h("div", { className: "text-xs text-destructive" }, state.err); } else if (!data || !data.exists) { body = h("div", { className: "text-xs text-muted-foreground italic" }, "— no worker log yet (task hasn't spawned or log was rotated away) —"); } else { body = h("pre", { className: "hermes-kanban-pre hermes-kanban-log" }, data.content || "(empty)"); } return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, h("span", { className: "hermes-kanban-section-head" }, "Worker log" + (data && data.size_bytes ? ` (${data.size_bytes} B)` : "")), h("button", { type: "button", onClick: load, className: "hermes-kanban-edit-link", title: "Refresh log", }, "refresh"), ), body, data && data.truncated ? h("div", { className: "text-xs text-muted-foreground" }, "(showing last 100 KB — full log at ", data.path, ")") : null, ); } function MetaRow(props) { return h("div", { className: "hermes-kanban-meta-row" }, h("span", { className: "hermes-kanban-meta-label" }, props.label), h("span", { className: "hermes-kanban-meta-value" }, props.value), ); } function TitleEditor(props) { const [v, setV] = useState(props.initial); const save = function () { const t = v.trim(); if (!t) return; props.onSave(t); }; return h("div", { className: "hermes-kanban-edit-row" }, h(Input, { value: v, autoFocus: true, onChange: function (e) { setV(e.target.value); }, onKeyDown: function (e) { if (e.key === "Enter") { e.preventDefault(); save(); } if (e.key === "Escape") props.onCancel(); }, className: "h-8 text-sm flex-1", }), h(Button, { onClick: save, className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Save"), h(Button, { onClick: props.onCancel, className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Cancel"), ); } function AssigneeEditor(props) { const [editing, setEditing] = useState(false); const [v, setV] = useState(props.task.assignee || ""); useEffect(function () { setV(props.task.assignee || ""); }, [props.task.assignee]); if (!editing) { return h("div", { className: "hermes-kanban-meta-row" }, h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), h("span", { className: "hermes-kanban-meta-value hermes-kanban-editable", onClick: function () { setEditing(true); }, title: "Click to edit", }, props.task.assignee || "unassigned"), ); } const save = function () { props.onPatch({ assignee: v.trim() || "" }).then(function () { setEditing(false); }); }; return h("div", { className: "hermes-kanban-meta-row" }, h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), h(Input, { value: v, autoFocus: true, onChange: function (e) { setV(e.target.value); }, onKeyDown: function (e) { if (e.key === "Enter") { e.preventDefault(); save(); } if (e.key === "Escape") setEditing(false); }, placeholder: "(empty = unassign)", className: "h-7 text-xs flex-1", }), ); } function PriorityEditor(props) { const [editing, setEditing] = useState(false); const [v, setV] = useState(String(props.task.priority || 0)); useEffect(function () { setV(String(props.task.priority || 0)); }, [props.task.priority]); if (!editing) { return h("div", { className: "hermes-kanban-meta-row" }, h("span", { className: "hermes-kanban-meta-label" }, "Priority"), h("span", { className: "hermes-kanban-meta-value hermes-kanban-editable", onClick: function () { setEditing(true); }, title: "Click to edit", }, String(props.task.priority)), ); } const save = function () { props.onPatch({ priority: Number(v) || 0 }).then(function () { setEditing(false); }); }; return h("div", { className: "hermes-kanban-meta-row" }, h("span", { className: "hermes-kanban-meta-label" }, "Priority"), h(Input, { type: "number", value: v, autoFocus: true, onChange: function (e) { setV(e.target.value); }, onKeyDown: function (e) { if (e.key === "Enter") { e.preventDefault(); save(); } if (e.key === "Escape") setEditing(false); }, className: "h-7 text-xs w-20", }), ); } function BodyEditor(props) { const [editing, setEditing] = useState(false); const [v, setV] = useState(props.task.body || ""); useEffect(function () { setV(props.task.body || ""); }, [props.task.body]); const save = function () { props.onPatch({ body: v }).then(function () { setEditing(false); }); }; return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, h("span", { className: "hermes-kanban-section-head" }, "Description"), editing ? h("div", { className: "flex gap-1" }, h(Button, { onClick: save, className: "h-6 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Save"), h(Button, { onClick: function () { setEditing(false); setV(props.task.body || ""); }, className: "h-6 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", }, "Cancel"), ) : h("button", { type: "button", onClick: function () { setEditing(true); }, className: "hermes-kanban-edit-link", title: "Edit description", }, "edit"), ), editing ? h("textarea", { className: "hermes-kanban-textarea", value: v, rows: 8, onChange: function (e) { setV(e.target.value); }, }) : props.task.body ? h(MarkdownBlock, { source: props.task.body, enabled: props.renderMarkdown }) : h("div", { className: "text-xs text-muted-foreground italic" }, "— no description —"), ); } function DependencyEditor(props) { const { task, links, allTasks } = props; const [newParent, setNewParent] = useState(""); const [newChild, setNewChild] = useState(""); // Filter out self + existing links when offering the "add" dropdown. const candidatesFor = function (excludeSet) { return (allTasks || []).filter(function (t) { return t.id !== task.id && !excludeSet.has(t.id); }); }; const parentExclude = new Set([task.id, ...(links.parents || [])]); const childExclude = new Set([task.id, ...(links.children || [])]); return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, "Dependencies"), h("div", { className: "hermes-kanban-deps-row" }, h("span", { className: "hermes-kanban-deps-label" }, "Parents:"), h("div", { className: "hermes-kanban-deps-chips" }, (links.parents || []).length === 0 ? h("span", { className: "hermes-kanban-deps-empty" }, "none") : (links.parents || []).map(function (id) { return h("span", { key: id, className: "hermes-kanban-dep-chip" }, id, h("button", { type: "button", className: "hermes-kanban-dep-chip-x", onClick: function () { props.onRemoveParent(id); }, title: "Remove dependency", }, "×"), ); }), ), ), h("div", { className: "hermes-kanban-deps-row" }, h(Select, { value: newParent, onChange: function (e) { setNewParent(e.target.value); }, className: "h-7 text-xs flex-1", }, h(SelectOption, { value: "" }, "— add parent —"), candidatesFor(parentExclude).map(function (t) { return h(SelectOption, { key: t.id, value: t.id }, `${t.id} — ${(t.title || "").slice(0, 50)}`); }), ), h(Button, { onClick: function () { if (!newParent) return; props.onAddParent(newParent).then(function () { setNewParent(""); }); }, disabled: !newParent, className: cn("h-7 px-2 text-xs border border-border cursor-pointer", !newParent ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10"), }, "+ parent"), ), h("div", { className: "hermes-kanban-deps-row" }, h("span", { className: "hermes-kanban-deps-label" }, "Children:"), h("div", { className: "hermes-kanban-deps-chips" }, (links.children || []).length === 0 ? h("span", { className: "hermes-kanban-deps-empty" }, "none") : (links.children || []).map(function (id) { return h("span", { key: id, className: "hermes-kanban-dep-chip" }, id, h("button", { type: "button", className: "hermes-kanban-dep-chip-x", onClick: function () { props.onRemoveChild(id); }, title: "Remove dependency", }, "×"), ); }), ), ), h("div", { className: "hermes-kanban-deps-row" }, h(Select, { value: newChild, onChange: function (e) { setNewChild(e.target.value); }, className: "h-7 text-xs flex-1", }, h(SelectOption, { value: "" }, "— add child —"), candidatesFor(childExclude).map(function (t) { return h(SelectOption, { key: t.id, value: t.id }, `${t.id} — ${(t.title || "").slice(0, 50)}`); }), ), h(Button, { onClick: function () { if (!newChild) return; props.onAddChild(newChild).then(function () { setNewChild(""); }); }, disabled: !newChild, className: cn("h-7 px-2 text-xs border border-border cursor-pointer", !newChild ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10"), }, "+ child"), ), ); } function StatusActions(props) { const t = props.task; const b = function (label, patch, enabled, confirmMsg) { return h(Button, { onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); }, disabled: enabled === false, className: cn( "h-7 px-2 text-xs border border-border cursor-pointer", enabled === false ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10", ), }, label); }; return h("div", { className: "hermes-kanban-actions" }, b("→ triage", { status: "triage" }, t.status !== "triage"), b("→ ready", { status: "ready" }, t.status !== "ready"), b("→ running", { status: "running" }, t.status !== "running"), b("Block", { status: "blocked" }, t.status === "running" || t.status === "ready", DESTRUCTIVE_TRANSITIONS.blocked), b("Unblock", { status: "ready" }, t.status === "blocked"), b("Complete", { status: "done" }, t.status === "running" || t.status === "ready" || t.status === "blocked", DESTRUCTIVE_TRANSITIONS.done), b("Archive", { status: "archived" }, t.status !== "archived", DESTRUCTIVE_TRANSITIONS.archived), ); } // ------------------------------------------------------------------------- // Register // ------------------------------------------------------------------------- if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") { window.__HERMES_PLUGINS__.register("kanban", KanbanPage); } })();