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:
+10
-1
@@ -42,6 +42,14 @@
|
||||
</span>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd"> ⌃⇧P</span></button>
|
||||
<button type="button" class="lt-theme-btn" id="theme-toggle" aria-label="Switch to light mode" title="Switch to light mode">☀</button>
|
||||
<div class="lt-notif-dropdown-wrap dd-inbox">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn" id="inbox-toggle" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox-panel" aria-label="Inbox" title="Inbox">🔔</button>
|
||||
<span class="lt-notif-badge" id="inbox-badge" data-count="0" aria-hidden="true"></span>
|
||||
<div class="lt-notif-panel dd-inbox-panel" id="inbox-panel" role="dialog" aria-label="Inbox" aria-hidden="true">
|
||||
<div class="lt-notif-panel-header"><span>Inbox</span><button type="button" class="lt-notif-panel-clear">Mark all read</button></div>
|
||||
<div class="lt-notif-panel-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dd-more">
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
||||
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
||||
@@ -53,13 +61,14 @@
|
||||
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
|
||||
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
||||
<div class="dd-menu-foot" id="version">devdesk</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// The bell (house .lt-notif-* pattern): what needs you right now (waiting
|
||||
// sessions, firing Grafana alerts) plus recent events you may have missed
|
||||
// (a background session finished or started waiting, CI passed or failed).
|
||||
// Events live in localStorage, so every tab shows the same inbox.
|
||||
|
||||
import { el } from './dom.js';
|
||||
import { ago } from './sessions.js';
|
||||
|
||||
const KEY = 'devdesk.inbox';
|
||||
const MAX = 30;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(events) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(events.slice(0, MAX)));
|
||||
} catch {
|
||||
// Private mode: the inbox lasts as long as the tab.
|
||||
}
|
||||
}
|
||||
|
||||
const when = (ms) => {
|
||||
const a = ago(Math.floor(ms / 1000));
|
||||
return a === 'now' ? 'just now' : `${a} ago`;
|
||||
};
|
||||
|
||||
const KINDS = {
|
||||
waiting: { icon: '!', tone: 'red' },
|
||||
finished: { icon: '✓', tone: 'green' },
|
||||
'ci-pass': { icon: '✓', tone: 'green' },
|
||||
'ci-fail': { icon: '✗', tone: 'red' },
|
||||
};
|
||||
|
||||
export class Inbox {
|
||||
// hooks: openSession(name), showAlerts()
|
||||
constructor({ button, badge, panel, hooks }) {
|
||||
Object.assign(this, { button, badge, panel, hooks });
|
||||
this.events = load();
|
||||
this.waiting = [];
|
||||
this.alerts = [];
|
||||
this.list = panel.querySelector('.lt-notif-panel-list');
|
||||
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === KEY) {
|
||||
this.events = load();
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return !this.panel.hasAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
setOpen(open) {
|
||||
if (open) this.panel.removeAttribute('aria-hidden');
|
||||
else this.panel.setAttribute('aria-hidden', 'true');
|
||||
this.button.setAttribute('aria-expanded', String(open));
|
||||
if (open) this.render();
|
||||
}
|
||||
|
||||
// Something happened. The same kind for the same session within a minute is
|
||||
// one event (several tabs, quick flaps).
|
||||
record({ kind, session, text, url }) {
|
||||
const now = Date.now();
|
||||
if (this.events.some((e) => e.kind === kind && e.session === session && now - e.at < 60000)) return;
|
||||
this.events.unshift({ id: `${now}-${Math.random().toString(36).slice(2, 7)}`, kind, session, text, url, at: now, read: false });
|
||||
this.events = this.events.slice(0, MAX);
|
||||
save(this.events);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Live state: other sessions waiting on you, firing alerts.
|
||||
setLive({ waiting, alerts }) {
|
||||
if (waiting) this.waiting = waiting;
|
||||
if (alerts) this.alerts = alerts;
|
||||
this.render();
|
||||
}
|
||||
|
||||
markAllRead() {
|
||||
this.events = this.events.map((e) => ({ ...e, read: true }));
|
||||
save(this.events);
|
||||
this.render();
|
||||
}
|
||||
|
||||
unread() {
|
||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
|
||||
}
|
||||
|
||||
render() {
|
||||
const count = this.waiting.length + this.alerts.length + this.unread();
|
||||
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
|
||||
this.badge.textContent = count ? String(count) : '';
|
||||
this.badge.dataset.count = String(count);
|
||||
this.badge.classList.toggle('is-calm', count > 0 && !urgent);
|
||||
this.button.title = count
|
||||
? [
|
||||
this.waiting.length && `${this.waiting.length} session(s) need you`,
|
||||
this.alerts.length && `${this.alerts.length} alert(s) firing`,
|
||||
this.unread() && `${this.unread()} new event(s)`,
|
||||
].filter(Boolean).join(' · ')
|
||||
: 'Inbox: nothing new';
|
||||
if (this.isOpen) this.renderPanel();
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
const close = (run) => () => {
|
||||
this.setOpen(false);
|
||||
run();
|
||||
};
|
||||
const item = ({ dot, title, meta, unread, onclick }) => el('div', {
|
||||
class: `lt-notif-item${unread ? ' lt-notif-item--unread' : ''}`, role: 'button', tabindex: '0', onclick,
|
||||
onkeydown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onclick();
|
||||
}
|
||||
},
|
||||
},
|
||||
el('span', { class: `lt-notif-dot dd-notif-${dot}` }),
|
||||
el('div', { class: 'lt-notif-item-body' },
|
||||
el('div', { class: 'lt-notif-item-title' }, title),
|
||||
meta ? el('div', { class: 'lt-notif-item-time' }, meta) : null));
|
||||
const nodes = [];
|
||||
|
||||
if (this.waiting.length) {
|
||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
||||
for (const s of this.waiting) {
|
||||
nodes.push(item({
|
||||
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
||||
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
||||
onclick: close(() => this.hooks.openSession(s.name)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (this.alerts.length) {
|
||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Ops alerts'));
|
||||
for (const a of this.alerts.slice(0, 5)) {
|
||||
nodes.push(item({
|
||||
dot: a.severity === 'critical' ? 'red' : 'amber', title: `${a.severity ? `${a.severity}: ` : ''}${a.name}`,
|
||||
meta: [a.state, a.summary].filter(Boolean).join(' · '), onclick: close(() => this.hooks.showAlerts()),
|
||||
}));
|
||||
}
|
||||
if (this.alerts.length > 5) nodes.push(el('div', { class: 'dd-notif-more' }, `+${this.alerts.length - 5} more in the Ops Alerts panel`));
|
||||
}
|
||||
const recent = this.events.filter((e) => e.kind !== 'waiting' || !this.waiting.some((s) => s.name === e.session));
|
||||
if (recent.length) {
|
||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
||||
for (const e of recent.slice(0, 12)) {
|
||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||
nodes.push(item({
|
||||
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
|
||||
onclick: close(() => {
|
||||
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
||||
save(this.events);
|
||||
if (e.url) window.open(e.url, '_blank', 'noopener');
|
||||
else if (e.session) this.hooks.openSession(e.session);
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
|
||||
this.list.replaceChildren(...nodes);
|
||||
}
|
||||
}
|
||||
+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();
|
||||
|
||||
+35
-15
@@ -100,20 +100,7 @@ body.dd-app::after {
|
||||
color: var(--accent-orange);
|
||||
border-color: var(--accent-orange-border);
|
||||
}
|
||||
.dd-badge {
|
||||
margin-left: 6px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-amber);
|
||||
color: var(--bg-primary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); }
|
||||
.dd-badge[hidden] { display: none; }
|
||||
|
||||
|
||||
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
|
||||
.dd-session,
|
||||
@@ -582,7 +569,6 @@ html[data-theme="light"] .dd-upload-status { box-shadow: 0 4px 16px rgba(50, 80,
|
||||
html[data-theme="light"] .dd-session-item.is-waiting,
|
||||
html[data-theme="light"] .dd-session-chip[data-state="waiting"],
|
||||
html[data-theme="light"] .dd-upload-status.is-error { border-color: rgba(181, 0, 31, 0.35); }
|
||||
html[data-theme="light"] .dd-badge { color: #ffffff; }
|
||||
html[data-theme="light"] .dd-session-chip[data-state="working"] .dd-state-dot,
|
||||
html[data-theme="light"] .dd-session-chip[data-state="waiting"] .dd-state-dot { box-shadow: none; }
|
||||
@media (max-width: 1099px) {
|
||||
@@ -679,3 +665,37 @@ html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cya
|
||||
/* One highlight only: the row under a resting pointer isn't "selected" until the
|
||||
pointer moves (mousemove updates the selection). */
|
||||
.lt-cmd-item:hover:not(.is-selected) { background: none; color: inherit; }
|
||||
|
||||
/* ---- inbox (bell) ---------------------------------------------------------------- */
|
||||
.dd-inbox .lt-notif-bell-btn { font-size: 0.9rem; line-height: 1; padding-inline: 8px; }
|
||||
.dd-inbox .lt-notif-badge { top: -5px; right: -5px; }
|
||||
.dd-inbox .lt-notif-badge.is-calm { background: var(--accent-amber); animation: none; }
|
||||
.dd-inbox-panel { width: min(360px, calc(100vw - 16px)); z-index: 40; }
|
||||
.dd-inbox-panel .lt-notif-panel-list { max-height: min(460px, calc(100vh - 140px)); }
|
||||
.dd-notif-section {
|
||||
padding: 6px 0.75rem 4px;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-orange);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.dd-notif-red { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); }
|
||||
.dd-notif-amber { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); }
|
||||
.dd-notif-green { background: var(--accent-green); box-shadow: 0 0 6px var(--accent-green); }
|
||||
.dd-notif-cyan { background: var(--accent-cyan); }
|
||||
.dd-notif-read { background: var(--border-color); box-shadow: none; }
|
||||
html[data-theme="light"] .lt-notif-dot { box-shadow: none; }
|
||||
.dd-notif-empty,
|
||||
.dd-notif-more { padding: 0.75rem; color: var(--text-muted); font-size: 0.72rem; line-height: 1.5; }
|
||||
.dd-panels-icon { display: none; }
|
||||
|
||||
@media (max-width: 719px) {
|
||||
/* Phone header: theme lives in the ⋯ menu; Panels becomes an icon. */
|
||||
.dd-header .lt-theme-btn { display: none; }
|
||||
.dd-inbox .lt-notif-badge { top: 1px; right: 1px; }
|
||||
.dd-panels-label { display: none; }
|
||||
.dd-panels-icon { display: inline; font-size: 1rem; }
|
||||
.dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user