Files
Codeman/docs/reports/notification-blinking.md
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

550 lines
19 KiB
Markdown

# 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:
```js
// 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:
```js
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):
```js
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:
```js
// 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:
```html
<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):
```js
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:
```css
/* 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)
```css
.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 }`:
```js
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)
```css
.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)
```css
.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)
```css
.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:
```js
// 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)
```js
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 (`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):
```js
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):
```js
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):
```js
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):
```js
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):
```js
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:
```js
// 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:
```js
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).