hermes-agent/plugins/kanban/dashboard/dist/index.js
Teknium 24d48ffb82
feat(kanban): add specify — auxiliary LLM fleshes out triage tasks (#21435)
* feat(kanban): add `specify` — auxiliary LLM fleshes out triage tasks

The Triage column shipped with a placeholder 'a specifier will flesh
out the spec', but the specifier itself was never built. This wires
it up as a dedicated CLI verb.

`hermes kanban specify <id>` calls the auxiliary LLM (configured under
`auxiliary.triage_specifier`) to expand a rough one-liner into a
concrete spec — tightened title plus a body with Goal / Approach /
Acceptance criteria / Out-of-scope sections — then atomically flips
`status: triage -> todo` and recomputes ready so parent-free tasks
go straight to the dispatcher on the same tick.

Surface:

  hermes kanban specify <task_id>               # single task
  hermes kanban specify --all [--tenant T]      # sweep triage column
  hermes kanban specify ... --author NAME       # audit-comment author
  hermes kanban specify ... --json              # one JSON line per task

Design choices:

  - Parent gating is preserved. specify_triage_task flips to 'todo',
    then recompute_ready promotes to 'ready' only when parents are
    done — same rule as a normal parent-gated todo.
  - No daemon, no background watcher. Every invocation is explicit —
    keeps cost predictable and doesn't fight the dispatcher loop.
  - Response parse is lenient: strict JSON preferred, markdown-fence
    tolerated, raw-body fallback on malformed JSON so the LLM can't
    strand a task in triage.
  - All failure modes (no aux client, API error, task moved out of
    triage mid-call) return SpecifyOutcome(ok=False, reason=...) so
    --all continues past individual failures.

Changes:

  hermes_cli/kanban_db.py    + specify_triage_task()
  hermes_cli/kanban_specify.py  NEW (~220 LOC — prompt, parse, call)
  hermes_cli/kanban.py       + specify subcommand + _cmd_specify
  hermes_cli/config.py       + auxiliary.triage_specifier task slot
  website/docs/user-guide/features/kanban.md  specify + config notes
  website/docs/reference/cli-commands.md      CLI reference entry
  tests/hermes_cli/test_kanban_specify_db.py    NEW (10 tests)
  tests/hermes_cli/test_kanban_specify.py       NEW (20 tests)

Validation: 30/30 targeted tests pass. E2E: triage task -> specify ->
ends in 'ready' with events [created, specified, promoted] and the
audit comment recorded under the configured author.

* feat(kanban): wire specifier into dashboard and gateway slash

Follow-ups to the initial PR #21435 — closes the two gaps I'd left as
post-merge: dashboard button and first-class gateway surface.

Dashboard (plugins/kanban/dashboard/)
  - POST /tasks/:id/specify  NEW endpoint. Thin wrapper around
    kanban_specify.specify_task(). Returns the CLI outcome shape
    ({ok, task_id, reason, new_title}); ok=false with a human reason
    is a 200, not a 4xx, so the UI can render it inline without
    treating 'no aux client configured' as a crash.
  - Runs sync in FastAPI's threadpool because the LLM call can take
    tens of seconds on reasoning models.
  - Pins HERMES_KANBAN_BOARD around the specify call so the module's
    argless kb.connect() lands on the right board.
  - dist/index.js: doSpecify callback threaded through the drawer →
    TaskDetail → StatusActions prop chain.  Specify button appears
    ONLY when task.status === 'triage' (elsewhere the backend would
    reject anyway — hide the button to keep the action row clean).
    Busy state (Specifying…) + inline success/error banner under the
    button using the response.reason text.
  - dist/style.css: tiny hermes-kanban-msg-ok / -err classes using
    existing --color vars so themes reskin cleanly.

Gateway slash (/kanban specify)
  - Already works via the existing run_slash → build_parser →
    kanban_command pipeline. No code change needed — slash commands
    inherit the argparse tree automatically. Added coverage:
    test_run_slash_specify_end_to_end (create --triage, specify, verify
    promotion + retitle) and test_run_slash_specify_help_is_reachable.

Tests
  - tests/plugins/test_kanban_dashboard_plugin.py: 3 new tests for the
    REST endpoint — happy path, non-triage rejection as ok=false 200,
    missing aux client as ok=false 200.
  - tests/hermes_cli/test_kanban_cli.py: 2 new slash-surface tests.

Docs
  - website/docs/user-guide/features/kanban.md: dashboard action row
    description mentions  Specify + all three surfaces. REST table
    gains /tasks/:id/specify. Slash examples include /kanban specify.

Validation: 340/340 targeted tests pass. E2E via TestClient: create a
triage task over REST → POST /specify with mocked aux client → task
moves to 'ready' column on /board with new title and body applied.
2026-05-07 13:04:41 -07:00

2643 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.",
};
// Diagnostic kind labels for the events-tab callout. Event kinds emitted
// by the kernel get a human-readable header when we detect them in the
// events list; add new entries here as new diagnostic event kinds land.
const DIAGNOSTIC_EVENT_LABELS = {
completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids",
suspected_hallucinated_references: "⚠ Prose referenced phantom card ids",
};
function isDiagnosticEvent(kind) {
return Object.prototype.hasOwnProperty.call(DIAGNOSTIC_EVENT_LABELS, kind);
}
function phantomIdsFromEvent(ev) {
if (!ev || !ev.payload) return [];
const p = ev.payload;
return p.phantom_cards || p.phantom_refs || [];
}
function withCompletionSummary(patch, count) {
if (!patch || patch.status !== "done") return patch;
const label = count && count > 1 ? `${count} selected task(s)` : "this task";
const value = window.prompt(
`Completion summary for ${label}. This is stored as the task result.`,
"",
);
if (value === null) return null;
const summary = value.trim();
if (!summary) {
window.alert("Completion summary is required before marking a task done.");
return null;
}
return Object.assign({}, patch, { result: summary, summary });
}
const API = "/api/plugins/kanban";
const MIME_TASK = "text/x-hermes-task";
// localStorage key for the user's selected board. Independent of the
// CLI's on-disk ``<root>/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=<slug> 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 <select>). Older plugin
// code calls ``onChange({target: {value}})`` which silently never
// fires. This helper wires both signatures so a setter works with
// either API — use it as:
//
// h(Select, {..., ...selectChangeHandler(setState), ...})
function selectChangeHandler(setter) {
return {
onValueChange: function (v) { setter(v == null ? "" : v); },
onChange: function (e) {
const v = e && e.target ? e.target.value : e;
setter(v == null ? "" : v);
},
};
}
// -------------------------------------------------------------------------
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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) => `<code>${c}</code>`)
// bold
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
// italic
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
// safe links — only http(s) and mailto
.replace(
/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
(_m, text, href) =>
`<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
}
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("<ul>"); inList = true; }
out.push(`<li>${renderInline(bullet[1])}</li>`);
continue;
}
if (inList) { out.push("</ul>"); inList = false; }
if (heading) {
const level = heading[1].length;
out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
} else if (line.trim() === "") {
out.push("");
} else {
out.push(`<p>${renderInline(line)}</p>`);
}
}
if (inList) out.push("</ul>");
let html = out.join("\n");
// Re-insert fenced code blocks.
html = html.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) =>
`<pre class="hermes-kanban-md-code"><code>${escapeHtml(blocks[Number(i)])}</code></pre>`,
);
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 }),
size: "sm",
}, "Reload view"),
),
);
}
return this.props.children;
}
}
// -------------------------------------------------------------------------
// Root page
// -------------------------------------------------------------------------
function KanbanPage() {
const [board, setBoard] = useState(() => readSelectedBoard() || "default");
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
const [showNewBoard, setShowNewBoard] = useState(false);
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
// Alias so the rest of the function can keep using `board` semantically
// for the grid data (card columns + tenants + assignees) without
// colliding with the selected-board slug above. History: the old
// component had `const [board, setBoard]` for the grid data. We
// renamed the grid data to `kanbanBoard` so the more useful name
// (`board`) belongs to the selected slug.
const boardData = kanbanBoard;
const setBoardData = setKanbanBoard;
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(withBoard(url, board))
.then(function (data) {
setBoardData(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, board]);
// --- load list of boards for the switcher ------------------------------
const loadBoardList = useCallback(function () {
return SDK.fetchJSON(`${API}/boards`)
.then(function (data) {
const boards = (data && data.boards) || [];
setBoardList(boards);
// If the stored slug isn't in the list any longer (board was
// deleted in the CLI while dashboard was open), fall back to
// default so the UI doesn't hang on a 404.
if (board !== "default" && !boards.find(function (b) { return b.slug === board; })) {
setBoard("default");
writeSelectedBoard("default");
}
})
.catch(function () { /* non-fatal */ });
}, [board]);
useEffect(function () { loadBoardList(); }, [loadBoardList]);
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 (!boardData) 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 qsParams = {
since: String(cursorRef.current || 0),
token: token,
};
// Pin the WS stream to the currently-selected board so events
// from other boards don't bleed in. Includes "default" so the
// dashboard's own board pin always wins over the server-side
// ``current`` file — same rationale as ``withBoard()`` above.
// Regression: #20879.
if (board) qsParams.board = board;
const qs = new URLSearchParams(qsParams);
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 */ }
};
}, [!!boardData, board, scheduleReload]);
// --- filtering ----------------------------------------------------------
const filteredBoard = useMemo(function () {
if (!boardData) return null;
const q = search.trim().toLowerCase();
const filterTask = function (t) {
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();
if (hay.indexOf(q) === -1) return false;
}
return true;
};
return Object.assign({}, boardData, {
columns: boardData.columns.map(function (col) {
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
}),
});
}, [boardData, tenantFilter, assigneeFilter, search]);
// --- actions ------------------------------------------------------------
const moveTask = useCallback(function (taskId, newStatus) {
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
if (confirmMsg && !window.confirm(confirmMsg)) return;
const patch = withCompletionSummary({ status: newStatus }, 1);
if (!patch) return;
setBoardData(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(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
}).catch(function (err) {
setError(`Move failed: ${err.message || err}`);
loadBoard();
});
}, [loadBoard, board]);
const createTask = useCallback(function (body) {
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
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();
loadBoardList(); // refresh counts in the switcher
return res;
});
}, [loadBoard, loadBoardList, board]);
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 finalPatch = withCompletionSummary(patch, selectedIds.size);
if (!finalPatch) return;
const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch);
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
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, board]);
// --- board switching ----------------------------------------------------
const switchBoard = useCallback(function (nextSlug) {
if (!nextSlug || nextSlug === board) return;
// Optimistic UI: clear the current grid + show loading, reset the
// event cursor so the WS reopens aligned to the new board's
// latest_event_id on the next loadBoard.
setBoardData(null);
cursorRef.current = 0;
setLoading(true);
setBoard(nextSlug);
writeSelectedBoard(nextSlug);
}, [board]);
const createNewBoard = useCallback(function (payload) {
return SDK.fetchJSON(`${API}/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then(function (res) {
loadBoardList();
const slug = res && res.board && res.board.slug;
if (slug && payload.switch) switchBoard(slug);
return res;
});
}, [loadBoardList, switchBoard]);
const deleteBoard = useCallback(function (slug) {
if (!slug || slug === "default") return Promise.resolve();
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
method: "DELETE",
}).then(function () {
loadBoardList();
if (board === slug) switchBoard("default");
});
}, [board, loadBoardList, switchBoard]);
// --- render -------------------------------------------------------------
if (loading && !boardData) {
return h("div", { className: "p-8 text-sm text-muted-foreground" },
"Loading Kanban board…");
}
if (error && !boardData) {
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(BoardSwitcher, {
board: board,
boardList: boardList,
onSwitch: switchBoard,
onNewClick: function () { setShowNewBoard(true); },
onDeleteBoard: deleteBoard,
}),
showNewBoard ? h(NewBoardDialog, {
onCancel: function () { setShowNewBoard(false); },
onCreate: function (payload) {
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
h(AttentionStrip, {
boardData,
onOpen: setSelectedTaskId,
}),
h(BoardToolbar, {
board: boardData,
tenantFilter, setTenantFilter,
assigneeFilter, setAssigneeFilter,
includeArchived, setIncludeArchived,
laneByProfile, setLaneByProfile,
search, setSearch,
onNudgeDispatch: function () {
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
.then(loadBoard)
.catch(function (e) { setError(String(e.message || e)); });
},
onRefresh: loadBoard,
}),
selectedIds.size > 0 ? h(BulkActionBar, {
count: selectedIds.size,
assignees: (boardData && boardData.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: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
}),
selectedTaskId ? h(TaskDrawer, {
taskId: selectedTaskId,
boardSlug: board,
onClose: function () { setSelectedTaskId(null); },
onRefresh: loadBoard,
renderMarkdown: renderMd,
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
assignees: (boardData && boardData.assignees) || [],
eventTick: taskEventTick[selectedTaskId] || 0,
}) : null,
),
);
}
// -------------------------------------------------------------------------
// Attention strip — surfaces every task with active diagnostics,
// severity-marked (warning/error/critical). Collapsed by default; click
// Show to expand into per-task rows with Open buttons. Dismissible
// per session via state flag.
// -------------------------------------------------------------------------
function collectDiagTasks(boardData) {
if (!boardData || !boardData.columns) return [];
const out = [];
for (const col of boardData.columns) {
for (const t of col.tasks || []) {
if (t.diagnostics && t.diagnostics.length > 0) out.push(t);
else if (t.warnings && t.warnings.count > 0) out.push(t);
}
}
// Sort: highest severity first (critical > error > warning), then by
// most recent latest_at.
const sevIdx = function (s) {
if (s === "critical") return 3;
if (s === "error") return 2;
if (s === "warning") return 1;
return 0;
};
out.sort(function (a, b) {
const aSev = sevIdx((a.warnings && a.warnings.highest_severity) || "warning");
const bSev = sevIdx((b.warnings && b.warnings.highest_severity) || "warning");
if (aSev !== bSev) return bSev - aSev;
const aLa = (a.warnings && a.warnings.latest_at) || 0;
const bLa = (b.warnings && b.warnings.latest_at) || 0;
return bLa - aLa;
});
return out;
}
function AttentionStrip(props) {
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState(false);
const diagTasks = useMemo(
function () { return collectDiagTasks(props.boardData); },
[props.boardData]
);
if (dismissed || diagTasks.length === 0) return null;
// Pick the highest severity present so we can colour the strip.
let topSev = "warning";
for (const t of diagTasks) {
const s = (t.warnings && t.warnings.highest_severity) || "warning";
if (s === "critical") { topSev = "critical"; break; }
if (s === "error" && topSev !== "critical") topSev = "error";
}
return h("div", {
className: cn(
"hermes-kanban-attention",
"hermes-kanban-attention--" + topSev,
),
},
h("div", { className: "hermes-kanban-attention-bar" },
h("span", { className: "hermes-kanban-attention-icon" },
topSev === "critical" ? "!!!" : topSev === "error" ? "!!" : "⚠"),
h("span", { className: "hermes-kanban-attention-text" },
diagTasks.length === 1
? "1 task needs attention"
: `${diagTasks.length} tasks need attention`,
),
h("button", {
className: "hermes-kanban-attention-toggle",
onClick: function () { setExpanded(function (x) { return !x; }); },
type: "button",
}, expanded ? "Hide" : "Show"),
h("button", {
className: "hermes-kanban-attention-dismiss",
onClick: function () { setDismissed(true); },
title: "Hide until next page reload",
type: "button",
}, "\u2715"),
),
expanded
? h("div", { className: "hermes-kanban-attention-list" },
diagTasks.map(function (t) {
const sev = (t.warnings && t.warnings.highest_severity) || "warning";
const kinds = t.warnings && t.warnings.kinds ? Object.keys(t.warnings.kinds) : [];
return h("div", {
key: t.id,
className: cn(
"hermes-kanban-attention-row",
"hermes-kanban-attention-row--" + sev,
),
},
h("span", { className: "hermes-kanban-attention-row-sev" },
sev === "critical" ? "!!!" : sev === "error" ? "!!" : "⚠"),
h("span", { className: "hermes-kanban-attention-row-id" }, t.id),
h("span", { className: "hermes-kanban-attention-row-title" },
t.title || "(untitled)"),
h("span", { className: "hermes-kanban-attention-row-meta" },
t.assignee ? "@" + t.assignee : "unassigned",
" \u00b7 ",
kinds.length > 0 ? kinds.join(", ") : "diagnostic",
),
h("button", {
className: "hermes-kanban-attention-row-btn",
onClick: function () { props.onOpen(t.id); },
type: "button",
}, "Open"),
);
}),
)
: null,
);
}
// -------------------------------------------------------------------------
// Diagnostics section — generic renderer for a task's active distress
// signals. Each diagnostic carries its own title, detail, data payload,
// and a list of structured actions; the section renders them uniformly
// regardless of kind. Replaces the hallucination-specific
// ``RecoveryPopover`` from the previous iteration.
//
// Action kinds supported today:
// reclaim → POST /tasks/:id/reclaim
// reassign → POST /tasks/:id/reassign (with profile picker)
// unblock → PATCH /tasks/:id body: {status: "ready"}
// comment → scroll to the comment input at the bottom of the drawer
// cli_hint → copy payload.command to clipboard
// open_docs → open payload.url in a new tab
// Unknown kinds are rendered as a disabled informational row so the
// server can add new action kinds without breaking the UI.
// -------------------------------------------------------------------------
function DiagnosticActionButton(props) {
const { action, onExec, busy, extra } = props;
const label = (action.suggested ? "\u2606 " : "") + action.label;
const cls = cn(
"hermes-kanban-diag-action-btn",
action.suggested ? "hermes-kanban-diag-action-btn--suggested" : "",
);
if (action.kind === "reclaim" || action.kind === "reassign" ||
action.kind === "unblock") {
return h("button", {
className: cls,
disabled: busy || (extra && extra.disabled),
onClick: function () { onExec(action); },
type: "button",
}, label);
}
if (action.kind === "cli_hint") {
return h("button", {
className: cls,
disabled: busy,
onClick: function () { onExec(action); },
type: "button",
title: "Copy command to clipboard",
}, (extra && extra.copied) ? "Copied" : label);
}
if (action.kind === "comment") {
return h("button", {
className: cls,
onClick: function () { onExec(action); },
type: "button",
}, label);
}
if (action.kind === "open_docs") {
return h("a", {
className: cls,
href: (action.payload && action.payload.url) || "#",
target: "_blank",
rel: "noreferrer",
}, label);
}
// Unknown kind — render informational, non-interactive.
return h("span", { className: cls + " hermes-kanban-diag-action-btn--unknown" },
label);
}
function DiagnosticCard(props) {
const { diag, task, boardSlug, assignees, onRefresh } = props;
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const [copiedKey, setCopiedKey] = useState(null);
const [reassignProfile, setReassignProfile] = useState(task.assignee || "");
const execAction = function (action) {
if (busy) return;
if (action.kind === "cli_hint") {
const cmd = (action.payload && action.payload.command) || action.label;
const fallback = function () { window.prompt("Copy this command:", cmd); };
try {
const p = navigator.clipboard && navigator.clipboard.writeText(cmd);
if (p && p.then) {
p.then(function () {
setCopiedKey(action.label);
setTimeout(function () { setCopiedKey(null); }, 2000);
}).catch(fallback);
} else {
fallback();
}
} catch (_) {
fallback();
}
return;
}
if (action.kind === "comment") {
// Scroll the comment input into view; the drawer already has one
// at the bottom. Focus it so the operator can start typing.
const ta = document.querySelector(".hermes-kanban-drawer-comment-row input, .hermes-kanban-drawer-comment-row textarea");
if (ta) {
ta.scrollIntoView({ behavior: "smooth", block: "nearest" });
ta.focus();
}
return;
}
if (action.kind === "unblock") {
setBusy(true); setMsg(null);
const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}`, boardSlug);
SDK.fetchJSON(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ready" }),
}).then(function () {
setMsg({ ok: true, text: `Unblocked ${task.id}. Task is ready for the next tick.` });
if (onRefresh) onRefresh();
}).catch(function (err) {
setMsg({ ok: false, text: `Unblock failed: ${err.message || err}` });
}).then(function () { setBusy(false); });
return;
}
if (action.kind === "reclaim") {
setBusy(true); setMsg(null);
const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}/reclaim`, boardSlug);
SDK.fetchJSON(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: `recovery action for ${diag.kind}` }),
}).then(function () {
setMsg({ ok: true, text: `Reclaimed ${task.id}. Task is back to ready.` });
if (onRefresh) onRefresh();
}).catch(function (err) {
setMsg({ ok: false, text: `Reclaim failed: ${err.message || err}` });
}).then(function () { setBusy(false); });
return;
}
if (action.kind === "reassign") {
if (!reassignProfile) {
setMsg({ ok: false, text: "Pick a profile first." });
return;
}
setBusy(true); setMsg(null);
const url = withBoard(`${API}/tasks/${encodeURIComponent(task.id)}/reassign`, boardSlug);
const body = {
profile: reassignProfile || null,
reclaim_first: !!(action.payload && action.payload.reclaim_first),
reason: `recovery action for ${diag.kind}`,
};
SDK.fetchJSON(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(function () {
setMsg({
ok: true,
text: `Reassigned ${task.id} to ${reassignProfile}.`,
});
if (onRefresh) onRefresh();
}).catch(function (err) {
setMsg({ ok: false, text: `Reassign failed: ${err.message || err}` });
}).then(function () { setBusy(false); });
return;
}
};
// Pull out the reassign action so we can render its picker inline.
const reassignAction = (diag.actions || []).find(function (a) {
return a.kind === "reassign";
});
const sevClass = "hermes-kanban-diag--" + (diag.severity || "warning");
return h("div", { className: cn("hermes-kanban-diag", sevClass) },
h("div", { className: "hermes-kanban-diag-header" },
h("span", { className: "hermes-kanban-diag-sev" },
diag.severity === "critical" ? "!!!" :
diag.severity === "error" ? "!!" : "\u26a0"),
h("span", { className: "hermes-kanban-diag-title" },
diag.title),
),
h("div", { className: "hermes-kanban-diag-detail" },
diag.detail),
diag.data && Object.keys(diag.data).length > 0
? h("div", { className: "hermes-kanban-diag-data" },
Object.keys(diag.data).map(function (k) {
const v = diag.data[k];
if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string" &&
v[0].indexOf("t_") === 0) {
// Task-id list — render as chips.
return h("div", { key: k, className: "hermes-kanban-diag-data-row" },
h("span", { className: "hermes-kanban-diag-data-key" }, k + ":"),
v.map(function (x) {
return h("code", {
key: x, className: "hermes-kanban-event-phantom-chip",
}, x);
}),
);
}
return h("div", { key: k, className: "hermes-kanban-diag-data-row" },
h("span", { className: "hermes-kanban-diag-data-key" }, k + ":"),
h("span", { className: "hermes-kanban-diag-data-val" },
Array.isArray(v) ? v.join(", ") : String(v)),
);
}),
)
: null,
// Inline reassign picker — only shown when the diagnostic offers
// a reassign action. Profile list comes from the board payload.
reassignAction
? h("div", { className: "hermes-kanban-diag-reassign-row" },
h("span", { className: "hermes-kanban-diag-reassign-label" },
"Reassign to:"),
h("select", {
className: "hermes-kanban-recovery-select",
value: reassignProfile,
onChange: function (e) { setReassignProfile(e.target.value); },
},
h("option", { value: "" }, "(unassigned)"),
(assignees || []).map(function (a) {
return h("option", { key: a, value: a }, a);
}),
),
)
: null,
h("div", { className: "hermes-kanban-diag-actions" },
(diag.actions || []).map(function (a, i) {
return h(DiagnosticActionButton, {
key: a.kind + i,
action: a,
onExec: execAction,
busy: busy,
extra: {
copied: copiedKey === a.label,
disabled: (a.kind === "reassign" && !reassignProfile),
},
});
}),
),
msg
? h("div", {
className: cn(
"hermes-kanban-diag-msg",
msg.ok ? "hermes-kanban-diag-msg--ok" : "hermes-kanban-diag-msg--err",
),
}, msg.text)
: null,
);
}
function DiagnosticsSection(props) {
const diags = props.diagnostics || [];
const hasOpenDiags = diags.length > 0;
const [open, setOpen] = useState(hasOpenDiags);
useEffect(function () {
if (hasOpenDiags) setOpen(true);
}, [hasOpenDiags]);
if (!hasOpenDiags && !props.alwaysVisible) {
// Nothing active. Collapse the section entirely rather than showing
// an empty "Recovery" header — keeps clean tasks visually clean.
return null;
}
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head-row" },
h("span", { className: "hermes-kanban-section-head" },
hasOpenDiags
? h("span", { className: "hermes-kanban-section-head-warning" },
`\u26a0 Diagnostics (${diags.length})`)
: "Diagnostics",
),
h("button", {
className: "hermes-kanban-section-toggle",
onClick: function () { setOpen(function (x) { return !x; }); },
type: "button",
}, open ? "Hide" : "Show"),
),
open
? h("div", { className: "hermes-kanban-diag-list" },
diags.map(function (d, i) {
return h(DiagnosticCard, {
key: props.task.id + ":" + d.kind + i,
diag: d,
task: props.task,
boardSlug: props.boardSlug,
assignees: props.assignees,
onRefresh: props.onRefresh,
});
}),
)
: null,
);
}
// -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
function BoardSwitcher(props) {
const list = props.boardList || [];
const current = list.find(function (b) { return b.slug === props.board; });
const currentName = current && current.name ? current.name : props.board;
const currentTotal = current ? current.total : 0;
const hasMultipleBoards = list.length > 1;
// Hide entirely when only the default board exists AND it's empty —
// single-project users never see boards UI unless they ask for it.
// We show the [+ New board] affordance as soon as any board has a
// task (so the user can discover multi-project before they need it)
// OR when any non-default board exists.
const totalAcrossAllBoards = list.reduce(function (n, b) { return n + (b.total || 0); }, 0);
const shouldShow = hasMultipleBoards || totalAcrossAllBoards > 0;
if (!shouldShow) {
return h("div", {
className: "hermes-kanban-boardswitcher-compact",
title: "Boards let you separate unrelated streams of work",
},
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-7 text-xs",
}, "+ New board"),
);
}
return h("div", { className: "hermes-kanban-boardswitcher" },
h("div", { className: "hermes-kanban-boardswitcher-inner" },
h("div", { className: "flex flex-col gap-0.5" },
h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" },
"Board"),
h("div", { className: "flex items-center gap-2" },
h(Select, Object.assign({
value: props.board,
className: "h-8 min-w-[220px]",
"aria-label": "Switch kanban board",
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
list.map(function (b) {
const label = b.total > 0
? `${b.name || b.slug} · ${b.total}`
: (b.name || b.slug);
return h(SelectOption, { key: b.slug, value: b.slug }, label);
}),
),
h("span", { className: "text-xs text-muted-foreground" },
`${currentTotal || 0} task${currentTotal === 1 ? "" : "s"}`),
),
),
h("div", { className: "flex-1" }),
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-8",
}, "+ New board"),
props.board !== "default"
? h(Button, {
onClick: function () {
const msg =
`Archive board '${currentName}'? ` +
`It will be moved to boards/_archived/ so you can recover it later. ` +
`Tasks on this board will no longer appear anywhere in the UI.`;
if (window.confirm(msg)) props.onDeleteBoard(props.board);
},
size: "sm",
className: "h-8",
title: "Archive this board",
}, "Archive")
: null,
),
);
}
function NewBoardDialog(props) {
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [switchTo, setSwitchTo] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(null);
// Auto-derive a name from the slug if the user hasn't typed one.
const autoName = useMemo(function () {
if (!slug) return "";
return slug.replace(/[-_]+/g, " ")
.split(" ")
.filter(Boolean)
.map(function (w) { return w[0].toUpperCase() + w.slice(1); })
.join(" ");
}, [slug]);
function onSubmit(ev) {
if (ev) ev.preventDefault();
if (!slug.trim()) { setErr("slug is required"); return; }
setSubmitting(true);
setErr(null);
props.onCreate({
slug: slug.trim(),
name: name.trim() || autoName || undefined,
description: description.trim() || undefined,
icon: icon.trim() || undefined,
switch: switchTo,
}).catch(function (e) {
setErr(String(e && e.message ? e.message : e));
setSubmitting(false);
});
}
return h("div", {
className: "hermes-kanban-dialog-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
},
h("form", {
className: "hermes-kanban-dialog",
onSubmit: onSubmit,
},
h("div", { className: "hermes-kanban-dialog-title" }, "New board"),
h("div", { className: "text-xs text-muted-foreground mb-2" },
"Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks."),
h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Slug ",
h("span", { className: "text-muted-foreground" },
"— lowercase, hyphens, e.g. atm10-server")),
h(Input, {
value: slug,
onChange: function (e) { setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9\-_]/g, "-")); },
placeholder: "atm10-server",
autoFocus: true,
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Display name ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: name,
onChange: function (e) { setName(e.target.value); },
placeholder: autoName || "Display name",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Description ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: description,
onChange: function (e) { setDescription(e.target.value); },
placeholder: "What goes on this board?",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Icon ",
h("span", { className: "text-muted-foreground" }, "(single character or emoji)")),
h(Input, {
value: icon,
onChange: function (e) { setIcon(e.target.value.slice(0, 4)); },
placeholder: "📦",
className: "h-8 w-24",
}),
),
h("label", { className: "flex items-center gap-2 text-xs" },
h("input", {
type: "checkbox",
checked: switchTo,
onChange: function (e) { setSwitchTo(e.target.checked); },
}),
"Switch to this board after creating it",
),
),
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
h("div", { className: "hermes-kanban-dialog-actions" },
h(Button, {
type: "button",
onClick: props.onCancel,
size: "sm",
disabled: submitting,
}, "Cancel"),
h(Button, {
type: "submit",
size: "sm",
disabled: submitting || !slug.trim(),
}, submitting ? "Creating…" : "Create board"),
),
),
);
}
// -------------------------------------------------------------------------
// 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, Object.assign({
value: props.tenantFilter,
className: "h-8",
}, selectChangeHandler(props.setTenantFilter)),
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, Object.assign({
value: props.assigneeFilter,
className: "h-8",
}, selectChangeHandler(props.setAssigneeFilter)),
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,
size: "sm",
}, "Nudge dispatcher"),
h(Button, {
onClick: props.onRefresh,
size: "sm",
}, "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" }); },
size: "sm",
}, "→ ready"),
h(Button, {
onClick: function () {
props.onApply({ status: "done" },
`Mark ${props.count} task(s) as done?`);
},
size: "sm",
}, "Complete"),
h(Button, {
onClick: function () {
props.onApply({ archive: true },
`Archive ${props.count} task(s)?`);
},
size: "sm",
}, "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,
size: "sm",
}, "Apply"),
),
h("div", { className: "flex-1" }),
h(Button, {
onClick: props.onClear,
size: "sm",
}, "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.warnings && t.warnings.count > 0
? h("span", {
className: cn(
"hermes-kanban-warning-badge",
"hermes-kanban-warning-badge--" + (t.warnings.highest_severity || "warning"),
),
title: (
`${t.warnings.count} active diagnostic` +
(t.warnings.count === 1 ? "" : "s") +
` (severity: ${t.warnings.highest_severity || "warning"}). ` +
`Click to open for details.`
),
}, t.warnings.highest_severity === "critical" ? "!!!" :
t.warnings.highest_severity === "error" ? "!!" : "⚠")
: null,
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("");
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
// takes a path (dispatcher derives one from the assignee profile otherwise);
// `dir` requires a path. Backend enforces the rule — we only hide/show the
// input here to save vertical space in the common `scratch` case.
const [workspaceKind, setWorkspaceKind] = useState("scratch");
const [workspacePath, setWorkspacePath] = 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;
// Only send workspace_kind when it's non-default. Keeps the request
// shape small and interoperable with older dispatcher versions.
if (workspaceKind && workspaceKind !== "scratch") {
body.workspace_kind = workspaceKind;
}
const wpTrim = workspacePath.trim();
if (wpTrim) body.workspace_path = wpTrim;
props.onSubmit(body);
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
setWorkspaceKind("scratch"); setWorkspacePath("");
};
const showPathInput = workspaceKind !== "scratch";
const pathPlaceholder = workspaceKind === "dir"
? "workspace path (required, e.g. ~/projects/my-app)"
: "workspace path (optional, derived from assignee if blank)";
return h("div", { className: "hermes-kanban-inline-create" },
h("textarea", {
value: title,
onChange: function (e) { setTitle(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter" && !e.shiftKey) { 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: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring",
rows: 2,
}),
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("div", { className: "flex gap-2" },
h(Select, {
value: workspaceKind,
onChange: function (e) { setWorkspaceKind(e.target.value); },
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
className: "h-7 text-xs w-28",
},
h(SelectOption, { value: "scratch" }, "scratch"),
h(SelectOption, { value: "worktree" }, "worktree"),
h(SelectOption, { value: "dir" }, "dir"),
),
showPathInput ? h(Input, {
value: workspacePath,
onChange: function (e) { setWorkspacePath(e.target.value); },
placeholder: pathPlaceholder,
className: "h-7 text-xs flex-1",
}) : null,
),
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,
size: "sm",
}, "Create"),
h(Button, {
onClick: props.onCancel,
size: "sm",
}, "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);
// Home-channel notification toggles. homeChannels is the list of platforms
// the user has a /sethome on; each entry has a `subscribed` bool telling
// us whether this task is currently subscribed via that platform's home.
const [homeChannels, setHomeChannels] = useState([]);
const [homeBusy, setHomeBusy] = useState({});
const boardSlug = props.boardSlug;
const load = useCallback(function () {
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug))
.then(function (d) { setData(d); setErr(null); })
.catch(function (e) { setErr(String(e.message || e)); })
.finally(function () { setLoading(false); });
}, [props.taskId, boardSlug]);
const loadHomeChannels = useCallback(function () {
const qs = new URLSearchParams({ task_id: props.taskId });
const url = withBoard(`${API}/home-channels?${qs}`, boardSlug);
return SDK.fetchJSON(url)
.then(function (d) { setHomeChannels(d.home_channels || []); })
.catch(function () { /* silent — endpoint optional on older gateways */ });
}, [props.taskId, boardSlug]);
// 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 () { loadHomeChannels(); }, [loadHomeChannels]);
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(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
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();
}
const finalPatch = withCompletionSummary(patch, 1);
if (!finalPatch) return Promise.resolve();
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(finalPatch),
}).then(function () { load(); props.onRefresh(); });
};
// Triage specifier — calls the auxiliary LLM to flesh out a rough
// idea in the Triage column into a concrete spec (title + body with
// goal, approach, acceptance criteria) and promotes it to todo.
// Not a PATCH: runs through a dedicated POST endpoint because the
// LLM call can take tens of seconds, and its outcome is richer than
// a status flip (may update title AND body AND emit an audit
// comment — or fail with a human-readable reason that the UI
// surfaces inline without treating it as an HTTP error).
const doSpecify = function () {
return SDK.fetchJSON(
withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/specify`, boardSlug),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
}
).then(function (res) {
load();
props.onRefresh();
return res;
});
};
const addLink = function (parentId) {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
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(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
const addChild = function (childId) {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
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(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
const toggleHomeSubscription = function (platform, currentlySubscribed) {
// Optimistic flip + busy flag to keep double-clicks idempotent.
setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); });
setHomeChannels(function (list) {
return list.map(function (h) {
return h.platform === platform
? Object.assign({}, h, { subscribed: !currentlySubscribed })
: h;
});
});
const method = currentlySubscribed ? "DELETE" : "POST";
const url = withBoard(
`${API}/tasks/${encodeURIComponent(props.taskId)}/home-subscribe/${encodeURIComponent(platform)}`,
boardSlug,
);
return SDK.fetchJSON(url, { method: method })
.then(function () { return loadHomeChannels(); })
.catch(function (e) {
// Revert optimistic flip on failure.
setHomeChannels(function (list) {
return list.map(function (h) {
return h.platform === platform
? Object.assign({}, h, { subscribed: currentlySubscribed })
: h;
});
});
setErr(String(e.message || e));
})
.finally(function () {
setHomeBusy(function (b) {
const next = Object.assign({}, b);
delete next[platform];
return next;
});
});
};
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,
assignees: props.assignees || [],
boardSlug: boardSlug,
onPatch: doPatch,
onSpecify: doSpecify,
onAddParent: addLink,
onRemoveParent: removeLink,
onAddChild: addChild,
onRemoveChild: removeChild,
homeChannels: homeChannels,
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
}) : 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,
size: "sm",
}, "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,
onSpecify: props.onSpecify,
}),
h(DiagnosticsSection, {
task: t,
boardSlug: props.boardSlug,
assignees: props.assignees,
diagnostics: t.diagnostics || [],
onRefresh: props.onRefresh,
}),
h(HomeSubsSection, {
homeChannels: props.homeChannels || [],
homeBusy: props.homeBusy || {},
onToggle: props.onToggleHomeSub,
}),
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) {
const isDiag = isDiagnosticEvent(e.kind);
const phantoms = isDiag ? phantomIdsFromEvent(e) : [];
return h("div", {
key: e.id,
className: cn(
"hermes-kanban-event",
isDiag ? "hermes-kanban-event--hallucination" : "",
),
},
isDiag
? h("div", { className: "hermes-kanban-event-header" },
h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"),
h("span", { className: "hermes-kanban-event-warning-label" },
DIAGNOSTIC_EVENT_LABELS[e.kind] || e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
)
: h("div", { className: "hermes-kanban-event-header-plain" },
h("span", { className: "hermes-kanban-event-kind" }, e.kind),
h("span", { className: "hermes-kanban-event-ago" },
timeAgo ? timeAgo(e.created_at) : ""),
),
isDiag && phantoms.length > 0
? h("div", { className: "hermes-kanban-event-phantom-row" },
h("span", { className: "hermes-kanban-event-phantom-label" },
"Phantom ids:"),
phantoms.map(function (pid) {
return h("code", {
key: pid,
className: "hermes-kanban-event-phantom-chip",
}, pid);
}),
)
: null,
e.payload && !isDiag
? h("code", { className: "hermes-kanban-event-payload" },
JSON.stringify(e.payload))
: null,
);
}),
),
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
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(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`, props.boardSlug))
.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, props.boardSlug]);
// 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,
size: "sm",
}, "Save"),
h(Button, { onClick: props.onCancel,
size: "sm",
}, "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,
size: "sm",
}, "Save"),
h(Button, { onClick: function () { setEditing(false); setV(props.task.body || ""); },
size: "sm",
}, "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, Object.assign({
value: newParent,
className: "h-7 text-xs flex-1",
}, selectChangeHandler(setNewParent)),
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,
size: "sm",
}, "+ 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, Object.assign({
value: newChild,
className: "h-7 text-xs flex-1",
}, selectChangeHandler(setNewChild)),
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,
size: "sm",
}, "+ child"),
),
);
}
function StatusActions(props) {
const t = props.task;
const [specifyBusy, setSpecifyBusy] = useState(false);
const [specifyMsg, setSpecifyMsg] = useState(null);
const b = function (label, patch, enabled, confirmMsg) {
return h(Button, {
onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); },
disabled: enabled === false,
size: "sm",
}, label);
};
// "Specify" appears only when the task is in the Triage column — the
// one column where an auxiliary LLM pass is meaningful. Elsewhere
// the backend would return ok:false with "not in triage" anyway,
// so hiding the button keeps the action row uncluttered.
const specifyButton = (t.status === "triage" && props.onSpecify)
? h(Button, {
onClick: function () {
if (specifyBusy) return;
setSpecifyBusy(true);
setSpecifyMsg(null);
props.onSpecify().then(function (res) {
if (res && res.ok) {
const suffix = res.new_title
? ` — retitled: ${res.new_title}`
: "";
setSpecifyMsg({ ok: true, text: `Specified${suffix}` });
} else {
setSpecifyMsg({
ok: false,
text: "Specify failed: " + ((res && res.reason) || "unknown error"),
});
}
}).catch(function (err) {
setSpecifyMsg({
ok: false,
text: "Specify failed: " + (err.message || String(err)),
});
}).then(function () {
setSpecifyBusy(false);
});
},
disabled: specifyBusy,
size: "sm",
}, specifyBusy ? "Specifying…" : "✨ Specify")
: null;
return h("div", null,
h("div", { className: "hermes-kanban-actions" },
specifyButton,
b("→ triage", { status: "triage" }, t.status !== "triage"),
b("→ ready", { status: "ready" }, t.status !== "ready"),
// No direct → running button: /tasks/:id PATCH rejects status=running
// with 400 (issue #19535). Tasks enter running only through the
// dispatcher's claim_task path, which atomically creates the run row,
// claim lock, and worker process metadata.
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),
),
specifyMsg ? h("div", {
className: specifyMsg.ok
? "hermes-kanban-msg-ok"
: "hermes-kanban-msg-err",
}, specifyMsg.text) : null,
);
}
// One toggle per gateway platform the user has a home channel set on
// (telegram, discord, slack, etc.). Toggling on creates a kanban_notify_subs
// row routed to that platform's home; toggling off removes it. Nothing
// renders when no platforms have a home configured — this section stays
// invisible for users who haven't set one up.
function HomeSubsSection(props) {
const channels = props.homeChannels || [];
if (channels.length === 0) return null;
const busy = props.homeBusy || {};
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
"Notify home channels"),
h("div", { className: "hermes-kanban-home-subs" },
channels.map(function (hc) {
const isBusy = !!busy[hc.platform];
const label = hc.subscribed ? "✓ " + hc.platform : hc.platform;
const title = hc.subscribed
? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.`
: `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`;
return h(Button, {
key: hc.platform,
size: "sm",
title: title,
disabled: isBusy || !props.onToggle,
onClick: function () {
if (props.onToggle) props.onToggle(hc.platform, hc.subscribed);
},
className: hc.subscribed
? "hermes-kanban-home-sub hermes-kanban-home-sub--on"
: "hermes-kanban-home-sub",
}, label);
})
)
);
}
// -------------------------------------------------------------------------
// Register
// -------------------------------------------------------------------------
if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") {
window.__HERMES_PLUGINS__.register("kanban", KanbanPage);
}
})();