mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat(kanban-dashboard): per-platform home-channel notification toggles (#19864)
* revert: auto-subscribe gateway chat on tool-driven kanban_create (#19718)
Reverts ff3d2773e2. Teknium reviewed the merged PR and decided this
behavior isn't wanted — tool-driven kanban_create should not mirror
the slash-command path's auto-subscribe. Orchestrators that want
their originating chat notified can call kanban_notify-subscribe
explicitly; we're not going to make it implicit.
* feat(kanban-dashboard): per-platform home-channel notification toggles
Adds a "Notify home channels" section to the task drawer in the kanban
dashboard plugin. Each platform where the user has set a home channel
(/sethome, TELEGRAM_HOME_CHANNEL env var, gateway.platforms.<p>.home_channel
in config.yaml) gets a toggle pill. Toggling on writes a kanban_notify_subs
row keyed to that platform's home (chat_id + thread_id); toggling off
removes it. The existing gateway notifier watcher delivers completed /
blocked / gave_up events without any new plumbing — this is purely a GUI
surface over existing machinery.
Replaces the reverted auto-subscribe behavior from #19718 with an explicit,
per-task, per-platform, user-controlled opt-in. No implicit subscription
on tool-driven kanban_create; no CLI commands; no slash commands. Just a
toggle in the drawer.
Backend (plugins/kanban/dashboard/plugin_api.py):
- GET /api/plugins/kanban/home-channels[?task_id=X]
Returns every platform with a configured home, plus a per-entry
subscribed: bool relative to task_id (false when task_id omitted).
Reads the live GatewayConfig via load_gateway_config() so env-var
overlays stay honored.
- POST /api/plugins/kanban/tasks/:id/home-subscribe/:platform
Idempotent add_notify_sub keyed to the platform's home.
- DELETE /api/plugins/kanban/tasks/:id/home-subscribe/:platform
remove_notify_sub for the same tuple.
- 404 when the platform has no home configured, or task_id doesn't
exist (POST only).
Frontend (plugins/kanban/dashboard/dist/index.js):
- TaskDrawer fetches /home-channels on open, keyed on task_id.
- HomeSubsSection renders nothing when zero platforms have a home (so
users who haven't set one up don't see an empty UI block).
- Optimistic toggle with busy flag + revert-on-failure. One pill per
platform; ✓ prefix and --on class indicate the subscribed state.
CSS (plugins/kanban/dashboard/dist/style.css):
- .hermes-kanban-home-subs flex row + .hermes-kanban-home-sub pill
style + --on subscribed variant (subtle ring-colored background).
Live-tested against a dashboard with TELEGRAM + DISCORD_BOT_TOKEN /
HOME_CHANNEL env vars set: drawer shows both pills, toggling each
flips its visual state AND writes/removes the correct kanban_notify_subs
row (verified via direct DB read).
Tests (tests/plugins/test_kanban_dashboard_plugin.py, 11 new, 53/53
pass total):
- home-channels lists only platforms with a home (slack with a
token but no home is excluded)
- no task_id -> all subscribed=false
- subscribe creates notify_sub row with correct chat/thread/platform
- subscribed=true reflected in subsequent GET
- idempotent re-subscribe
- unknown platform -> 404
- unknown task -> 404
- unsubscribe removes the row
- telegram + discord subscribe/unsubscribe independent
- zero homes -> empty list
This commit is contained in:
parent
3db6b9cc87
commit
1c7c7c3c5f
4 changed files with 423 additions and 0 deletions
96
plugins/kanban/dashboard/dist/index.js
vendored
96
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -1375,6 +1375,11 @@
|
|||
const [err, setErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Home-channel notification toggles. homeChannels is the list of platforms
|
||||
// the user has a /sethome on; each entry has a `subscribed` bool telling
|
||||
// us whether this task is currently subscribed via that platform's home.
|
||||
const [homeChannels, setHomeChannels] = useState([]);
|
||||
const [homeBusy, setHomeBusy] = useState({});
|
||||
const boardSlug = props.boardSlug;
|
||||
|
||||
const load = useCallback(function () {
|
||||
|
|
@ -1384,10 +1389,19 @@
|
|||
.finally(function () { setLoading(false); });
|
||||
}, [props.taskId, boardSlug]);
|
||||
|
||||
const loadHomeChannels = useCallback(function () {
|
||||
const qs = new URLSearchParams({ task_id: props.taskId });
|
||||
const url = withBoard(`${API}/home-channels?${qs}`, boardSlug);
|
||||
return SDK.fetchJSON(url)
|
||||
.then(function (d) { setHomeChannels(d.home_channels || []); })
|
||||
.catch(function () { /* silent — endpoint optional on older gateways */ });
|
||||
}, [props.taskId, boardSlug]);
|
||||
|
||||
// Reload when the WS stream reports new events for this task id
|
||||
// (completion, block, crash, etc. — anything that'd make the drawer
|
||||
// show stale data if we only loaded on mount).
|
||||
useEffect(function () { load(); }, [load, props.eventTick]);
|
||||
useEffect(function () { loadHomeChannels(); }, [loadHomeChannels]);
|
||||
useEffect(function () {
|
||||
function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); }
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
|
@ -1448,6 +1462,43 @@
|
|||
.catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
const toggleHomeSubscription = function (platform, currentlySubscribed) {
|
||||
// Optimistic flip + busy flag to keep double-clicks idempotent.
|
||||
setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); });
|
||||
setHomeChannels(function (list) {
|
||||
return list.map(function (h) {
|
||||
return h.platform === platform
|
||||
? Object.assign({}, h, { subscribed: !currentlySubscribed })
|
||||
: h;
|
||||
});
|
||||
});
|
||||
const method = currentlySubscribed ? "DELETE" : "POST";
|
||||
const url = withBoard(
|
||||
`${API}/tasks/${encodeURIComponent(props.taskId)}/home-subscribe/${encodeURIComponent(platform)}`,
|
||||
boardSlug,
|
||||
);
|
||||
return SDK.fetchJSON(url, { method: method })
|
||||
.then(function () { return loadHomeChannels(); })
|
||||
.catch(function (e) {
|
||||
// Revert optimistic flip on failure.
|
||||
setHomeChannels(function (list) {
|
||||
return list.map(function (h) {
|
||||
return h.platform === platform
|
||||
? Object.assign({}, h, { subscribed: currentlySubscribed })
|
||||
: h;
|
||||
});
|
||||
});
|
||||
setErr(String(e.message || e));
|
||||
})
|
||||
.finally(function () {
|
||||
setHomeBusy(function (b) {
|
||||
const next = Object.assign({}, b);
|
||||
delete next[platform];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose },
|
||||
h("div", {
|
||||
className: "hermes-kanban-drawer",
|
||||
|
|
@ -1474,6 +1525,9 @@
|
|||
onRemoveParent: removeLink,
|
||||
onAddChild: addChild,
|
||||
onRemoveChild: removeChild,
|
||||
homeChannels: homeChannels,
|
||||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
|
|
@ -1535,6 +1589,11 @@
|
|||
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, { task: t, onPatch: props.onPatch }),
|
||||
h(HomeSubsSection, {
|
||||
homeChannels: props.homeChannels || [],
|
||||
homeBusy: props.homeBusy || {},
|
||||
onToggle: props.onToggleHomeSub,
|
||||
}),
|
||||
h(BodyEditor, {
|
||||
task: t,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
|
|
@ -1950,6 +2009,43 @@
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
// One toggle per gateway platform the user has a home channel set on
|
||||
// (telegram, discord, slack, etc.). Toggling on creates a kanban_notify_subs
|
||||
// row routed to that platform's home; toggling off removes it. Nothing
|
||||
// renders when no platforms have a home configured — this section stays
|
||||
// invisible for users who haven't set one up.
|
||||
function HomeSubsSection(props) {
|
||||
const channels = props.homeChannels || [];
|
||||
if (channels.length === 0) return null;
|
||||
const busy = props.homeBusy || {};
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
"Notify home channels"),
|
||||
h("div", { className: "hermes-kanban-home-subs" },
|
||||
channels.map(function (hc) {
|
||||
const isBusy = !!busy[hc.platform];
|
||||
const label = hc.subscribed ? "✓ " + hc.platform : hc.platform;
|
||||
const title = hc.subscribed
|
||||
? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.`
|
||||
: `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`;
|
||||
return h(Button, {
|
||||
key: hc.platform,
|
||||
size: "sm",
|
||||
title: title,
|
||||
disabled: isBusy || !props.onToggle,
|
||||
onClick: function () {
|
||||
if (props.onToggle) props.onToggle(hc.platform, hc.subscribed);
|
||||
},
|
||||
className: hc.subscribed
|
||||
? "hermes-kanban-home-sub hermes-kanban-home-sub--on"
|
||||
: "hermes-kanban-home-sub",
|
||||
}, label);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Register
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
20
plugins/kanban/dashboard/dist/style.css
vendored
20
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -351,6 +351,26 @@
|
|||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* ---- Home channel subscription toggles (per-platform, per-task) ----- */
|
||||
|
||||
.hermes-kanban-home-subs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.hermes-kanban-home-sub {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hermes-kanban-home-sub--on {
|
||||
/* Subtly indicate the subscribed state without a hard color change so
|
||||
* dashboard themes stay coherent. Border + tinted background. */
|
||||
border-color: color-mix(in srgb, var(--color-ring) 55%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-ring) 14%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.hermes-kanban-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -733,6 +733,155 @@ def get_config():
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home-channel subscriptions (per-task, per-platform toggles)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Home channels are a first-class gateway concept — each configured platform
|
||||
# can have exactly one (chat_id, thread_id, name) it considers "home". The
|
||||
# dashboard surfaces these as per-task toggles so a user can opt a specific
|
||||
# task into receiving terminal notifications (completed / blocked / gave_up)
|
||||
# at their telegram/discord/slack home, without touching the CLI.
|
||||
#
|
||||
# The wire format mirrors kanban_db.add_notify_sub — (task_id, platform,
|
||||
# chat_id, thread_id) — so toggle-on creates exactly the same row the
|
||||
# `/kanban create` slash command would, and the existing gateway notifier
|
||||
# watcher delivers events without any additional plumbing.
|
||||
|
||||
|
||||
def _configured_home_channels() -> list[dict]:
|
||||
"""Return every platform that has a home_channel set, fully hydrated.
|
||||
|
||||
Reads the live GatewayConfig so env-var overlays (``TELEGRAM_HOME_CHANNEL``
|
||||
etc.) are honored alongside config.yaml. Returns platforms in a stable
|
||||
order and drops platforms without a home.
|
||||
"""
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
gw_cfg = load_gateway_config()
|
||||
except Exception:
|
||||
return []
|
||||
result: list[dict] = []
|
||||
for platform, pcfg in gw_cfg.platforms.items():
|
||||
if not pcfg or not pcfg.home_channel:
|
||||
continue
|
||||
hc = pcfg.home_channel
|
||||
result.append({
|
||||
"platform": platform.value,
|
||||
"chat_id": hc.chat_id,
|
||||
"thread_id": hc.thread_id or "",
|
||||
"name": hc.name or "Home",
|
||||
})
|
||||
# Stable order for deterministic UI — platform name alphabetical.
|
||||
result.sort(key=lambda r: r["platform"])
|
||||
return result
|
||||
|
||||
|
||||
def _home_sub_matches(sub: dict, home: dict) -> bool:
|
||||
"""True if a notify_subs row corresponds to the given home channel."""
|
||||
return (
|
||||
sub.get("platform") == home["platform"]
|
||||
and str(sub.get("chat_id", "")) == str(home["chat_id"])
|
||||
and str(sub.get("thread_id") or "") == str(home["thread_id"] or "")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/home-channels")
|
||||
def get_home_channels(
|
||||
task_id: Optional[str] = Query(None),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""List every platform with a home channel, plus whether *task_id*
|
||||
(if given) is currently subscribed to that home.
|
||||
|
||||
When ``task_id`` is omitted, every entry's ``subscribed`` is ``false``
|
||||
— useful for the "no task selected" state of the UI.
|
||||
"""
|
||||
homes = _configured_home_channels()
|
||||
subscribed_homes: set[tuple[str, str, str]] = set()
|
||||
if task_id:
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
subs = kanban_db.list_notify_subs(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
for sub in subs:
|
||||
key = (
|
||||
str(sub.get("platform") or ""),
|
||||
str(sub.get("chat_id") or ""),
|
||||
str(sub.get("thread_id") or ""),
|
||||
)
|
||||
subscribed_homes.add(key)
|
||||
result = []
|
||||
for home in homes:
|
||||
key = (home["platform"], home["chat_id"], home["thread_id"])
|
||||
result.append({**home, "subscribed": key in subscribed_homes})
|
||||
return {"home_channels": result}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/home-subscribe/{platform}")
|
||||
def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
|
||||
"""Subscribe *task_id* to notifications routed to *platform*'s home channel.
|
||||
|
||||
Idempotent — re-subscribing is a no-op at the DB layer. 404 if the
|
||||
platform has no home channel configured. 404 if the task doesn't exist.
|
||||
"""
|
||||
homes = _configured_home_channels()
|
||||
home = next((h for h in homes if h["platform"] == platform), None)
|
||||
if not home:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No home channel configured for platform {platform!r}. "
|
||||
f"Set one from the messenger via /sethome, or configure "
|
||||
f"gateway.platforms.{platform}.home_channel in config.yaml.",
|
||||
)
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
kanban_db.add_notify_sub(
|
||||
conn,
|
||||
task_id=task_id,
|
||||
platform=platform,
|
||||
chat_id=home["chat_id"],
|
||||
thread_id=home["thread_id"] or None,
|
||||
)
|
||||
return {"ok": True, "task_id": task_id, "home_channel": home}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}/home-subscribe/{platform}")
|
||||
def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
|
||||
"""Remove any notify subscription on *task_id* that matches *platform*'s home."""
|
||||
homes = _configured_home_channels()
|
||||
home = next((h for h in homes if h["platform"] == platform), None)
|
||||
if not home:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No home channel configured for platform {platform!r}.",
|
||||
)
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
kanban_db.remove_notify_sub(
|
||||
conn,
|
||||
task_id=task_id,
|
||||
platform=platform,
|
||||
chat_id=home["chat_id"],
|
||||
thread_id=home["thread_id"] or None,
|
||||
)
|
||||
return {"ok": True, "task_id": task_id, "home_channel": home}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats (per-profile / per-status counts + oldest-ready age)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue