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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue