feat: add multi-layer browser notification system

Implement a 4-layer notification system for alerting users when Claude
Code sessions need attention:

1. Notification Drawer - persistent in-page log with bell icon + badge
2. Tab Title Updates - flashing title with unread count when unfocused
3. Web Notifications - browser push notifications when tab hidden
4. Audio Alerts - Web Audio API beep for critical events

Notification triggers:
- Critical: session errors, crashes (non-zero exit), agent failures/timeouts
- Warning: Ralph loop completions, budget warnings, stuck sessions (idle >10min)
- Info: auto-accepts, auto-clears, agent completions

Features:
- NotificationManager class with debouncing/grouping (5s window)
- Settings tab in App Settings with per-layer/per-urgency toggles
- Configurable idle threshold for stuck session detection
- Click notification to switch to relevant session
- Notification permission request flow
- Rate-limited browser notifications (max 1/3s)
- Idle timer cleanup on session delete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 01:22:53 +01:00
co-authored by Claude Opus 4.5
parent e40849da79
commit 0fc52902cc
3 changed files with 752 additions and 0 deletions
+485
View File
@@ -2,6 +2,329 @@
// Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000;
// Notification Manager - Multi-layer browser notification system
class NotificationManager {
constructor(app) {
this.app = app;
this.notifications = [];
this.unreadCount = 0;
this.isTabVisible = !document.hidden;
this.isDrawerOpen = false;
this.originalTitle = document.title;
this.titleFlashInterval = null;
this.titleFlashState = false;
this.lastBrowserNotifTime = 0;
this.audioCtx = null;
this.renderScheduled = false;
// Debounce grouping: Map<key, {notification, timeout}>
this.groupingMap = new Map();
// Load preferences
this.preferences = this.loadPreferences();
// Visibility tracking
document.addEventListener('visibilitychange', () => {
this.isTabVisible = !document.hidden;
if (this.isTabVisible) {
this.onTabVisible();
}
});
}
loadPreferences() {
try {
const saved = localStorage.getItem('claudeman-notification-prefs');
if (saved) return JSON.parse(saved);
} catch (_e) { /* ignore */ }
return {
enabled: true,
browserNotifications: false,
audioAlerts: false,
stuckThresholdMs: 600000,
muteCritical: false,
muteWarning: false,
muteInfo: false,
};
}
savePreferences() {
localStorage.setItem('claudeman-notification-prefs', JSON.stringify(this.preferences));
}
notify({ urgency, category, sessionId, sessionName, title, message }) {
if (!this.preferences.enabled) return;
// Check urgency muting
if (urgency === 'critical' && this.preferences.muteCritical) return;
if (urgency === 'warning' && this.preferences.muteWarning) return;
if (urgency === 'info' && this.preferences.muteInfo) return;
// Grouping: same category+session within 5s updates count instead of new entry
const groupKey = `${category}:${sessionId || 'global'}`;
const existing = this.groupingMap.get(groupKey);
if (existing) {
existing.notification.count = (existing.notification.count || 1) + 1;
existing.notification.message = message;
existing.notification.timestamp = Date.now();
clearTimeout(existing.timeout);
existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), 5000);
this.scheduleRender();
return;
}
const notification = {
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
urgency,
category,
sessionId,
sessionName,
title,
message,
timestamp: Date.now(),
read: false,
count: 1,
};
// Add to log (cap at 100)
this.notifications.unshift(notification);
if (this.notifications.length > 100) this.notifications.pop();
// Track for grouping
const timeout = setTimeout(() => this.groupingMap.delete(groupKey), 5000);
this.groupingMap.set(groupKey, { notification, timeout });
// Update unread
this.unreadCount++;
this.updateBadge();
this.scheduleRender();
// Layer 2: Tab title (when tab unfocused)
if (!this.isTabVisible) {
this.updateTabTitle();
}
// Layer 3: Browser notification (when tab hidden, critical/warning only)
if (!this.isTabVisible && (urgency === 'critical' || urgency === 'warning')) {
this.sendBrowserNotif(title, message, category);
}
// Layer 4: Audio alert (critical only)
if (urgency === 'critical' && this.preferences.audioAlerts) {
this.playAudioAlert();
}
}
// Layer 1: Drawer rendering
scheduleRender() {
if (this.renderScheduled) return;
this.renderScheduled = true;
requestAnimationFrame(() => {
this.renderScheduled = false;
this.renderDrawer();
});
}
renderDrawer() {
const list = document.getElementById('notifList');
const empty = document.getElementById('notifEmpty');
if (!list || !empty) return;
if (this.notifications.length === 0) {
list.style.display = 'none';
empty.style.display = 'flex';
return;
}
list.style.display = 'block';
empty.style.display = 'none';
list.innerHTML = this.notifications.map(n => {
const urgencyClass = `notif-item-${n.urgency}`;
const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : '';
const sessionChip = n.sessionName ? `<span class="notif-item-session">${this.escapeHtml(n.sessionName)}</span>` : '';
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${n.id}')">
<div class="notif-item-header">
<span class="notif-item-title">${this.escapeHtml(n.title)}${countLabel}</span>
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
</div>
<div class="notif-item-message">${this.escapeHtml(n.message)}</div>
${sessionChip}
</div>`;
}).join('');
}
// Layer 2: Tab title with unread count
updateTabTitle() {
if (this.unreadCount > 0 && !this.isTabVisible) {
if (!this.titleFlashInterval) {
this.titleFlashInterval = setInterval(() => {
this.titleFlashState = !this.titleFlashState;
document.title = this.titleFlashState
? `\u26A0\uFE0F (${this.unreadCount}) Claudeman`
: this.originalTitle;
}, 1500);
// Set immediately
document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`;
}
}
}
stopTitleFlash() {
if (this.titleFlashInterval) {
clearInterval(this.titleFlashInterval);
this.titleFlashInterval = null;
this.titleFlashState = false;
document.title = this.originalTitle;
}
}
// Layer 3: Web Notification API
sendBrowserNotif(title, body, tag) {
if (!this.preferences.browserNotifications) return;
if (typeof Notification === 'undefined') return;
if (Notification.permission !== 'granted') return;
// Rate limit: max 1 per 3 seconds
const now = Date.now();
if (now - this.lastBrowserNotifTime < 3000) return;
this.lastBrowserNotifTime = now;
const notif = new Notification(`Claudeman: ${title}`, {
body,
tag, // Groups same-tag notifications
icon: '/favicon.ico',
silent: true, // We handle audio ourselves
});
notif.onclick = () => {
window.focus();
notif.close();
};
// Auto-close after 8s
setTimeout(() => notif.close(), 8000);
}
async requestPermission() {
if (typeof Notification === 'undefined') {
this.app.showToast('Browser notifications not supported', 'warning');
return;
}
const result = await Notification.requestPermission();
const statusEl = document.getElementById('notifPermissionStatus');
if (statusEl) statusEl.textContent = `Status: ${result}`;
if (result === 'granted') {
this.preferences.browserNotifications = true;
this.savePreferences();
this.app.showToast('Notifications enabled', 'success');
} else {
this.app.showToast(`Permission ${result}`, 'warning');
}
}
// Layer 4: Audio alert via Web Audio API
playAudioAlert() {
try {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = this.audioCtx;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.15);
} catch (_e) { /* Audio not available */ }
}
// UI interactions
toggleDrawer() {
const drawer = document.getElementById('notifDrawer');
if (!drawer) return;
this.isDrawerOpen = !this.isDrawerOpen;
drawer.classList.toggle('open', this.isDrawerOpen);
if (this.isDrawerOpen) {
this.renderDrawer();
}
}
clickNotification(notifId) {
const notif = this.notifications.find(n => n.id === notifId);
if (!notif) return;
// Mark as read
if (!notif.read) {
notif.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
}
// Switch to session if available
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
this.app.switchToSession(notif.sessionId);
this.toggleDrawer();
}
this.scheduleRender();
}
markAllRead() {
this.notifications.forEach(n => { n.read = true; });
this.unreadCount = 0;
this.updateBadge();
this.stopTitleFlash();
this.scheduleRender();
}
clearAll() {
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.stopTitleFlash();
this.scheduleRender();
}
updateBadge() {
const badge = document.getElementById('notifBadge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.style.display = 'flex';
badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount);
} else {
badge.style.display = 'none';
}
}
onTabVisible() {
this.stopTitleFlash();
// If drawer is open, mark all as read
if (this.isDrawerOpen) {
this.markAllRead();
}
}
// Utilities
relativeTime(ts) {
const diff = Math.floor((Date.now() - ts) / 1000);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
escapeHtml(text) {
if (!text) return '';
return text.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
}
class ClaudemanApp {
constructor() {
this.sessions = new Map();
@@ -36,6 +359,10 @@ class ClaudemanApp {
// System stats polling
this.systemStatsInterval = null;
// Notification system
this.notificationManager = new NotificationManager(this);
this.idleTimers = new Map(); // Map<sessionId, timeout> for stuck detection
// DOM element cache for performance (avoid repeated getElementById calls)
this._elemCache = {};
@@ -345,6 +672,12 @@ class ClaudemanApp {
this.sessions.delete(data.id);
this.terminalBuffers.delete(data.id);
this.ralphStates.delete(data.id); // Clean up ralph state for this session
// Clean up idle timer for this session
const idleTimer = this.idleTimers.get(data.id);
if (idleTimer) {
clearTimeout(idleTimer);
this.idleTimers.delete(data.id);
}
if (this.activeSessionId === data.id) {
this.activeSessionId = null;
this.terminal.clear();
@@ -405,6 +738,15 @@ class ClaudemanApp {
if (data.id === this.activeSessionId) {
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
}
const session = this.sessions.get(data.id);
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-error',
sessionId: data.id,
sessionName: session?.name || data.id?.slice(0, 8),
title: 'Session Error',
message: data.error || 'Unknown error',
});
});
this.eventSource.addEventListener('session:exit', (e) => {
@@ -414,6 +756,17 @@ class ClaudemanApp {
session.status = 'stopped';
this.renderSessionTabs();
}
// Notify on unexpected exit (non-zero code)
if (data.code && data.code !== 0) {
this.notificationManager?.notify({
urgency: 'critical',
category: 'session-crash',
sessionId: data.id,
sessionName: session?.name || data.id?.slice(0, 8),
title: 'Session Crashed',
message: `Exited with code ${data.code}`,
});
}
});
this.eventSource.addEventListener('session:idle', (e) => {
@@ -425,6 +778,23 @@ class ClaudemanApp {
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
const s = this.sessions.get(data.id);
this.notificationManager?.notify({
urgency: 'warning',
category: 'session-stuck',
sessionId: data.id,
sessionName: s?.name || data.id?.slice(0, 8),
title: 'Session Idle',
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
});
this.idleTimers.delete(data.id);
}, threshold));
}
});
this.eventSource.addEventListener('session:working', (e) => {
@@ -436,6 +806,12 @@ class ClaudemanApp {
this.renderSessionTabs();
this.sendPendingCtrlL(data.id);
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
}
});
// Scheduled run events
@@ -509,6 +885,15 @@ class ClaudemanApp {
if (data.sessionId === this.activeSessionId) {
document.getElementById('respawnStep').textContent = '⏎ Auto-accepted prompt';
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'auto-accept',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId?.slice(0, 8),
title: 'Auto-Accepted',
message: `Accepted prompt for ${session?.name || 'session'}`,
});
});
this.eventSource.addEventListener('respawn:detectionUpdate', (e) => {
@@ -541,6 +926,15 @@ class ClaudemanApp {
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
this.updateRespawnTokens(0);
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'info',
category: 'auto-clear',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId?.slice(0, 8),
title: 'Auto-Cleared',
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
});
});
// Background task events
@@ -633,6 +1027,65 @@ class ClaudemanApp {
existing.loop.active = false;
this.updateRalphState(data.sessionId, existing);
}
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'ralph-complete',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId?.slice(0, 8),
title: 'Loop Complete',
message: `Completion: ${data.phrase || 'unknown'}`,
});
});
// Spawn agent notification events
this.eventSource.addEventListener('spawn:failed', (e) => {
const data = JSON.parse(e.data);
this.notificationManager?.notify({
urgency: 'critical',
category: 'spawn-failed',
sessionId: data.sessionId,
sessionName: data.agentId || 'agent',
title: 'Agent Failed',
message: `Agent "${data.agentId}" failed: ${data.reason || 'unknown'}`,
});
});
this.eventSource.addEventListener('spawn:timeout', (e) => {
const data = JSON.parse(e.data);
this.notificationManager?.notify({
urgency: 'critical',
category: 'spawn-timeout',
sessionId: data.sessionId,
sessionName: data.agentId || 'agent',
title: 'Agent Timeout',
message: `Agent "${data.agentId}" exceeded time limit`,
});
});
this.eventSource.addEventListener('spawn:budgetWarning', (e) => {
const data = JSON.parse(e.data);
this.notificationManager?.notify({
urgency: 'warning',
category: 'spawn-budget',
sessionId: data.sessionId,
sessionName: data.agentId || 'agent',
title: 'Budget Warning',
message: `Agent "${data.agentId}" at ${data.percent || 80}% budget`,
});
});
this.eventSource.addEventListener('spawn:completed', (e) => {
const data = JSON.parse(e.data);
this.notificationManager?.notify({
urgency: 'info',
category: 'spawn-completed',
sessionId: data.sessionId,
sessionName: data.agentId || 'agent',
title: 'Agent Complete',
message: `Agent "${data.agentId}" finished successfully`,
});
});
}
@@ -2007,6 +2460,20 @@ class ClaudemanApp {
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
// Notification settings
const notifPrefs = this.notificationManager?.preferences || {};
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
// Update permission status display
const permStatus = document.getElementById('notifPermissionStatus');
if (permStatus && typeof Notification !== 'undefined') {
permStatus.textContent = `Status: ${Notification.permission}`;
}
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
const modal = document.getElementById('appSettingsModal');
@@ -2050,6 +2517,20 @@ class ClaudemanApp {
// Save to localStorage
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
// Save notification preferences separately
if (this.notificationManager) {
this.notificationManager.preferences = {
enabled: document.getElementById('appSettingsNotifEnabled').checked,
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
};
this.notificationManager.savePreferences();
}
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applyMonitorVisibility();
@@ -3056,6 +3537,10 @@ class ClaudemanApp {
// Cached toast container for performance
_toastContainer = null;
toggleNotifications() {
this.notificationManager?.toggleDrawer();
}
// Alias for showToast
toast(message, type = 'info') {
return this.showToast(message, type);
+79
View File
@@ -46,6 +46,10 @@
<span class="stat-value" id="statMem">--</span>
</div>
</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications">
&#x1F514;
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
</button>
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings">&#x2699;</button>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
</div>
@@ -467,6 +471,7 @@
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
<button class="modal-tab-btn" data-tab="settings-claude">Claude CLI</button>
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
</div>
<div class="modal-body">
<!-- Display Tab -->
@@ -542,6 +547,66 @@
<span class="form-hint">Default directory for new sessions.</span>
</div>
</div>
<!-- Notifications Tab -->
<div class="modal-tab-content hidden" id="settings-notifications">
<div class="form-row form-row-switch">
<label>Enable Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifEnabled" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Master toggle for all notification layers</span>
</div>
<div class="form-row form-row-switch">
<label>Browser Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifBrowser">
<span class="slider"></span>
</label>
<span class="form-hint">Show OS-level notifications when tab is hidden</span>
</div>
<div class="form-row">
<button class="btn-toolbar btn-sm" onclick="app.notificationManager?.requestPermission()">Request Permission</button>
<span class="form-hint" id="notifPermissionStatus">Status: unknown</span>
</div>
<div class="form-row form-row-switch">
<label>Audio Alerts</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifAudio">
<span class="slider"></span>
</label>
<span class="form-hint">Play a short beep for critical events</span>
</div>
<div class="form-row">
<label>Idle Threshold (minutes)</label>
<input type="number" id="appSettingsNotifStuckMins" value="10" min="1" max="120" style="width: 80px;">
<span class="form-hint">Notify when a session is idle longer than this</span>
</div>
<div class="form-row form-row-switch">
<label>Critical Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifCritical" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Errors, crashes, agent failures</span>
</div>
<div class="form-row form-row-switch">
<label>Warning Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifWarning" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Completions, budget warnings, stuck sessions</span>
</div>
<div class="form-row form-row-switch">
<label>Info Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifInfo" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Auto-accepts, auto-clears, agent completions</span>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
@@ -623,6 +688,20 @@
</div>
</div>
<!-- Notification Drawer -->
<div class="notification-drawer" id="notifDrawer">
<div class="notif-drawer-header">
<span class="notif-drawer-title">Notifications</span>
<div class="notif-drawer-actions">
<button class="btn-notif-action" onclick="app.notificationManager?.markAllRead()" title="Mark all read">&#x2713;</button>
<button class="btn-notif-action" onclick="app.notificationManager?.clearAll()" title="Clear all">&#x1F5D1;</button>
<button class="btn-notif-action" onclick="app.toggleNotifications()" title="Close">&times;</button>
</div>
</div>
<div class="notif-drawer-list" id="notifList"></div>
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
</div>
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
</div>
+188
View File
@@ -2174,6 +2174,194 @@ kbd {
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
.toast-info { border-color: rgba(59, 130, 246, 0.4); }
/* Notification Bell Button */
.btn-icon-header.btn-notifications {
position: relative;
width: 32px;
height: 32px;
font-size: 1.1rem;
margin-left: 0.5rem;
}
.btn-icon-header.btn-notifications:hover {
transform: none;
}
.notification-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--red);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
animation: notif-badge-pulse 2s ease-in-out infinite;
}
@keyframes notif-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
/* Notification Drawer */
.notification-drawer {
position: fixed;
top: var(--header-height);
right: 0;
width: 340px;
height: calc(100vh - var(--header-height));
background: var(--bg-card);
border-left: 1px solid var(--border);
z-index: 10001;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.2s ease;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
}
.notification-drawer.open {
transform: translateX(0);
}
.notif-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.notif-drawer-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
}
.notif-drawer-actions {
display: flex;
gap: 4px;
}
.btn-notif-action {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
width: 26px;
height: 26px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
transition: all 0.15s;
}
.btn-notif-action:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-light);
}
.notif-drawer-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.notif-drawer-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.8rem;
}
/* Notification Items */
.notif-item {
padding: 10px 12px;
margin-bottom: 4px;
border-radius: 6px;
border-left: 3px solid var(--border-light);
background: var(--bg-dark);
cursor: pointer;
transition: background 0.15s;
animation: notif-slide-in 0.2s ease;
}
.notif-item:hover {
background: var(--bg-hover);
}
.notif-item.unread {
background: rgba(59, 130, 246, 0.05);
}
.notif-item-critical { border-left-color: var(--red); }
.notif-item-warning { border-left-color: var(--yellow); }
.notif-item-info { border-left-color: var(--accent); }
.notif-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.notif-item-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.notif-item-count {
font-size: 0.65rem;
color: var(--text-muted);
margin-left: 6px;
}
.notif-item-time {
font-size: 0.65rem;
color: var(--text-muted);
white-space: nowrap;
}
.notif-item-message {
font-size: 0.72rem;
color: var(--text-dim);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-item-session {
display: inline-block;
margin-top: 4px;
padding: 1px 6px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 0.65rem;
color: var(--text-dim);
}
@keyframes notif-slide-in {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Task Badge on Session Tab */
.session-tab .tab-badge {
display: flex;