Files
Codeman/docs/reports/notification-blinking.md
T
arkonandClaude Opus 4.6 3795c45cc1 chore: rename Claudeman to Codeman
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>
2026-02-26 16:44:34 +01:00

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:

  1. In-app notification drawer (Layer 1) - badge + list UI
  2. Document title flashing (Layer 2) - tab title blinks when hidden
  3. Browser Web Notifications (Layer 3) - OS-level popups
  4. 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) - Critical
  • session: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:

  1. onTabVisible() (line 1242) - When tab becomes visible again
  2. markAllRead() (line 1218) - When user marks all notifications read
  3. clearAll() (line 1226) - When user clears all notifications
  4. handleInit() 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 return
  • handleInit() - on SSE reconnect (lines 3294-3298)
  • markAllRead() and clearAll() - 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 animation
  • caseModalSlideUp (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

  • isTabVisible set to false
  • 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.

  • document.addEventListener('focusin') (line 238) - Mobile keyboard handler for scrolling inputs into view
  • Various element.focus() calls for modal focus trapping (FocusTrap class, 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:

  1. First event: creates notification, starts title flash, sends browser notif
  2. Events 2-N within 5s of same category+session: grouped (count increments, no new intervals)
  3. Events of different categories: new notifications, but title flash interval is singular
  4. 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:

  1. 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):

  1. Clears all Maps (sessions, ralphStates, terminalBuffers, etc.)
  2. Clears all idle timers
  3. Clears flicker filter state
  4. Clears pending terminal writes
  5. Clears pending hooks and tab alerts
  6. Clears notification title flash interval (L3295-3298)
  7. Clears notification grouping timeouts (L3300-3304)
  8. Disconnects terminal resize observer
  9. Clears plan loading timers
  10. Clears countdown intervals
  11. 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:

  1. The timeout fires once and is garbage collected
  2. notif.close() is idempotent
  3. 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

  1. Dynamic favicon: Could swap favicon to a red/orange variant when there are unread critical notifications (common pattern in web apps).

  2. Team agent notifications: When TeammateIdle and TaskCompleted hooks are implemented, add dedicated notification categories with configurable preferences in the per-event settings grid.

  3. 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.

  4. 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).