diff --git a/README.md b/README.md index c229138..ff9f02d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory. - **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**. - The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state. - - A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts. + - A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting. + - The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them. - **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off). - **File & image drop:** drag files onto the terminal or paste a screenshot. - They're uploaded through ttyd to `/var/lib/devdesk/uploads//` (25 MB max, pruned after 7 days), and the path is pasted into Claude. diff --git a/debian/changelog b/debian/changelog index 8b5e2b9..919d063 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +devdesk (0.6.1) bookworm; urgency=medium + + * Inbox bell (#36): sessions waiting on you, firing alerts, and what + happened while you were away (a background session finished or started + waiting, CI passed or failed). Click to jump there; shared by all tabs. + Replaces the count on the Panels button. + * Phone header: theme in the ⋯ menu, Panels as an icon, so the bell fits. + + -- Jared Vititoe Thu, 08 Oct 2026 00:50:56 -0400 + devdesk (0.6.0) bookworm; urgency=medium * Light mode, like the other web_template apps (#31): the ☀/◐ button in the diff --git a/web/index.template.html b/web/index.template.html index e48b3d7..0df94fc 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -42,6 +42,14 @@ +
+ + + +
+
devdesk
- + diff --git a/web/src/inbox.js b/web/src/inbox.js new file mode 100644 index 0000000..8e565ee --- /dev/null +++ b/web/src/inbox.js @@ -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); + } +} diff --git a/web/src/main.js b/web/src/main.js index d770089..d844d22 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -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(); diff --git a/web/styles.css b/web/styles.css index 61d0a0b..9926ac4 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } +}