mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Eliminates every 'known broken on day one' item in the core functionality
audit. The board is now self-driving (daemon, not cron), self-healing
(crash detection, spawn-failure circuit breaker), and self-reporting
(logs, stats, gateway notifications).
Dispatcher
- New `hermes kanban daemon` long-lived loop with --interval, --max,
--failure-limit, --pidfile, --verbose, signal-clean shutdown
(SIGINT/SIGTERM via threading.Event). A kb.run_daemon() entry point
lets tests drive it inline without subprocess.
- `hermes kanban init` now prints the dispatcher setup hint so users
don't leave the board off-by-default. Ships a systemd user unit at
plugins/kanban/systemd/hermes-kanban-dispatcher.service.
- Removed the old 'add this to cron' doc path. Cron runs agent
prompts (LLM cost per tick) — unacceptable for a per-minute
coordination loop.
Worker aliveness / safety
- Spawn returns the child's PID; dispatcher stores it on the task row
and calls detect_crashed_workers() every tick. If the PID is gone
but the claim TTL hasn't expired, the task drops back to ready with
a 'crashed' event. Host-local only — cross-host PIDs are ignored
per the single-host design.
- Spawn-failure circuit breaker: after N consecutive spawn_failed
events on the same task (default 5), the dispatcher auto-blocks
with the last error as the reason. Success resets the counter.
Workspace-resolution failures count against the same budget.
- Log rotation: _rotate_worker_log trims at 2 MiB, keeps one
generation (.log.1), bounds per-task disk usage at ~4 MiB.
Idempotency / dedup
- create_task(idempotency_key=...) returns the existing non-archived
task id for retried webhooks. --idempotency-key on the CLI, json
body field on the dashboard plugin. Archived tasks don't block a
fresh create with the same key.
CLI surface
- Bulk verbs: complete, unblock, archive accept multiple ids;
block accepts --ids for sibling blocks with the same reason.
- New verbs: daemon, watch (live event tail filtered by
assignee/tenant/kinds), stats, log, notify-subscribe,
notify-list, notify-unsubscribe.
- dispatch gains --failure-limit + crashed/auto_blocked columns in
JSON output and human-readable output.
- gc accepts --event-retention-days / --log-retention-days; prunes
task_events for terminal tasks and old log files.
Gateway integration
- New GatewayRunner._kanban_notifier_watcher: polls
kanban_notify_subs every 5s, pushes ✔/⏸/✖ messages to subscribed
chats for completed/blocked/spawn_auto_blocked/crashed events.
Cursor-advanced per-sub; auto-removed when the task reaches
done/archived. Runs alongside the session expiry and platform
reconnect watchers — SQLite work in asyncio.to_thread so the
event loop never blocks.
- /kanban create in the gateway auto-subscribes the originating
chat (platform + chat_id + thread_id). Users see
'(subscribed — you'll be notified when t_abcd completes or
blocks)' appended to the response.
Dashboard plugin
- GET /stats returns board_stats (by_status, by_assignee,
oldest_ready_age_seconds).
- GET /tasks/:id/log returns the worker log with optional ?tail=N
cap. 404 on unknown task, exists=false when the task has never
spawned.
- POST /tasks accepts idempotency_key; both Pydantic body and the
create_task kwarg now round-trip.
- /board attaches task.age (created/started/time_to_complete in
seconds) so the UI can colour stale cards without recomputing.
- Card CSS: amber border after N minutes, red border when clearly
stuck (tier per status: running 10m/60m, ready 1h/24h, todo
7d/30d, blocked 1h/24h).
- Drawer: new Worker log section, auto-loads on mount, last 100 KB
cap with on-disk path surfaced when truncated.
Kernel
- Schema additions: tasks.idempotency_key, tasks.spawn_failures,
tasks.worker_pid, tasks.last_spawn_error; new
kanban_notify_subs table. All gated by _migrate_add_optional_columns
so legacy DBs upgrade cleanly.
- release_stale_claims / complete_task / block_task now all clear
worker_pid so crash detection doesn't false-positive on reclaimed
tasks.
- read_worker_log fixed: tail-skip no longer eats one-giant-line
logs (common with child processes that don't flush newlines
before dying).
Tests (tests/hermes_cli/test_kanban_core_functionality.py, 28 new)
- Idempotency: same key returns existing, archived doesn't block,
no key never collides
- Circuit breaker: auto-blocks after limit, success resets counter,
workspace-resolution failure counts against budget
- Aliveness: _pid_alive helper, detect_crashed_workers reclaims
exited child
- Daemon: runs and stops cleanly via stop_event, survives a tick
exception
- Stats + task_age helpers
- Notify subs: CRUD, cursor advances, distinct-thread is a separate row
- GC: events-only-for-terminal-tasks, old worker logs deleted
- Log: rotation keeps one generation, read_worker_log tail
- CLI: bulk complete/archive/unblock/block, create with
--idempotency-key, stats --json, notify-subscribe+list, log
missing task, gc reports counts
- run_slash parity: smoke-tests every registered verb (23
invocations); none may raise or return empty string
Full kanban test suite: 234/234 pass under scripts/run_tests.sh
(60 original + 30 dashboard plugin + 28 new core + 116 command
registry). Live smoke covers /stats, idempotency, age, log endpoint
with and without content, log?tail= truncation signal, 404 on unknown
task.
Docs (website/docs/user-guide/features/kanban.md)
- 'Core concepts' rewritten: new statuses (triage), idempotency key,
dispatcher-as-daemon-not-cron with circuit breaker behaviour
documented.
- Quick start swapped to daemon. New systemd section covers user
service install.
- New sections: idempotent create, bulk verbs, gateway
notifications, out-of-scope single-host note (kanban.db is local;
don't expect multi-host).
- CLI reference updated for every new verb, every new flag.
1493 lines
57 KiB
JavaScript
1493 lines
57 KiB
JavaScript
/**
|
||
* 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, """)
|
||
.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) => `<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 }),
|
||
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());
|
||
|
||
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;
|
||
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(loadBoard);
|
||
}, [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); }, []),
|
||
}) : 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 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];
|
||
props.onSubmit(body);
|
||
setTitle(""); setAssignee(""); setPriority(0); setParent("");
|
||
};
|
||
|
||
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(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]);
|
||
|
||
useEffect(function () { load(); }, [load]);
|
||
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.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 }),
|
||
);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
})();
|