feat: inbox bell for waiting sessions, alerts and missed events
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped

Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:51:01 -04:00
co-authored by Claude Opus 5.5
parent 6e109aec36
commit 390ce61f5b
6 changed files with 283 additions and 30 deletions
+54 -13
View File
@@ -11,6 +11,7 @@ import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
import { Inbox } from './inbox.js';
import { el } from './dom.js';
import * as notify from './notify.js';
@@ -52,7 +53,6 @@ const ui = {
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
@@ -74,6 +74,7 @@ let term;
let fit;
let sessionsPanel;
let palette;
let inbox;
let lastRepo = null;
let lastAlerts = null;
let waitingOthers = [];
@@ -130,6 +131,7 @@ function renderSessionChip() {
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
const prevStatus = new Map();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
@@ -137,6 +139,7 @@ function renderAttention(fetched) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
if (fetched) recordTransitions(data?.sessions ?? []);
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
@@ -144,17 +147,27 @@ function renderAttention(fetched) {
renderPanelsBadge();
}
// Inbox events for what happened while you were elsewhere: a session (other than
// the one you're looking at) finished a turn or started waiting. Only the tab
// that fetched the list records them; the inbox is shared via localStorage.
function recordTransitions(sessions) {
for (const s of sessions) {
const key = sessionStatus(s).key;
const prev = prevStatus.get(s.name);
prevStatus.set(s.name, key);
if (!prev || prev === key) continue;
const away = s.name !== state.session || document.hidden;
if (!away) continue;
if (key === 'waiting') {
inbox.record({ kind: 'waiting', session: s.name, text: `${s.name} needs you: ${s.status?.message || 'Claude is waiting for your input'}` });
} else if (prev === 'working' && key === 'idle') {
inbox.record({ kind: 'finished', session: s.name, text: `${s.name} finished` });
}
}
}
function renderPanelsBadge() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
}
function onSessionData({ fetched = false } = {}) {
@@ -281,6 +294,7 @@ async function refreshRepo() {
const text = `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`;
notify.ring(session, text);
toast[ci === 'success' ? 'success' : 'error'](text);
inbox.record({ kind: ci === 'success' ? 'ci-pass' : 'ci-fail', session, text: `${session}: ${text}`, url: repo.ci?.url });
}
lastCi = { key, state: ci };
} catch {
@@ -480,6 +494,7 @@ function closeMenus() {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
if (inbox?.isOpen) inbox.setOpen(false);
}
// Session switcher on the session chip (devdesk#16).
@@ -761,7 +776,33 @@ function wireChrome() {
else if (!dialogOpen()) palette.open();
}
}, true);
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
wireMenu(ui.moreToggle, ui.moreMenu, () => {
renderNotifyButton();
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
});
$('theme-menu').addEventListener('click', () => {
closeMenus();
toggleTheme();
});
inbox = new Inbox({
button: $('inbox-toggle'),
badge: $('inbox-badge'),
panel: $('inbox-panel'),
hooks: {
openSession: (name) => connect(name),
showAlerts: () => {
setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
},
},
});
$('inbox-toggle').addEventListener('click', (event) => {
event.stopPropagation();
const open = !inbox.isOpen;
closeMenus();
inbox.setOpen(open);
});
$('inbox-panel').addEventListener('click', (event) => event.stopPropagation());
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
@@ -778,7 +819,7 @@ function wireChrome() {
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || dialogOpen()) return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();