Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions plugins/kanban/dashboard/dist/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
149 changes: 149 additions & 0 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading