mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(kanban): multi-project boards — one install, many kanbans (#19653)
Adds first-class board support to kanban so users can separate unrelated
streams of work (projects, repos, domains) into isolated queues. Single-
project users stay on the 'default' board and see no UI change.
Isolation model
---------------
- Each board is a directory at `~/.hermes/kanban/boards/<slug>/` with
its own `kanban.db`, `workspaces/`, and `logs/`. The 'default' board
keeps its legacy path (`~/.hermes/kanban.db`) for back-compat — fresh
installs and pre-boards users get zero migration.
- Workers spawned by the dispatcher have `HERMES_KANBAN_BOARD` pinned in
their env alongside the existing `HERMES_KANBAN_DB` /
`HERMES_KANBAN_WORKSPACES_ROOT` pins, so workers physically cannot see
other boards' tasks.
- The gateway's single dispatcher loop now sweeps every board per tick;
per-tick cost is a few extra filesystem stats.
- CAS concurrency guarantees are preserved per-board (each board is its
own SQLite DB, same WAL+IMMEDIATE machinery as before).
CLI
---
hermes kanban boards list|create|switch|show|rename|rm
hermes kanban --board <slug> <any-subcommand>
Board resolution order: `--board` flag → `HERMES_KANBAN_BOARD` env →
`~/.hermes/kanban/current` file → `default`. Slug validation is strict:
lowercase alphanumerics + hyphens + underscores, 1-64 chars, starts with
alphanumeric. Uppercase is auto-downcased; slashes / dots / `..` /
control chars are rejected so boards can't name their way out of the
boards/ directory.
Passive discoverability: when more than one board exists, `hermes kanban
list` prints a one-line header ("Board: foo (2 other boards …)") so
users who stumble across multi-project never have to hunt for the
feature. Invisible for single-board installs.
Dashboard
---------
- New `BoardSwitcher` component at the top of the Kanban tab: dropdown
with all boards + task counts, `+ New board` button, `Archive`
button (non-default only). Hidden entirely when only `default` exists
and is empty — single-project users never see it.
- New `NewBoardDialog` modal: slug / display name / description / icon
+ "switch to this board after creating" checkbox.
- Selected board persists to `localStorage` so browser users don't
shift the CLI's active board out from under a terminal they left open.
- New `?board=<slug>` query param on every existing endpoint plus a
new `/boards` CRUD surface (`GET /boards`, `POST /boards`,
`PATCH /boards/<slug>`, `DELETE /boards/<slug>`,
`POST /boards/<slug>/switch`).
- Events WebSocket is pinned to a board at connection time; switching
opens a fresh WS against the new board.
Also fixes a pre-existing bug in the plugin's tenant / assignee
filters: the SDK's `Select` uses `onValueChange(value)`, not
native `onChange(event)`, so those filters silently didn't work.
New `selectChangeHandler` helper wires both signatures.
Tests
-----
49 new tests in `tests/hermes_cli/test_kanban_boards.py` covering:
slug validation (valid / invalid / auto-downcase), path resolution
(default = legacy path, named = `boards/<slug>/`, env var override),
current-board resolution chain (env > file > default), board CRUD +
archive / hard-delete, per-board connection isolation (tasks don't
leak), worker spawn env injection (`HERMES_KANBAN_BOARD`,
`HERMES_KANBAN_DB`, `HERMES_KANBAN_WORKSPACES_ROOT` all point at the
right board), and end-to-end CLI surface.
Regression surface: all 264 pre-existing kanban tests continue to pass.
Live-tested via the dashboard: created 3 boards (default,
hermes-agent, atm10-server), created tasks on each via both CLI
(`--board <slug> create`) and dashboard (inline create on the Ready
column), confirmed zero cross-board leakage, confirmed `BoardSwitcher`
+ `NewBoardDialog` work end-to-end in the browser.
This commit is contained in:
parent
135b4c8b35
commit
5ec6baa400
8 changed files with 2191 additions and 212 deletions
414
plugins/kanban/dashboard/dist/index.js
vendored
414
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -63,6 +63,53 @@
|
|||
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 {
|
||||
if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug);
|
||||
else window.localStorage.removeItem(LS_BOARD_KEY);
|
||||
} catch (_e) { /* ignore quota / private mode */ }
|
||||
}
|
||||
|
||||
function withBoard(url, board) {
|
||||
// Append ?board=<slug> when a non-default board is active. Omitted
|
||||
// for default so the URL stays clean and the backend falls through
|
||||
// to its own resolution chain (env var → ``current`` file →
|
||||
// default) which is already correct.
|
||||
if (!board || board === "default") 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.
|
||||
//
|
||||
|
|
@ -245,7 +292,19 @@
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
function KanbanPage() {
|
||||
const [board, setBoard] = useState(null);
|
||||
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);
|
||||
|
|
@ -292,9 +351,9 @@
|
|||
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)
|
||||
return SDK.fetchJSON(withBoard(url, board))
|
||||
.then(function (data) {
|
||||
setBoard(data);
|
||||
setBoardData(data);
|
||||
cursorRef.current = data.latest_event_id || 0;
|
||||
setError(null);
|
||||
})
|
||||
|
|
@ -302,7 +361,26 @@
|
|||
setError(String(err && err.message ? err.message : err));
|
||||
})
|
||||
.finally(function () { setLoading(false); });
|
||||
}, [tenantFilter, includeArchived]);
|
||||
}, [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;
|
||||
|
|
@ -324,16 +402,21 @@
|
|||
|
||||
// --- WebSocket ---------------------------------------------------------
|
||||
useEffect(function () {
|
||||
if (!board) return undefined;
|
||||
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 qs = new URLSearchParams({
|
||||
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. Only set for non-default so
|
||||
// single-board installs keep the cleaner URL.
|
||||
if (board && board !== "default") 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; }
|
||||
|
|
@ -372,11 +455,11 @@
|
|||
wsClosedRef.current = true;
|
||||
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
|
||||
};
|
||||
}, [!!board, scheduleReload]);
|
||||
}, [!!boardData, board, scheduleReload]);
|
||||
|
||||
// --- filtering ----------------------------------------------------------
|
||||
const filteredBoard = useMemo(function () {
|
||||
if (!board) return null;
|
||||
if (!boardData) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
const filterTask = function (t) {
|
||||
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
||||
|
|
@ -386,18 +469,18 @@
|
|||
}
|
||||
return true;
|
||||
};
|
||||
return Object.assign({}, board, {
|
||||
columns: board.columns.map(function (col) {
|
||||
return Object.assign({}, boardData, {
|
||||
columns: boardData.columns.map(function (col) {
|
||||
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
||||
}),
|
||||
});
|
||||
}, [board, assigneeFilter, search]);
|
||||
}, [boardData, assigneeFilter, search]);
|
||||
|
||||
// --- actions ------------------------------------------------------------
|
||||
const moveTask = useCallback(function (taskId, newStatus) {
|
||||
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
setBoard(function (b) {
|
||||
setBoardData(function (b) {
|
||||
if (!b) return b;
|
||||
let moved = null;
|
||||
const columns = b.columns.map(function (col) {
|
||||
|
|
@ -413,7 +496,7 @@
|
|||
}
|
||||
return Object.assign({}, b, { columns });
|
||||
});
|
||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
|
|
@ -421,10 +504,10 @@
|
|||
setError(`Move failed: ${err.message || err}`);
|
||||
loadBoard();
|
||||
});
|
||||
}, [loadBoard]);
|
||||
}, [loadBoard, board]);
|
||||
|
||||
const createTask = useCallback(function (body) {
|
||||
return SDK.fetchJSON(`${API}/tasks`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -437,9 +520,10 @@
|
|||
setError("Task created, but: " + res.warning);
|
||||
}
|
||||
loadBoard();
|
||||
loadBoardList(); // refresh counts in the switcher
|
||||
return res;
|
||||
});
|
||||
}, [loadBoard]);
|
||||
}, [loadBoard, loadBoardList, board]);
|
||||
|
||||
const toggleSelected = useCallback(function (id, additive) {
|
||||
setSelectedIds(function (prev) {
|
||||
|
|
@ -455,7 +539,7 @@
|
|||
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`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -470,14 +554,50 @@
|
|||
loadBoard();
|
||||
})
|
||||
.catch(function (e) { setError(String(e.message || e)); });
|
||||
}, [selectedIds, loadBoard, clearSelected]);
|
||||
}, [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 && !board) {
|
||||
if (loading && !boardData) {
|
||||
return h("div", { className: "p-8 text-sm text-muted-foreground" },
|
||||
"Loading Kanban board…");
|
||||
}
|
||||
if (error && !board) {
|
||||
if (error && !boardData) {
|
||||
return h(Card, null,
|
||||
h(CardContent, { className: "p-6" },
|
||||
h("div", { className: "text-sm text-destructive" },
|
||||
|
|
@ -493,15 +613,28 @@
|
|||
|
||||
return h(ErrorBoundary, null,
|
||||
h("div", { className: "hermes-kanban flex flex-col gap-4" },
|
||||
h(BoardToolbar, {
|
||||
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(BoardToolbar, {
|
||||
board: boardData,
|
||||
tenantFilter, setTenantFilter,
|
||||
assigneeFilter, setAssigneeFilter,
|
||||
includeArchived, setIncludeArchived,
|
||||
laneByProfile, setLaneByProfile,
|
||||
search, setSearch,
|
||||
onNudgeDispatch: function () {
|
||||
SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" })
|
||||
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
|
||||
.then(loadBoard)
|
||||
.catch(function (e) { setError(String(e.message || e)); });
|
||||
},
|
||||
|
|
@ -509,7 +642,7 @@
|
|||
}),
|
||||
selectedIds.size > 0 ? h(BulkActionBar, {
|
||||
count: selectedIds.size,
|
||||
assignees: (board && board.assignees) || [],
|
||||
assignees: (boardData && boardData.assignees) || [],
|
||||
onApply: applyBulk,
|
||||
onClear: clearSelected,
|
||||
}) : null,
|
||||
|
|
@ -522,20 +655,215 @@
|
|||
onMove: moveTask,
|
||||
onOpen: setSelectedTaskId,
|
||||
onCreate: createTask,
|
||||
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
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: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
eventTick: taskEventTick[selectedTaskId] || 0,
|
||||
}) : 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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -555,11 +883,10 @@
|
|||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||
h(Select, {
|
||||
h(Select, Object.assign({
|
||||
value: props.tenantFilter,
|
||||
onChange: function (e) { props.setTenantFilter(e.target.value); },
|
||||
className: "h-8",
|
||||
},
|
||||
}, selectChangeHandler(props.setTenantFilter)),
|
||||
h(SelectOption, { value: "" }, "All tenants"),
|
||||
tenants.map(function (t) {
|
||||
return h(SelectOption, { key: t, value: t }, t);
|
||||
|
|
@ -568,11 +895,10 @@
|
|||
),
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||
h(Select, {
|
||||
h(Select, Object.assign({
|
||||
value: props.assigneeFilter,
|
||||
onChange: function (e) { props.setAssigneeFilter(e.target.value); },
|
||||
className: "h-8",
|
||||
},
|
||||
}, selectChangeHandler(props.setAssigneeFilter)),
|
||||
h(SelectOption, { value: "" }, "All profiles"),
|
||||
assignees.map(function (a) {
|
||||
return h(SelectOption, { key: a, value: a }, a);
|
||||
|
|
@ -1049,13 +1375,14 @@
|
|||
const [err, setErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const boardSlug = props.boardSlug;
|
||||
|
||||
const load = useCallback(function () {
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`)
|
||||
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]);
|
||||
}, [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
|
||||
|
|
@ -1070,7 +1397,7 @@
|
|||
const handleComment = function () {
|
||||
const body = newComment.trim();
|
||||
if (!body) return;
|
||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, {
|
||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
|
|
@ -1085,7 +1412,7 @@
|
|||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
|
|
@ -1093,7 +1420,7 @@
|
|||
};
|
||||
|
||||
const addLink = function (parentId) {
|
||||
return SDK.fetchJSON(`${API}/links`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
|
||||
|
|
@ -1102,12 +1429,12 @@
|
|||
};
|
||||
const removeLink = function (parentId) {
|
||||
const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId });
|
||||
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
|
||||
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(`${API}/links`, {
|
||||
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
|
||||
|
|
@ -1116,7 +1443,7 @@
|
|||
};
|
||||
const removeChild = function (childId) {
|
||||
const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId });
|
||||
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
|
||||
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
|
@ -1141,6 +1468,7 @@
|
|||
data, editing, setEditing,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
allTasks: props.allTasks,
|
||||
boardSlug: boardSlug,
|
||||
onPatch: doPatch,
|
||||
onAddParent: addLink,
|
||||
onRemoveParent: removeLink,
|
||||
|
|
@ -1253,7 +1581,7 @@
|
|||
);
|
||||
}),
|
||||
),
|
||||
h(WorkerLogSection, { taskId: t.id }),
|
||||
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
|
||||
h(RunHistorySection, { runs: props.data.runs || [] }),
|
||||
);
|
||||
}
|
||||
|
|
@ -1324,10 +1652,10 @@
|
|||
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`)
|
||||
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.taskId, props.boardSlug]);
|
||||
|
||||
// Auto-load when the section mounts; the user opened the drawer so the
|
||||
// cost is one small HTTP round-trip.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue