feat: inbox bell for waiting sessions, alerts and missed events
Closes #36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+54
-13
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user