Full product rename across 109 files (~834 occurrences): - Env vars: CLAUDEMAN_* → CODEMAN_* - Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/ - tmux prefix: claudeman- → codeman- - localStorage: claudeman-* → codeman-* - Package/CLI: claudeman → codeman - GitHub repo: Ark0N/Claudeman → Ark0N/Codeman - systemd service: claudeman-web → codeman-web - Class: ClaudemanApp → CodemanApp Migration infrastructure for seamless transition: - state-store.ts: auto-migrates data directories on startup - tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions) - app.js: localStorage key migration (preserves old keys) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
19 KiB
Notification Blinking & Visual Alert System - Deep Dive
Overview
Codeman implements a 4-layer notification system managed by the NotificationManager class (app.js lines 860-1265). The layers are:
- In-app notification drawer (Layer 1) - badge + list UI
- Document title flashing (Layer 2) - tab title blinks when hidden
- Browser Web Notifications (Layer 3) - OS-level popups
- Audio alerts (Layer 4) - Web Audio API beeps
In addition, there are CSS-based tab alert animations that are independent of NotificationManager and driven by the pending hooks state machine.
1. Document Title Blinking
Location
src/web/public/app.js lines 1082-1104
Mechanism
The title blink uses setInterval to toggle document.title between two states every 1500ms:
// Constants (line 14)
const TITLE_FLASH_INTERVAL_MS = 1500;
// updateTabTitle() - line 1082
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}) Codeman`
: this.originalTitle;
}, TITLE_FLASH_INTERVAL_MS);
// Set immediately
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
}
}
}
The title alternates between:
- Warning emoji + unread count:
"(3) Codeman" - Original title:
"Codeman"
What Triggers It
Title flashing starts when notify() is called while the tab is not visible (!this.isTabVisible). The check is at lines 1026-1028:
if (!this.isTabVisible) {
this.updateTabTitle();
}
Every event that calls notificationManager.notify() can trigger title blinking. This includes:
session:error- Session errors (critical)session:exit- Unexpected exits with non-zero codes (critical)session:idle- Stuck detection after threshold (warning)hook:idle_prompt- Claude waiting for input (warning)hook:permission_prompt- Tool approval needed (critical)hook:elicitation_dialog- Claude asking a question (critical)hook:stop- Response complete (info)respawn:blocked- Respawn blocked (critical)respawn:autoAcceptSent- Plan accepted (info)session:autoClear- Auto-cleared context (info)session:ralphCompletionDetected- Loop complete (warning)session:circuitBreakerUpdate(when OPEN) - Criticalsession:exitGateMet- Exit gate met (warning)- Various Ralph/fix-plan operations
What Stops It
Title flashing stops via stopTitleFlash() (lines 1097-1104):
stopTitleFlash() {
if (this.titleFlashInterval) {
clearInterval(this.titleFlashInterval);
this.titleFlashInterval = null;
this.titleFlashState = false;
document.title = this.originalTitle;
}
}
Called from:
onTabVisible()(line 1242) - When tab becomes visible againmarkAllRead()(line 1218) - When user marks all notifications readclearAll()(line 1226) - When user clears all notificationshandleInit()cleanup (lines 3294-3298) - On SSE reconnect
Interval Safety
The interval guard (if (!this.titleFlashInterval)) at line 1084 prevents stacking - only one interval can exist at a time. New notifications while blinking update the unreadCount displayed but don't create additional intervals. This is correct and safe.
Memory Leak Risk: LOW
The interval is properly cleaned up in:
onTabVisible()- on every tab returnhandleInit()- on SSE reconnect (lines 3294-3298)markAllRead()andclearAll()- user actions
The handleInit() cleanup is particularly important because SSE reconnects reset all state. The explicit cleanup at lines 3294-3298 prevents orphaned intervals:
// Clear notification manager title flash interval to prevent memory leak
if (this.notificationManager?.titleFlashInterval) {
clearInterval(this.notificationManager.titleFlashInterval);
this.notificationManager.titleFlashInterval = null;
}
2. Favicon Changes
Current State: NO dynamic favicon changes
The favicon is defined as an inline SVG data URI in index.html line 9:
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,...">
It shows a lightning bolt icon on a dark background. The favicon is static and never changes programmatically.
Browser notifications reference /favicon.ico as their icon (line 1130):
const notif = new Notification(`Codeman: ${title}`, {
body,
tag,
icon: '/favicon.ico',
silent: true,
});
This is for the OS notification popup icon, not the browser tab favicon. There is no code that manipulates link[rel="icon"] or swaps the favicon for attention.
3. CSS Animations for Attention
3.1 Tab Alert Animations (styles.css lines 328-345)
Two CSS animation classes are applied to session tabs:
/* Red blinking tab - for action-required hooks */
.session-tab.tab-alert-action {
animation: tab-blink-red 2.5s ease-in-out infinite;
}
/* Yellow blinking tab - for idle hooks */
.session-tab.tab-alert-idle {
animation: tab-blink-yellow 3.5s ease-in-out infinite;
}
@keyframes tab-blink-red {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(239, 68, 68, 0.12); border-color: var(--red); }
}
@keyframes tab-blink-yellow {
0%, 100% { background: transparent; border-color: transparent; }
50% { background: rgba(234, 179, 8, 0.1); border-color: var(--yellow); }
}
- Red blink (2.5s cycle): Permission prompts, elicitation dialogs - requires user action
- Yellow blink (3.5s cycle): Idle prompt - Claude waiting for input
These are CSS-only infinite animations with no JavaScript timer overhead. They are performant and have zero memory leak risk.
3.2 Tab Switch Glow (styles.css lines 241-251)
.session-tab.tab-glow {
animation: tab-glow 0.35s ease-out forwards;
}
A one-shot green glow burst when switching tabs. Applied in selectSession() (app.js line 3985) and cleaned up via animationend event with { once: true }:
activeTab.classList.add('tab-glow');
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
This is safe - { once: true } auto-removes the listener.
3.3 Notification Badge Pulse (styles.css lines 3978-4000)
.notification-badge {
animation: notif-badge-pulse 2s ease-in-out infinite;
}
@keyframes notif-badge-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
The red notification count badge in the header bell icon pulses continuously when visible. Pure CSS, no memory concerns.
3.4 Status Dot Pulse (styles.css lines 261-265, 347-350)
.session-tab .tab-status.busy {
background: var(--green);
animation: pulse 1.5s infinite;
will-change: opacity;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
The green status dot pulses when a session is busy/working. Pure CSS.
3.5 Connection Status Animations (styles.css lines 405-413)
.connection-dot.warning {
animation: connection-pulse 1.5s ease-in-out infinite;
}
.connection-dot.error {
animation: connection-pulse 0.8s ease-in-out infinite;
}
Connection indicator pulses differently for warning vs error states.
3.6 Other CSS Animations
| Animation | Location | Purpose |
|---|---|---|
respawn-blocked-pulse |
styles.css:798 | Respawn blocked indicator |
pulse-hook |
styles.css:899 | Hook event indicator |
ralph-pulse |
styles.css:1013 | Ralph tracker active state |
circuit-breaker-pulse |
styles.css:1057 | Circuit breaker warning |
wizard-pulse |
styles.css:5541 | Ralph wizard active indicator |
plan-subagent-pulse |
styles.css:5558 | Plan subagent active |
notif-slide-in |
styles.css:4088 | Notification drawer slide |
3.7 Mobile-Specific Animations (mobile.css)
Mobile CSS is minimal for animations:
slideUp(line 1091) - Mobile toolbar slide animationcaseModalSlideUp(line 1204) - Case modal bottom-sheet animation
No mobile-specific blinking or notification animations exist.
4. Tab Visibility API
Implementation (app.js lines 881-893)
The NotificationManager constructor sets up two visibility listeners:
// Standard visibility change
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();
}
});
Behavior When Tab Hidden
isTabVisibleset tofalse- New notifications trigger
updateTabTitle()which starts the title blink interval - Browser notifications are sent (subject to per-event preferences)
Behavior When Tab Becomes Visible (onTabVisible() - line 1241)
onTabVisible() {
this.stopTitleFlash(); // Stop title blinking
if (this.isDrawerOpen) {
this.markAllRead(); // Mark all read if drawer is open
}
// Re-fit terminal dimensions
if (this.app?.fitAddon && this.app?.activeSessionId) {
this.app.fitAddon.fit();
this.app.sendResize(this.app.activeSessionId);
}
}
Key detail: Title flash always stops when tab becomes visible, but unread count is NOT reset unless the notification drawer is open. This means the badge count persists until the user interacts with it.
5. Focus/Blur Handling
No window focus/blur listeners
Codeman does not use window.addEventListener('focus') or window.addEventListener('blur'). It relies solely on the Page Visibility API (visibilitychange + pageshow).
This is the correct modern approach. The focus/blur events are unreliable (fire for devtools, iframe changes, etc.) while visibilitychange accurately reflects whether the user can see the tab.
Other focus-related listeners
document.addEventListener('focusin')(line 238) - Mobile keyboard handler for scrolling inputs into view- Various
element.focus()calls for modal focus trapping (FocusTrapclass, line 742) window.focus()in browser notification click handler (line 1135) to bring window to front
None of these are related to notification/blinking behavior.
6. Multiple Notification Stacking
Can intervals stack? NO
The updateTabTitle() method has a guard (line 1084):
if (!this.titleFlashInterval) {
this.titleFlashInterval = setInterval(() => { ... }, TITLE_FLASH_INTERVAL_MS);
}
Only one interval is ever created. Subsequent notifications while the tab is hidden simply update this.unreadCount which is read by the existing interval callback. The displayed count stays current without creating new intervals.
Notification Grouping
Notifications within the same category + session within 5 seconds are grouped instead of creating new entries (lines 986-997):
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; // <-- Early return prevents duplicate badge/title/browser/audio triggers
}
The grouping early return prevents:
- Duplicate badge increments
- Duplicate title updates
- Duplicate browser notifications
- Duplicate audio alerts
Browser Notification Rate Limiting
Even without grouping, browser notifications are rate-limited to 1 per 3 seconds (lines 1122-1125):
const now = Date.now();
if (now - this.lastBrowserNotifTime < 3000) return;
this.lastBrowserNotifTime = now;
Notification List Cap
The notification list is capped at 100 entries (line 1014):
if (this.notifications.length > 100) this.notifications.pop();
Grouping Timeout Cleanup
Grouping map entries self-clean after 5 seconds via setTimeout. These timeouts are also explicitly cleaned up in handleInit() (lines 3299-3304):
if (this.notificationManager?.groupingMap) {
for (const { timeout } of this.notificationManager.groupingMap.values()) {
clearTimeout(timeout);
}
this.notificationManager.groupingMap.clear();
}
Rapid Event Scenario
If 50 events fire while the tab is hidden:
- First event: creates notification, starts title flash, sends browser notif
- Events 2-N within 5s of same category+session: grouped (count increments, no new intervals)
- Events of different categories: new notifications, but title flash interval is singular
- Browser notifications: only 1 per 3s gets through
Verdict: Well-protected against stacking/compounding.
7. Team Agent Blinking
Current State: NO team-specific blinking
Searching for team: SSE event listeners finds none. There are no addListener('team:...') handlers in app.js.
Team agent data (teammates, tasks, colors) is tracked via:
this.teammateMap(Map of agent info)this.teammatePanesByName(Map of pane targets)this.teammateTerminals(Map of terminal instances)
But these are populated from subagent data, not dedicated team events. Teammates appear as standard subagents and are detected by subagent-watcher.ts (as noted in MEMORY.md: "Teammates appear as standard subagents").
What team events could trigger blinking?
Currently, subagent events have their own notification category:
// Default event type preferences (line 906-907)
subagent_spawn: { enabled: false, browser: false, audio: false },
subagent_complete: { enabled: false, browser: false, audio: false },
Both are disabled by default. Even if enabled, they go through the standard notify() path which would trigger title blinking only when the tab is hidden.
Should team events trigger blinking?
The MEMORY.md implementation priority notes:
- Team-aware idle detection (prevent premature respawn)
There is no TeammateIdle or TaskCompleted hook handler in the frontend. The hooks are mentioned in MEMORY.md as valid settings schema keys but have no frontend implementation yet.
If/when team hooks are implemented, they should:
- Potentially trigger tab-alert-action (red blink) for teammate stuck/blocked states
- Use the notification system for teammate task completions
- Consider a new notification category (e.g.,
teammate_idle,team_task_complete) with configurable per-event preferences
8. Cleanup Analysis
All Interval/Timeout Cleanup Points
| Timer | Created | Cleared | Risk |
|---|---|---|---|
titleFlashInterval |
updateTabTitle() L1085 |
stopTitleFlash() L1099, onTabVisible() L1242, handleInit() L3296 |
LOW - guarded + multi-path cleanup |
| Grouping timeouts | notify() L994/L1017 |
Self-expire 5s, handleInit() L3301 |
LOW - TTL + explicit cleanup |
| Browser notif auto-close | sendBrowserNotif() L1143 |
Self-expire 8s (via setTimeout) |
NONE - fires once |
| Audio oscillator | playAudioAlert() L1178 |
Self-stops 0.15s | NONE - Web Audio manages it |
| Idle timer per session | session:idle handler L2384 |
session:working L2415, handleInit() L3269, removeSession() L4138 |
LOW - cleaned in all paths |
handleInit Cleanup (SSE Reconnect)
The handleInit() method (called on every SSE reconnect) performs comprehensive cleanup (lines 3260-3321):
- Clears all Maps (sessions, ralphStates, terminalBuffers, etc.)
- Clears all idle timers
- Clears flicker filter state
- Clears pending terminal writes
- Clears pending hooks and tab alerts
- Clears notification title flash interval (L3295-3298)
- Clears notification grouping timeouts (L3300-3304)
- Disconnects terminal resize observer
- Clears plan loading timers
- Clears countdown intervals
- Clears run summary auto-refresh timer
removeSession Cleanup (line 4120-4139)
When a session is removed:
pendingHooks.delete(sessionId)(L4128)tabAlerts.delete(sessionId)(L4129)- Idle timer cleared (L4136-4139)
- All floating windows closed
Browser Notification Auto-Close
The setTimeout(() => notif.close(), 8000) at line 1143 creates an anonymous closure over the notif variable. This is safe because:
- The timeout fires once and is garbage collected
notif.close()is idempotent- 8 seconds is short enough to not accumulate
Edge Case: AudioContext
The AudioContext (line 1167) is created once and reused:
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
This is never explicitly closed, but AudioContext is lightweight when idle and the singleton pattern prevents accumulation. Not a practical concern.
Architecture Diagram
notify() called
|
+-----------+-----------+
| |
enabled check preferences check
| |
[Layer 1: Drawer] [Layer 2: Title Flash]
- Add to notifications[] - Only if tab hidden
- Cap at 100 - Single interval guard
- Update badge count - Toggles every 1500ms
- requestAnimationFrame
| |
[Layer 3: Browser Notif] [Layer 4: Audio]
- Per-event prefs - Per-event prefs
- Rate limit 3s - Web Audio API
- Auto-close 8s - 0.15s beep
- Permission check - Singleton AudioContext
INDEPENDENT:
[Tab CSS Alerts]
- Driven by pendingHooks state machine
- tab-alert-action (red, 2.5s cycle)
- tab-alert-idle (yellow, 3.5s cycle)
- Pure CSS animation, no JS timers
Summary of Findings
| Aspect | Status | Notes |
|---|---|---|
| Title blink interval safety | SAFE | Guard prevents stacking; 3-path cleanup |
| Favicon changes | NOT IMPLEMENTED | Static inline SVG; no dynamic swapping |
| CSS tab animations | SAFE | Pure CSS infinite animations; no JS timer cost |
| Visibility API usage | CORRECT | visibilitychange + pageshow (bfcache) |
| Focus/blur handling | NOT USED (correct) | Relies on Visibility API instead |
| Notification stacking | WELL PROTECTED | Grouping, rate limiting, single interval |
| Team agent blinking | NOT IMPLEMENTED | No team: SSE handlers; teammates use subagent path |
| Interval cleanup | COMPREHENSIVE | handleInit, onTabVisible, removeSession, markAllRead, clearAll |
| Memory leak risk | LOW | All timers have explicit cleanup paths |
Potential Improvements
-
Dynamic favicon: Could swap favicon to a red/orange variant when there are unread critical notifications (common pattern in web apps).
-
Team agent notifications: When
TeammateIdleandTaskCompletedhooks are implemented, add dedicated notification categories with configurable preferences in the per-event settings grid. -
Unread count on tab return: Currently, returning to the tab stops the title flash but does NOT reset the unread count. The user must open the drawer or click individual notifications. Consider auto-marking as read after a brief delay when the tab becomes visible.
-
Notification sound variety: Currently all audio alerts use the same 660Hz sine wave. Different categories could use different tones (e.g., lower pitch for info, higher for critical).