mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen". The screenshot is not a dropped frame or a frozen renderer — it is arithmetic. Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the rows it believes that frame took. A browser terminal of a different width makes each logical line occupy more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased: doubled lines, and short tool summaries sitting inside longer prose rows with the prose's tail still visible. Reproduced against this repo's own xterm before changing anything — a 120-column PTY against a 62-column terminal renders every wrapped line twice. `test/ terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching widths beside it, so the assertion cannot be satisfied by code that fixes nothing. Four ways the two drifted apart, none of them observable from either end: 1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every server-facing path reported those floored at 40x10. Measured in Chrome at 430px: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. Three call sites each did their own fit-then-floor, and two re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly there, so the container had moved. 2. `throttledResize` (keyboard up) and `sendResize` (session detached into its own window) reflowed locally and withheld only the SIGWINCH. That is the one combination that cannot be right: a reflow nothing is rendering for buys nothing and costs correctness. Both now withhold everything, and the keyboard's settle timer still sends the one resize that stops the PTY going stale. 3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry change — and told the server nothing at all, so raising the font on a phone left the CLI wrapping at the old column count. 4. `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim, and said nothing, because resize was write-only. `syncTerminalGeometry()` is now the one function that may change the terminal's size: it fits, floors and applies as a single step, so the numbers xterm holds are the numbers the server is told. A test sweeps every module for a bare `fit()` on the main terminal, and finds exactly one — the owner's own. For (4) the client cannot win, so it is told the truth instead: both transports answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal that keeps a shape the PTY refused does not render "too narrow", it renders garbled. Adopting can leave the pane wider than the screen and the container is `overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as long as the mismatch lasts: correct-and-reachable beats correct-and-clipped beats garbled. That rule sets both overflow axes and its own `touch-action` because mobile.css loads later and sets `.terminal-container { overflow: visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y computing to `auto` and hand the browser a vertical scroll container the terminal's touch handler knows nothing about. Verified in Chrome at 430px against a live server, with a desktop client holding the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y: hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own vertical scrolling. The pre-fix build was measured in the same harness for the control. Two things this deliberately does not do. It does not change who owns the pane size — the desktop still wins, and `_startMobileResizeRetry` still takes it back once that goes idle. And `throttledResize` still holds the PTY's shape for the whole keyboard animation rather than sending a SIGWINCH per step; that decision predates this and was not re-tested here. Also in this commit, Ark0N's third-pass review items on #431: - The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes` (32MB) — the largest body the frontend asks for anywhere. One carried no deadline at all and the other got the 15s tail budget. Both now take the full-history budget. - A `?full=1` capture that outruns its deadline falls back to the bounded tail. The pane is blanked before that fetch, so an abort used to leave a black rectangle, discard the queued live output and never reach `_connectWs`. A failed load now still opens the socket, says one dim line where the content would have been, and clears the tab's spinner — which nothing did, so a failed select left `aria-busy="true"` set forever. - `_wsOutputGapSession` is cleared at the repaint that settles it, not in a `finally` that also ran on the catch. A reconcile that threw, or hit the new deadline — the flaky link the marker exists for — dropped the gap with nothing to retry it. `ws.onopen` no longer clears it up front either. - The replay-clear invariant is pinned in the gate, which is the drift this PR exists to fix: `_resetTerminalForReplay` must be a queued write and nothing else, and no module may blank the terminal with a `clear()+reset()` pair. - `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was never declared, and that is the one function in the app that must not throw. - panels-ui's two kill-all clears route through the same helper, and the xterm-version guard's comment says "resolved lockfile version" rather than "dependency RANGE", which is what it has pinned since the last round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
532 lines
18 KiB
JavaScript
532 lines
18 KiB
JavaScript
/**
|
|
* @fileoverview Five-layer notification system for session events and alerts.
|
|
*
|
|
* The NotificationManager class implements five notification layers:
|
|
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
|
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
|
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
|
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
|
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
|
*
|
|
* Features:
|
|
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
|
|
* - Device-specific defaults (notifications disabled on mobile by default)
|
|
* - 5s notification grouping window to batch rapid-fire events
|
|
* - 100-notification cap with oldest eviction
|
|
* - Rate limiting: 3s between browser notifications
|
|
* - Visibility tracking (pauses title flash when tab becomes visible)
|
|
* - iOS Safari bfcache support via pageshow event
|
|
*
|
|
* @class NotificationManager
|
|
* @param {CodemanApp} app - Reference to the main app instance
|
|
*
|
|
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
|
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
|
|
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
|
|
*/
|
|
|
|
// Codeman — Multi-layer notification system
|
|
// Loaded after mobile-handlers.js, before app.js
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
// iOS Safari: pageshow fires on back-forward cache restore (bfcache)
|
|
window.addEventListener('pageshow', (e) => {
|
|
if (e.persisted) {
|
|
this.isTabVisible = true;
|
|
this.onTabVisible();
|
|
}
|
|
});
|
|
}
|
|
|
|
_usesMobilePreferences() {
|
|
return (
|
|
MobileDetection.isHandheldDevice?.() ??
|
|
MobileDetection.getDeviceType() === 'mobile'
|
|
);
|
|
}
|
|
|
|
getDefaultPreferences() {
|
|
const defaultEventTypes = {
|
|
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
|
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
|
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
|
stop: { enabled: false, browser: false, audio: false, push: false },
|
|
session_error: { enabled: true, browser: true, audio: false, push: false },
|
|
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
|
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
|
ralph_complete: { enabled: true, browser: true, audio: true, push: false },
|
|
subagent_spawn: { enabled: false, browser: false, audio: false, push: false },
|
|
subagent_complete: { enabled: false, browser: false, audio: false, push: false },
|
|
};
|
|
|
|
// Device-specific defaults: mobile has notifications disabled by default
|
|
const isMobile = this._usesMobilePreferences();
|
|
return {
|
|
enabled: !isMobile, // Disabled on mobile by default
|
|
browserNotifications: !isMobile,
|
|
audioAlerts: false,
|
|
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
|
// Legacy urgency muting (keep for backwards compat)
|
|
muteCritical: false,
|
|
muteWarning: false,
|
|
muteInfo: false,
|
|
// Per-event-type preferences
|
|
eventTypes: defaultEventTypes,
|
|
_version: 5,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply the complete v1→v5 migration to either local or server-hydrated
|
|
* preferences. Keeping one normalization path prevents fresh browsers from
|
|
* reviving retired drawer-only hook defaults.
|
|
*/
|
|
normalizePreferences(rawPreferences) {
|
|
const defaults = this.getDefaultPreferences();
|
|
if (
|
|
!rawPreferences ||
|
|
typeof rawPreferences !== 'object' ||
|
|
Array.isArray(rawPreferences)
|
|
) {
|
|
return defaults;
|
|
}
|
|
|
|
const prefs = {
|
|
...rawPreferences,
|
|
eventTypes:
|
|
rawPreferences.eventTypes &&
|
|
typeof rawPreferences.eventTypes === 'object' &&
|
|
!Array.isArray(rawPreferences.eventTypes)
|
|
? Object.fromEntries(
|
|
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
|
|
key,
|
|
value && typeof value === 'object' ? { ...value } : value,
|
|
])
|
|
)
|
|
: undefined,
|
|
};
|
|
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
|
|
|
|
// Migrate: v1 had browserNotifications defaulting to false
|
|
if (version < 2) {
|
|
prefs.browserNotifications = true;
|
|
}
|
|
// Migrate: v2 -> v3 adds eventTypes
|
|
if (version < 3) {
|
|
prefs.eventTypes = { ...defaults.eventTypes };
|
|
}
|
|
// Migrate: v3 -> v4 adds push field to all eventTypes
|
|
if (version < 4 && prefs.eventTypes) {
|
|
for (const key of Object.keys(prefs.eventTypes)) {
|
|
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
|
prefs.eventTypes[key].push = false;
|
|
}
|
|
}
|
|
}
|
|
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
|
|
// Preserve users who opted into any external delivery channel.
|
|
if (version < 5) {
|
|
const stopPref = prefs.eventTypes?.stop;
|
|
if (
|
|
stopPref?.enabled === true &&
|
|
!stopPref.browser &&
|
|
!stopPref.audio &&
|
|
!stopPref.push
|
|
) {
|
|
stopPref.enabled = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...defaults,
|
|
...prefs,
|
|
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
|
_version: 5,
|
|
};
|
|
}
|
|
|
|
loadPreferences() {
|
|
try {
|
|
const storageKey = this.getStorageKey();
|
|
const saved = localStorage.getItem(storageKey);
|
|
if (saved) {
|
|
const normalized = this.normalizePreferences(JSON.parse(saved));
|
|
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
|
return normalized;
|
|
}
|
|
} catch (_e) { /* ignore */ }
|
|
return this.getDefaultPreferences();
|
|
}
|
|
|
|
// Get storage key for notification prefs (device-specific)
|
|
getStorageKey() {
|
|
return this._usesMobilePreferences()
|
|
? 'codeman-notification-prefs-mobile'
|
|
: 'codeman-notification-prefs';
|
|
}
|
|
|
|
savePreferences() {
|
|
localStorage.setItem(this.getStorageKey(), JSON.stringify(this.preferences));
|
|
}
|
|
|
|
notify({ urgency, category, sessionId, sessionName, title, message }) {
|
|
if (!this.preferences.enabled) return;
|
|
|
|
// Map notification categories to eventType preference keys
|
|
const categoryToEventType = {
|
|
'hook-permission': 'permission_prompt',
|
|
'hook-elicitation': 'elicitation_dialog',
|
|
'hook-idle': 'idle_prompt',
|
|
'hook-stop': 'stop',
|
|
'session-error': 'session_error',
|
|
'session-crash': 'session_error',
|
|
'session-stuck': 'idle_prompt',
|
|
'respawn-blocked': 'respawn_cycle',
|
|
'auto-accept': 'respawn_cycle',
|
|
'auto-clear': 'respawn_cycle',
|
|
'ralph-complete': 'ralph_complete',
|
|
'circuit-breaker': 'respawn_cycle',
|
|
'exit-gate': 'ralph_complete',
|
|
'subagent-spawn': 'subagent_spawn',
|
|
'subagent-complete': 'subagent_complete',
|
|
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
|
|
// Reuse the existing opt-in agent categories instead of making them noisy.
|
|
'hook-teammate-idle': 'subagent_spawn',
|
|
'hook-task-completed': 'subagent_complete',
|
|
};
|
|
const eventTypeKey = categoryToEventType[category] || category;
|
|
|
|
// Check per-event-type preferences first
|
|
const eventPref = this.preferences.eventTypes?.[eventTypeKey];
|
|
let shouldBrowserNotify = false;
|
|
let shouldAudioAlert = false;
|
|
|
|
if (eventPref) {
|
|
// Event type found - use its specific preferences
|
|
if (!eventPref.enabled) return;
|
|
shouldBrowserNotify = eventPref.browser && this.preferences.browserNotifications;
|
|
shouldAudioAlert = eventPref.audio && this.preferences.audioAlerts;
|
|
} else {
|
|
// Fall back to urgency-based muting for unknown categories
|
|
if (urgency === 'critical' && this.preferences.muteCritical) return;
|
|
if (urgency === 'warning' && this.preferences.muteWarning) return;
|
|
if (urgency === 'info' && this.preferences.muteInfo) return;
|
|
// Default browser/audio behavior based on urgency
|
|
shouldBrowserNotify = this.preferences.browserNotifications &&
|
|
(urgency === 'critical' || urgency === 'warning' || !this.isTabVisible);
|
|
shouldAudioAlert = urgency === 'critical' && this.preferences.audioAlerts;
|
|
}
|
|
|
|
// 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), GROUPING_TIMEOUT_MS);
|
|
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 NOTIFICATION_LIST_CAP)
|
|
this.notifications.unshift(notification);
|
|
if (this.notifications.length > NOTIFICATION_LIST_CAP) this.notifications.pop();
|
|
|
|
// Track for grouping
|
|
const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
|
|
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
|
|
if (shouldBrowserNotify) {
|
|
this.sendBrowserNotif(title, message, category, sessionId);
|
|
}
|
|
|
|
// Layer 4: Audio alert
|
|
if (shouldAudioAlert) {
|
|
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">×${n.count}</span>` : '';
|
|
const sessionChip = n.sessionName ? `<span class="notif-item-session">${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(${escapeHtml(JSON.stringify(n.id))})">
|
|
<div class="notif-item-header">
|
|
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
|
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
|
</div>
|
|
<div class="notif-item-message">${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}) ${this.originalTitle}`
|
|
: this.originalTitle;
|
|
}, TITLE_FLASH_INTERVAL_MS);
|
|
// Set immediately
|
|
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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, sessionId) {
|
|
if (!this.preferences.browserNotifications) return;
|
|
if (typeof Notification === 'undefined') return;
|
|
if (Notification.permission === 'default') {
|
|
// Auto-request on first notification attempt
|
|
Notification.requestPermission().then(result => {
|
|
if (result === 'granted') {
|
|
// Re-send this notification now that we have permission
|
|
this.sendBrowserNotif(title, body, tag, sessionId);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (Notification.permission !== 'granted') return;
|
|
|
|
// Rate limit
|
|
const now = Date.now();
|
|
if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return;
|
|
this.lastBrowserNotifTime = now;
|
|
|
|
const localizedTitle = window.codemanT?.(title) || title;
|
|
const localizedBody = window.codemanT?.(body) || body;
|
|
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
|
|
body: localizedBody,
|
|
tag, // Groups same-tag notifications
|
|
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
|
|
silent: true, // We handle audio ourselves
|
|
});
|
|
|
|
notif.onclick = () => {
|
|
window.focus();
|
|
if (sessionId && this.app.sessions.has(sessionId)) {
|
|
this.app.selectSession(sessionId);
|
|
}
|
|
notif.close();
|
|
};
|
|
|
|
// Auto-close
|
|
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
|
|
}
|
|
|
|
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)();
|
|
}
|
|
if (this.audioCtx.state === 'suspended') {
|
|
this.audioCtx.resume();
|
|
}
|
|
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.selectSession(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();
|
|
}
|
|
// Re-fit terminal and send resize to PTY so this client's dimensions win.
|
|
// Fixes broken layout when switching between desktop and mobile on the same session.
|
|
// sendResize fits (floored) as its first synchronous step, so the bare
|
|
// fit that used to precede it was both redundant and a chance to leave
|
|
// xterm at the unfloored proposal (#464).
|
|
if (this.app?.activeSessionId) {
|
|
this.app.sendResize(this.app.activeSessionId);
|
|
}
|
|
}
|
|
|
|
// 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`;
|
|
}
|
|
}
|