From 9d67a571bab5ecd7c369935918816c136a67e04e Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 18 Feb 2026 05:36:20 +0100 Subject: [PATCH] chore: bump version to 0.1527 --- CLAUDE.md | 2 +- package.json | 2 +- reports/notification-audit-summary.md | 89 +++ reports/notification-backend.md | 489 +++++++++++++++++ reports/notification-blinking.md | 549 +++++++++++++++++++ reports/notification-frontend.md | 749 ++++++++++++++++++++++++++ reports/notification-settings.md | 459 ++++++++++++++++ src/web/public/app.js | 92 +++- src/web/server.ts | 2 +- 9 files changed, 2428 insertions(+), 5 deletions(-) create mode 100644 reports/notification-audit-summary.md create mode 100644 reports/notification-backend.md create mode 100644 reports/notification-blinking.md create mode 100644 reports/notification-frontend.md create mode 100644 reports/notification-settings.md diff --git a/CLAUDE.md b/CLAUDE.md index afc67f1a..f85491ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1526 (must match `package.json` for npm publish) +**Version**: 0.1527 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 6758a2ef..8740bd0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1526", + "version": "0.1527", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/reports/notification-audit-summary.md b/reports/notification-audit-summary.md new file mode 100644 index 00000000..b74b4d40 --- /dev/null +++ b/reports/notification-audit-summary.md @@ -0,0 +1,89 @@ +# Notification System Audit - Summary Report + +Date: 2026-02-17 + +## Scope + +Full audit of the Claudeman notification system covering: +- Backend event pipeline (server.ts, hooks-config.ts, team-watcher.ts, subagent-watcher.ts) +- Frontend notification manager (app.js NotificationManager class, 4-layer architecture) +- Settings UI and persistence (localStorage + server backup) +- Blinking/visual alerts (title flash, CSS tab animations, badge pulse) +- Desktop vs mobile behavior +- Team agent integration + +## Architecture + +4-layer notification system in `NotificationManager` class: +1. **In-app drawer** - Sliding panel with badge on bell icon, grouping within 5s windows +2. **Tab title flash** - `setInterval` at 1500ms, warning emoji + unread count when tab hidden +3. **Browser Notification API** - OS-level notifications, rate-limited 1 per 3s, auto-close at 8s +4. **Audio alerts** - Web Audio API 660Hz sine wave beep, 150ms duration + +Separate from NotificationManager: **CSS tab alert system** driven by `pendingHooks` state machine (red blink for action, yellow for idle). + +## Bugs Found & Fixed + +### CRITICAL + +| # | Bug | Fix | Files | +|---|-----|-----|-------| +| 1 | **Category/EventType key mismatch** - Per-event notification settings (On/Browser/Sound checkboxes) were completely non-functional. `notify()` used categories like `hook-permission` but `eventTypes` keys were `permission_prompt`. Lookup always failed, falling through to legacy urgency-based logic. | Added `categoryToEventType` mapping object in `notify()` method | `app.js:966-984` | +| 2 | **Cache invalidation bug** - `broadcast()` checked `event === 'respawn:'` (exact match) but all respawn events are `respawn:stateChanged` etc. Respawn state changes never invalidated cached state. | Changed to `event.startsWith('respawn:')` | `server.ts:4648` | + +### HIGH + +| # | Bug | Fix | Files | +|---|-----|-----|-------| +| 3 | **`session_error.browser` setting** used wrong checkbox - saved from `eventPermissionBrowser` instead of its own value | Changed to preserve current pref value with fallback | `app.js:9516` | +| 4 | **Dead hook events** - `hook:teammate_idle` and `hook:task_completed` broadcast by backend but no frontend handlers | Added SSE listeners with appropriate notifications | `app.js:2924-2950` | +| 5 | **`respawn:error` silently dropped** - No frontend handler for respawn errors | Added SSE listener with critical notification | `app.js:2630-2642` | +| 6 | **Subagent notifications decorative** - Settings had toggles but no code dispatched notifications | Wired `notify()` calls into subagent:discovered and subagent:completed handlers | `app.js:2989,3107` | + +### MEDIUM + +| # | Bug | Fix | Files | +|---|-----|-----|-------| +| 7 | **AudioContext autoplay policy** - No `resume()` call, first audio silently fails on browsers with autoplay restrictions | Added `audioCtx.state === 'suspended'` check with `resume()` | `app.js:1191` | + +## What Works Well (No Changes Needed) + +- **Title blinking**: Properly guarded against interval stacking, comprehensive cleanup (onTabVisible, handleInit, markAllRead, clearAll) +- **CSS tab alerts**: Pure CSS infinite animations, zero JS timer overhead, correctly wired to pendingHooks state machine +- **Notification grouping**: 5s sliding window dedup prevents spam, triple-layered stacking protection +- **Mobile/desktop separation**: Separate localStorage keys (`-mobile` suffix), separate defaults (mobile OFF by default) +- **Memory cleanup**: Thorough in handleInit (SSE reconnect), removeSession, all timer paths +- **Browser notification rate limiting**: Global 3s rate limit with auto-close at 8s +- **Visibility API usage**: Correct modern approach (visibilitychange + pageshow for iOS bfcache, no focus/blur) +- **Notification drawer UX**: Urgency-colored borders, relative timestamps, click-to-switch-session, slide-in animations + +## Detailed Reports + +| Report | File | +|--------|------| +| Backend analysis | `reports/notification-backend.md` | +| Frontend analysis | `reports/notification-frontend.md` | +| Settings flow | `reports/notification-settings.md` | +| Blinking/visual alerts | `reports/notification-blinking.md` | + +## Changes Summary + +### `src/web/public/app.js` +- Added `categoryToEventType` mapping in `notify()` (lines 966-984) +- Added `hook:teammate_idle` SSE handler (lines 2924-2936) +- Added `hook:task_completed` SSE handler (lines 2938-2950) +- Added `respawn:error` SSE handler (lines 2630-2642) +- Wired `subagent:discovered` → `notify()` call (line 2989) +- Wired `subagent:completed` → `notify()` call (line 3107) +- Fixed `session_error.browser` setting save (line 9516) +- Added `AudioContext.resume()` for autoplay policy (line 1191) + +### `src/web/server.ts` +- Fixed cache invalidation: `event === 'respawn:'` → `event.startsWith('respawn:')` (line 4648) + +## Known Limitations (Not Addressed) + +- **TeamWatcher is unintegrated** - The class exists in `team-watcher.ts` but is never imported in `server.ts`. Team events (member join/leave, task updates, inbox messages) are not broadcast via SSE. This is a larger feature gap, not a notification bug. +- **Browser notification rate limit is global** - A rapid succession of different event types only shows the first browser notification within 3s. This is by design for spam prevention. +- **No dynamic favicon** - Tab favicon is static; could be enhanced to show red/orange badge for unread notifications. +- **Per-session notification settings** - All notification prefs are global, no per-session customization. diff --git a/reports/notification-backend.md b/reports/notification-backend.md new file mode 100644 index 00000000..203c6f2a --- /dev/null +++ b/reports/notification-backend.md @@ -0,0 +1,489 @@ +# Claudeman Notification System - Backend Research Report + +Date: 2026-02-17 + +## Executive Summary + +The Claudeman notification system is a **multi-layer, event-driven pipeline** that flows from backend event emitters, through SSE broadcasts, to a frontend `NotificationManager` class. The backend itself has no concept of "notifications" -- it broadcasts structured SSE events, and the frontend decides which events warrant user notification (browser notifications, audio alerts, tab title flashing, in-app notification drawer, tab alert badges). + +The system handles ~25 distinct notification-triggering SSE events across 5 categories: hook events, session lifecycle, respawn state machine, Ralph Loop, and UI actions. + +--- + +## 1. Server-Side Notification Logic (`src/web/server.ts`) + +### 1.1 The `broadcast()` Method (Line 4646) + +All real-time client communication flows through a single private method: + +```typescript +private broadcast(event: string, data: unknown): void { + // Invalidate caches on state-changing broadcasts + if (event.startsWith('session:') || event === 'respawn:') { + this.cachedLightState = null; + this.cachedSessionsList = null; + } + let message: string; + try { + message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + } catch (err) { + console.error(`[Server] Failed to serialize SSE event "${event}":`, err); + return; + } + for (const client of this.sseClients) { + this.sendSSEPreformatted(client, message); + } +} +``` + +Key characteristics: +- Serializes JSON once, then writes to all connected SSE clients +- Has backpressure handling (`sendSSEPreformatted` tracks `backpressuredClients`) +- Silently drops events on serialization failure (circular refs) +- Cache invalidation is broad -- any `session:*` event clears caches + +### 1.2 SSE Client Management (Line 547-564) + +Clients connect at `GET /api/events`: +- Immediately sent `init` event with lightweight state (no terminal buffers) +- Tracked in `Set` (`this.sseClients`) +- Dead client cleanup runs every 30s (`SSE_HEALTH_CHECK_INTERVAL`) +- Max 100 SSE clients (`MAX_SSE_CLIENTS` from `map-limits.ts`) + +### 1.3 Complete Catalog of Notification-Relevant Broadcasts + +The server emits ~70 distinct SSE event types. Those that trigger frontend notifications are: + +| SSE Event | Server Location | Frontend Notification? | Category | +|-----------|----------------|----------------------|----------| +| `hook:idle_prompt` | Line 3431 | Yes - warning | Hook | +| `hook:permission_prompt` | Line 3431 | Yes - critical | Hook | +| `hook:elicitation_dialog` | Line 3431 | Yes - critical | Hook | +| `hook:stop` | Line 3431 | Yes - info | Hook | +| `hook:teammate_idle` | Line 3431 | **NO** (no frontend handler) | Hook | +| `hook:task_completed` | Line 3431 | **NO** (no frontend handler) | Hook | +| `session:error` | Line 3921 | Yes - critical | Session | +| `session:exit` | Line 3939 | Yes - critical (non-zero code) | Session | +| `session:idle` | Line 3976 | Yes - warning (after stuck threshold) | Session | +| `session:autoClear` | Line 4009 | Yes - info | Session | +| `session:ralphCompletionDetected` | Line 4044 | Yes - warning | Ralph | +| `session:circuitBreakerUpdate` | Line 4066 | Yes - critical (OPEN state) | Ralph | +| `session:exitGateMet` | Line 4080 | Yes - warning | Ralph | +| `respawn:blocked` | Line 4158 | Yes - critical | Respawn | +| `respawn:autoAcceptSent` | Line 4177 | Yes - info | Respawn | + +Events that update UI but do NOT trigger notifications: +- `session:working` (line 3966) -- clears stuck timer and tab alerts +- `session:completion` (line 3928) -- updates cost display +- `session:updated` (many locations) -- tab/panel state updates +- `respawn:stateChanged` (line 4143) -- banner update only +- `respawn:cycleStarted` (line 4150) -- cycle counter update +- `subagent:discovered` (line 458) -- auto-opens window, no notification +- `subagent:completed` (line 464) -- no notification +- `image:detected` (line 504) -- auto-opens popup, no notification +- `transcript:*` events (lines 3575-3591) -- no frontend handlers for notifications + +### 1.4 Terminal Data Batching + +Terminal and output data use separate batching pipelines that bypass `broadcast()`: +- `batchTerminalData()` (line 4668) -- adaptive 16-50ms batching for PTY output +- `batchOutputData()` (line 4741) -- 50ms batching for parsed text output +- Both flush through `broadcast('session:terminal', ...)` and `broadcast('session:output', ...)` + +--- + +## 2. Hook Events System (`src/hooks-config.ts`) + +### 2.1 Hook Configuration Generator (Lines 24-67) + +The `generateHooksConfig()` function creates `.claude/settings.local.json` entries that make Claude Code POST to Claudeman when hooks fire: + +```typescript +const curlCmd = (event: HookEventType) => + `HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` + + `curl -s -X POST "$CLAUDEMAN_API_URL/api/hook-event" ` + + `-H 'Content-Type: application/json' ` + + `-d "{\\"event\\":\\"${event}\\",\\"sessionId\\":\\"$CLAUDEMAN_SESSION_ID\\",\\"data\\":$HOOK_DATA}" ` + + `2>/dev/null || true`; +``` + +Six hook types are configured: + +| Hook Category | Matcher | Event Type | +|--------------|---------|------------| +| Notification | `idle_prompt` | `idle_prompt` | +| Notification | `permission_prompt` | `permission_prompt` | +| Notification | `elicitation_dialog` | `elicitation_dialog` | +| Stop | (all stops) | `stop` | +| TeammateIdle | (all) | `teammate_idle` | +| TaskCompleted | (all) | `task_completed` | + +### 2.2 Hook Event Flow + +``` +Claude Code Hook Fires + --> Shell command executes (curl) + --> POST /api/hook-event with {event, sessionId, data} + --> Zod validation (HookEventSchema, schemas.ts:79) + --> Session lookup (must exist) + --> Respawn controller signaling (elicitation/stop/idle_prompt only) + --> Transcript watcher setup (if data.transcript_path present) + --> Data sanitization (sanitizeHookData, server.ts:178) + --> SSE broadcast as `hook:{eventType}` + --> Run summary tracking (recordHookEvent) +``` + +### 2.3 Data Sanitization (Lines 178-211) + +The `sanitizeHookData()` function limits what gets broadcast: +- Allowed keys: `hook_event_name`, `tool_name`, `tool_input`, `session_id`, `cwd`, `permission_mode`, `stop_hook_active`, `transcript_path` +- `tool_input` objects are summarized (command truncated to 500 chars, only summary fields forwarded) +- Total data size capped at `MAX_HOOK_DATA_SIZE` (line 135) + +### 2.4 Environment Variables (Lines 70-101) + +Two env vars are set per case directory via `updateCaseEnvVars()`: +- `CLAUDEMAN_API_URL` -- server URL (e.g., `http://localhost:3000`) +- `CLAUDEMAN_SESSION_ID` -- session identifier + +These are resolved at runtime by the shell, so the hook config is static per case. + +### 2.5 Hook Config Writing (Lines 107-129) + +`writeHooksConfig()` merges hook config into existing `.claude/settings.local.json`, preserving other keys. Called during case creation (server.ts lines 2197, 2441). + +--- + +## 3. Hook-to-Respawn Controller Integration (`src/web/server.ts`, Lines 3406-3418) + +Three of the six hook types signal the respawn controller: + +| Hook Event | Controller Method | Effect | +|-----------|------------------|--------| +| `elicitation_dialog` | `signalElicitation()` | Blocks auto-accept (prevents Enter press on question prompts) | +| `stop` | `signalStopHook()` | Definitive idle signal; starts short confirmation timer, skips AI check | +| `idle_prompt` | `signalIdlePrompt()` | Definitive 60s+ idle signal; cancels all detection timers, directly confirms idle | + +**Not handled by respawn controller**: `teammate_idle`, `task_completed`, `permission_prompt`. The first two are team-related hooks that have no backend integration beyond being broadcast via SSE (and the frontend has no handlers either -- see Section 7). + +--- + +## 4. Respawn Controller Events (`src/respawn-controller.ts`) + +The `RespawnController` extends `EventEmitter` and emits many events that the server wires to SSE broadcasts (server.ts lines 4143-4271). + +### 4.1 Notification-Triggering Events + +| Controller Event | SSE Broadcast | Frontend Notification? | +|-----------------|---------------|----------------------| +| `respawnBlocked` | `respawn:blocked` | Yes - critical (reason: circuit_breaker, exit_signal, status_blocked, session_error, session_stopped, no_pty) | +| `autoAcceptSent` | `respawn:autoAcceptSent` | Yes - info ("Plan Accepted") | + +### 4.2 UI-Only Events (No Notification) + +| Controller Event | SSE Broadcast | Frontend Action | +|-----------------|---------------|----------------| +| `stateChanged` | `respawn:stateChanged` | Banner state label update | +| `respawnCycleStarted` | `respawn:cycleStarted` | Cycle counter update | +| `respawnCycleCompleted` | `respawn:cycleCompleted` | (no explicit handler) | +| `detectionUpdate` | `respawn:detectionUpdate` | Detection display update | +| `stepSent` | `respawn:stepSent` | (no-op handler) | +| `stepCompleted` | `respawn:stepCompleted` | (no handler) | +| `aiCheckStarted/Completed/Failed` | `respawn:aiCheck*` | (no-op handlers) | +| `aiCheckCooldown` | `respawn:aiCheckCooldown` | (no-op handler) | +| `planCheckStarted/Completed/Failed` | `respawn:planCheck*` | (no handlers) | +| `timerStarted/Cancelled/Completed` | `respawn:timer*` | Countdown timer UI | +| `actionLog` | `respawn:actionLog` | Action log display | +| `log` | `respawn:log` | Debug log | +| `error` | `respawn:error` | (no handler) | + +### 4.3 Respawn-to-Run-Summary Integration + +The server wires respawn state changes into the run summary tracker (server.ts line 4143): +```typescript +this.broadcast('respawn:stateChanged', { sessionId, state, prevState }); +// Also records in run summary: +const summaryTracker = this.runSummaryTrackers.get(sessionId); +if (summaryTracker) summaryTracker.recordStateChange(state); +``` + +--- + +## 5. Team / Agent Notification Flow + +### 5.1 SubagentWatcher (`src/subagent-watcher.ts`) + +The SubagentWatcher emits events that the server wires to SSE broadcasts (server.ts lines 458-477): + +```typescript +discovered: (info) => this.broadcast('subagent:discovered', info), +updated: (info) => this.broadcast('subagent:updated', info), +toolCall: (data) => this.broadcast('subagent:tool_call', data), +toolResult: (data) => this.broadcast('subagent:tool_result', data), +progress: (data) => this.broadcast('subagent:progress', data), +message: (data) => this.broadcast('subagent:message', data), +completed: (info) => this.broadcast('subagent:completed', info), +``` + +**None of these trigger frontend notifications.** The frontend auto-opens subagent windows on `subagent:discovered` (app.js line 2887), but the `NotificationManager` is not invoked. + +### 5.2 TeamWatcher (`src/team-watcher.ts`) + +**CRITICAL FINDING: TeamWatcher is completely unintegrated with the server.** + +- The class is defined in `src/team-watcher.ts` and extends `EventEmitter` +- It emits events: `teamCreated`, `teamUpdated`, `teamRemoved`, `taskUpdated`, `inboxMessage` +- It is **never imported** in `server.ts` or any other file +- The `hasActiveTeammates()` method (designed for idle detection) is never called +- There is no SSE broadcast for any team event +- The frontend has no SSE listeners for `team:*` events + +The frontend does have team-related UI code (teammate badges, team task panel, teammate terminal windows at app.js lines 13057-13501), but this appears to be driven by polling APIs or subagent watcher integration rather than dedicated SSE events. + +### 5.3 Team Hook Events (teammate_idle, task_completed) + +These are accepted by the API endpoint (validated by `HookEventSchema`) and broadcast as `hook:teammate_idle` and `hook:task_completed` SSE events, but: +- The **respawn controller ignores them** (only handles `elicitation_dialog`, `stop`, `idle_prompt`) +- The **frontend has no SSE listeners** for `hook:teammate_idle` or `hook:task_completed` +- They are recorded in the **run summary** via `recordHookEvent()` but otherwise silently dropped + +--- + +## 6. Run Summary (`src/run-summary.ts`) + +### 6.1 Overview + +The RunSummaryTracker records a timeline of events per session for "what happened while I was away" views. It is a **recording system**, not a notification system -- it does not trigger any notifications itself. + +### 6.2 Event Types Tracked + +From `types.ts` (lines 1359-1375): +```typescript +type RunSummaryEventType = + | 'session_started' | 'session_stopped' + | 'respawn_cycle_started' | 'respawn_cycle_completed' | 'respawn_state_change' + | 'error' | 'warning' + | 'token_milestone' | 'auto_compact' | 'auto_clear' + | 'idle_detected' | 'working_detected' + | 'ralph_completion' | 'ai_check_result' + | 'hook_event' | 'state_stuck'; +``` + +### 6.3 Integration Points with Notification System + +The run summary and notification system are **parallel but independent**: +- Both consume the same backend events (hooks, idle, working, errors) +- Run summary records for historical review; notifications alert in real-time +- There is no feedback loop between them (e.g., run summary does not trigger delayed notifications) + +### 6.4 State Stuck Detection (Lines 402-421) + +The RunSummaryTracker has its own state-stuck detection (10-minute threshold, checked every 60s) that records `state_stuck` events. This is separate from the frontend's idle-stuck notification (which uses `stuckThresholdMs`, default 10 minutes, triggered by `session:idle` events). + +**Potential overlap**: Both the run summary and the frontend independently detect "stuck" states. The run summary records it; the frontend notifies. They could diverge if their thresholds or detection logic differ. + +--- + +## 7. Types (`src/types.ts`) + +### 7.1 Hook Event Types (Line 749) + +```typescript +type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' + | 'stop' | 'teammate_idle' | 'task_completed'; +``` + +### 7.2 Hook Event Request (Lines 754-761) + +```typescript +interface HookEventRequest { + event: HookEventType; + sessionId: string; + data?: Record; +} +``` + +### 7.3 Run Summary Types (Lines 1355-1470) + +- `RunSummaryEventType` -- 16 event types +- `RunSummaryEventSeverity` -- `'info' | 'warning' | 'error' | 'success'` +- `RunSummaryEvent` -- `{id, timestamp, type, severity, title, details?, metadata?}` +- `RunSummaryStats` -- aggregated statistics (cycles, tokens, time active/idle, etc.) +- `RunSummary` -- complete summary `{sessionId, sessionName, startedAt, lastUpdatedAt, events, stats}` + +### 7.4 Missing Notification Types + +There is **no dedicated notification type** in the backend. The backend has no `Notification` interface or notification-specific data structures. All notification logic lives in the frontend `NotificationManager` class (`app.js` lines 859-1230). + +--- + +## 8. Bugs and Issues + +### 8.1 TeamWatcher Not Integrated (Critical Gap) + +**File**: `src/team-watcher.ts` (entire file) +**Issue**: TeamWatcher is defined but never instantiated or imported in the server. The `hasActiveTeammates()` method was designed for team-aware idle detection (preventing premature respawn when teammates are still working), but it is never called. + +**Impact**: +- The respawn controller has no awareness of active teammates +- Team events (member join/leave, task updates, inbox messages) are never broadcast to clients +- The frontend's team UI must rely on other mechanisms (likely API polling or subagent watcher) + +### 8.2 `hook:teammate_idle` and `hook:task_completed` Are Dead Events + +**File**: `src/web/server.ts` (line 3431), `src/web/public/app.js` +**Issue**: These hook events are accepted by the API, validated, and broadcast via SSE, but: +- The respawn controller does not handle them (line 3408-3418 -- only checks elicitation, stop, idle_prompt) +- The frontend has no `addListener('hook:teammate_idle', ...)` or `addListener('hook:task_completed', ...)` +- They are recorded in the run summary but otherwise have zero effect + +**Impact**: When Claude Code fires TeammateIdle or TaskCompleted hooks, the data is broadcast into the void. No notification, no UI update, no respawn logic. + +### 8.3 `respawn:error` Has No Frontend Handler + +**File**: `src/web/server.ts` (line 4236), `src/web/public/app.js` +**Issue**: The server broadcasts `respawn:error` events, but the frontend has no listener for this event. Respawn errors are silently ignored on the client side. + +**Impact**: If the respawn controller encounters an error (e.g., PTY write failure), the user gets no notification. + +### 8.4 `respawn:cycleCompleted` Has No Frontend Handler + +**File**: `src/web/server.ts` (line 4154) +**Issue**: `respawn:cycleCompleted` is broadcast but has no frontend listener. The cycle count is updated via `respawn:cycleStarted`, but completion is not acknowledged. + +### 8.5 `respawn:stepCompleted` Has No Frontend Handler + +**File**: `src/web/server.ts` (line 4169) +**Issue**: Broadcast but not listened to in the frontend. + +### 8.6 Image Detection Lacks Notification + +**File**: `src/web/public/app.js` (line 3039) +**Issue**: `image:detected` events auto-open a popup window but do not trigger the `NotificationManager`. If the user is on another tab, they get no notification that a screenshot or generated image was detected. + +### 8.7 Subagent Discovery/Completion Lacks Notification (By Design?) + +**File**: `src/web/public/app.js` (lines 2887, 2912+) +**Issue**: Subagent events auto-open windows but do not trigger notifications. The notification preferences have `subagent_spawn` and `subagent_complete` event types defined (app.js line 906-907) with defaults of `enabled: false`, but no code actually calls `notificationManager.notify()` for these events. + +**Impact**: The notification preferences UI shows toggle switches for subagent events, but they do nothing -- the notifications are never triggered regardless of the setting. + +### 8.8 `session:autoCompact` Has No Notification + +**File**: `src/web/public/app.js` +**Issue**: `session:autoClear` triggers a notification (app.js line 2623), but `session:autoCompact` does not. Both are significant session events (context reset vs. context compaction). The `autoCompact` SSE event is handled (line 2633 area) but only shows a toast if it's the active session, with no `NotificationManager.notify()` call. + +**Note**: After reviewing the code more carefully, `session:autoCompact` is not in the file at the lines I checked. It may be handled elsewhere or may genuinely be missing a notification. + +### 8.9 Cache Invalidation Pattern Is Overly Broad + +**File**: `src/web/server.ts` (line 4648) +**Issue**: `if (event.startsWith('session:') || event === 'respawn:')` -- the `respawn:` check uses exact equality, but all respawn events are formatted as `respawn:stateChanged`, `respawn:blocked`, etc. The check `event === 'respawn:'` will never match. This means respawn events do NOT invalidate the cached state. + +```typescript +if (event.startsWith('session:') || event === 'respawn:') { +``` + +Should likely be: +```typescript +if (event.startsWith('session:') || event.startsWith('respawn:')) { +``` + +**Impact**: After respawn state changes, the cached `getLightSessionsState()` may serve stale data until a `session:*` event triggers invalidation. Since respawn status is included in session state (via `getSessionStateWithRespawn()`), subsequent API calls to `GET /api/sessions` or SSE reconnects could show outdated respawn info. + +--- + +## 9. Missing Notification Paths + +### 9.1 Events That SHOULD Notify But Don't + +| Event | Current Behavior | Suggested Notification | +|-------|-----------------|----------------------| +| `hook:teammate_idle` | Broadcast, no handler | Warning: "Teammate idle, may need new task" | +| `hook:task_completed` | Broadcast, no handler | Info: "Team task completed" | +| `respawn:error` | Broadcast, no handler | Critical: "Respawn error: {message}" | +| `image:detected` | Auto-opens popup | Info (when tab unfocused): "Screenshot captured" | +| `subagent:discovered` | Auto-opens window | Info (if enabled): "New subagent spawned: {description}" | +| `subagent:completed` | Updates panel | Info (if enabled): "Subagent completed: {description}" | +| `respawn:cycleCompleted` | Broadcast, no handler | Info: "Respawn cycle #{n} completed" | + +### 9.2 Team Events That Need SSE Broadcasting + +Since TeamWatcher is not integrated, these events never reach clients: +- Team created/updated/removed +- Task status changes +- New inbox messages +- Teammate count changes + +--- + +## 10. Architecture Diagram + +``` + Claude Code Hooks + | + curl POST /api/hook-event + | + +-------------------+ + | server.ts | + | (Fastify) | + +-------------------+ + | | | + Respawn | Broadcast | RunSummary + Signal | via SSE | Record + | | | + +---------+ +---+---+ +--------+ + |RespawnCtrl| |SSE Bus| |Summary | + | emits | | | |Tracker | + | events | +---+---+ +--------+ + +---------+ | + | | + server.ts Connected + wires to Browsers + SSE via | + broadcast() | + +----+-----+ + | app.js | + | Frontend | + +----------+ + | + +----------+-----------+ + | | + NotificationManager Tab Alert System + (4 layers) (pendingHooks) + 1. In-app drawer - action (critical) + 2. Tab title flash - idle (warning) + 3. Browser Notification API + 4. Audio alerts +``` + +--- + +## 11. Summary of Key Files and Line References + +| File | Lines | Purpose | +|------|-------|---------| +| `src/web/server.ts` | 4646-4663 | `broadcast()` method | +| `src/web/server.ts` | 547-564 | SSE client setup | +| `src/web/server.ts` | 3396-3440 | Hook event endpoint | +| `src/web/server.ts` | 178-211 | `sanitizeHookData()` | +| `src/web/server.ts` | 3900-4103 | Session event wiring | +| `src/web/server.ts` | 4143-4271 | Respawn event wiring | +| `src/web/server.ts` | 458-477 | Subagent event wiring | +| `src/hooks-config.ts` | 24-67 | Hook config generator | +| `src/hooks-config.ts` | 107-129 | Config file writer | +| `src/types.ts` | 749 | `HookEventType` | +| `src/types.ts` | 754-761 | `HookEventRequest` | +| `src/types.ts` | 1359-1375 | `RunSummaryEventType` | +| `src/team-watcher.ts` | 27-338 | TeamWatcher (unintegrated) | +| `src/subagent-watcher.ts` | 217-1346 | SubagentWatcher events | +| `src/respawn-controller.ts` | 467-476 | Event documentation | +| `src/respawn-controller.ts` | 2469-2551 | Hook signal methods | +| `src/respawn-controller.ts` | 2750-2842 | Respawn blocking logic | +| `src/run-summary.ts` | 56-447 | RunSummaryTracker class | +| `src/web/schemas.ts` | 79-83 | HookEventSchema | +| `src/web/public/app.js` | 860-1038 | NotificationManager class | +| `src/web/public/app.js` | 1445-1477 | Pending hooks state machine | +| `src/web/public/app.js` | 2813-2883 | Hook event SSE handlers | +| `src/web/public/app.js` | 2443-2613 | Respawn event SSE handlers | +| `src/web/public/app.js` | 2335-2417 | Session lifecycle SSE handlers | diff --git a/reports/notification-blinking.md b/reports/notification-blinking.md new file mode 100644 index 00000000..4c9f5224 --- /dev/null +++ b/reports/notification-blinking.md @@ -0,0 +1,549 @@ +# Notification Blinking & Visual Alert System - Deep Dive + +## Overview + +Claudeman 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}) Claudeman` + : this.originalTitle; + }, TITLE_FLASH_INTERVAL_MS); + // Set immediately + document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`; + } + } +} +``` + +The title alternates between: +- Warning emoji + unread count: `"(3) Claudeman"` +- Original title: `"Claudeman"` + +### 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 + +``` + +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(`Claudeman: ${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 +Claudeman 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). diff --git a/reports/notification-frontend.md b/reports/notification-frontend.md new file mode 100644 index 00000000..469e5942 --- /dev/null +++ b/reports/notification-frontend.md @@ -0,0 +1,749 @@ +# Claudeman Frontend Notification System -- Detailed Report + +> Generated: 2026-02-17 +> Source files analyzed: +> - `/home/arkon/default/claudeman/src/web/public/app.js` (main frontend, ~15k lines) +> - `/home/arkon/default/claudeman/src/web/public/index.html` +> - `/home/arkon/default/claudeman/src/web/public/styles.css` +> - `/home/arkon/default/claudeman/src/web/public/mobile.css` + +--- + +## 1. Architecture Overview + +The notification system is a **4-layer** design, all implemented in the `NotificationManager` class (lines 860-1268 of `app.js`). The layers are: + +| Layer | Mechanism | When Active | +|-------|-----------|-------------| +| 1 | In-app notification drawer | Always (when enabled) | +| 2 | Tab title flashing | When tab is hidden (background) | +| 3 | Browser Notification API (OS-level) | When enabled + permission granted | +| 4 | Audio alert (Web Audio API) | When enabled for specific events | + +The `NotificationManager` is instantiated once at line 1404: +```js +this.notificationManager = new NotificationManager(this); +``` + +--- + +## 2. NotificationManager Class (lines 860-1268) + +### 2.1 Constructor (lines 861-893) + +State initialized: +- `this.notifications = []` -- in-memory log (max 100 items) +- `this.unreadCount = 0` -- badge counter +- `this.isTabVisible = !document.hidden` -- visibility tracking +- `this.isDrawerOpen = false` +- `this.originalTitle = document.title` -- saved for flash restore +- `this.titleFlashInterval = null` -- interval ID for title flashing +- `this.titleFlashState = false` -- toggle state for flash +- `this.lastBrowserNotifTime = 0` -- rate-limit timestamp +- `this.audioCtx = null` -- lazily created Web Audio context +- `this.groupingMap = new Map()` -- debounce grouping (5s window) + +Visibility listeners: +- `document.visibilitychange` -- updates `isTabVisible`, calls `onTabVisible()` when tab becomes visible +- `window.pageshow` (with `e.persisted` check) -- handles iOS Safari back-forward cache (bfcache) restore + +### 2.2 Preferences System (lines 896-960) + +#### Default Event-Type Preferences (lines 897-908) + +```js +const defaultEventTypes = { + permission_prompt: { enabled: true, browser: true, audio: true }, + elicitation_dialog: { enabled: true, browser: true, audio: true }, + idle_prompt: { enabled: true, browser: true, audio: false }, + stop: { enabled: true, browser: false, audio: false }, + session_error: { enabled: true, browser: true, audio: false }, + respawn_cycle: { enabled: true, browser: false, audio: false }, + token_milestone: { enabled: true, browser: false, audio: false }, + ralph_complete: { enabled: true, browser: true, audio: true }, + subagent_spawn: { enabled: false, browser: false, audio: false }, + subagent_complete: { enabled: false, browser: false, audio: false }, +}; +``` + +#### Device-Specific Defaults (lines 910-923) + +Mobile devices (`MobileDetection.getDeviceType() === 'mobile'`) get notifications **disabled by default**: +```js +const isMobile = MobileDetection.getDeviceType() === 'mobile'; +const defaults = { + enabled: !isMobile, // OFF on mobile + browserNotifications: !isMobile, // OFF on mobile + audioAlerts: false, // OFF everywhere + stuckThresholdMs: 600000, // 10 minutes + muteCritical: false, // Legacy urgency muting + muteWarning: false, + muteInfo: false, + eventTypes: defaultEventTypes, + _version: 3, +}; +``` + +#### Storage Keys (lines 952-956) + +Device-specific localStorage keys prevent mobile settings from overriding desktop settings: +- Desktop: `claudeman-notification-prefs` +- Mobile: `claudeman-notification-prefs-mobile` + +#### Version Migrations (lines 928-940) + +- v1 -> v2: `browserNotifications` default changed from `false` to `true` +- v2 -> v3: Added `eventTypes` object with per-event-type preferences + +#### Server Sync (lines 9470-9475, 9787-9828) + +Notification preferences are saved to the server alongside app settings via `PUT /api/settings`: +```js +body: JSON.stringify({ ...settings, notificationPreferences: notifPrefsToSave }) +``` + +On load, server prefs are applied **only if localStorage has none** (line 9815-9818): +```js +if (notificationPreferences && this.notificationManager) { + const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey()); + if (!localNotifPrefs) { + this.notificationManager.preferences = notificationPreferences; + this.notificationManager.savePreferences(); + } +} +``` + +This means localStorage always takes precedence over server-stored prefs. + +--- + +## 3. The `notify()` Flow (lines 962-1038) + +``` +notify() called + | + +-- preferences.enabled === false? --> RETURN (no-op) + | + +-- Check per-event-type preferences (eventTypes[category]) + | | + | +-- Found: eventPref.enabled === false? --> RETURN + | | shouldBrowserNotify = eventPref.browser && prefs.browserNotifications + | | shouldAudioAlert = eventPref.audio && prefs.audioAlerts + | | + | +-- Not found: fall back to legacy urgency-based muting + | if muteCritical/muteWarning/muteInfo matches --> RETURN + | shouldBrowserNotify = prefs.browserNotifications && (critical/warning/!tabVisible) + | shouldAudioAlert = critical && prefs.audioAlerts + | + +-- Grouping: same category+session within 5s? --> increment count, update message, RETURN + | + +-- Create notification object { id, urgency, category, sessionId, sessionName, title, message, timestamp, read, count } + | + +-- Add to this.notifications[] (max 100, FIFO eviction) + | + +-- Track in groupingMap (5s TTL) + | + +-- unreadCount++; updateBadge(); scheduleRender() + | + +-- Layer 2: if tab NOT visible --> updateTabTitle() (start title flashing) + | + +-- Layer 3: if shouldBrowserNotify --> sendBrowserNotif() + | + +-- Layer 4: if shouldAudioAlert --> playAudioAlert() +``` + +### 3.1 Notification Grouping (lines 986-997) + +Within a 5-second window, notifications with the same `category:sessionId` key are grouped: +- Count is incremented on existing notification +- Message is updated to latest +- Timestamp refreshed +- No new notification entry is created +- The grouping timeout is reset (sliding window) + +This prevents notification spam for rapid-fire events. + +--- + +## 4. Layer 1: In-App Notification Drawer + +### 4.1 HTML Structure (index.html lines 1394-1406) + +```html +
+
+ Notifications +
+ + + +
+
+
+
No notifications
+
+``` + +### 4.2 Bell Button (index.html lines 63-66) + +```html + +``` + +The bell button visibility is controlled by the `enabled` preference (line 9647-9652): +```js +const notifEnabled = this.notificationManager?.preferences?.enabled ?? true; +const notifBtn = document.querySelector('.btn-notifications'); +if (notifBtn) { + notifBtn.style.display = notifEnabled ? '' : 'none'; +} +``` + +### 4.3 Badge (lines 1230-1238) + +The red badge on the bell shows unread count. It pulses via CSS animation: +```css +.notification-badge { + position: absolute; top: 2px; right: 2px; + background: var(--red); color: #fff; + animation: notif-badge-pulse 2s ease-in-out infinite; +} +@keyframes notif-badge-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} +``` + +Display logic: `badge.style.display = unreadCount > 0 ? 'flex' : 'none'`. +Shows `99+` if count exceeds 99. + +### 4.4 Drawer Rendering (lines 1041-1079) + +Uses `requestAnimationFrame` for debounced rendering. Each notification item shows: +- Urgency color (left border: red/yellow/blue) +- Title with count multiplier (e.g., "Permission Required x3") +- Relative timestamp ("now", "5m ago", "2h ago") +- Message (truncated with ellipsis) +- Session chip (session name) +- Unread highlight (subtle blue background) +- Slide-in animation (`notif-slide-in`) + +Clicking a notification: marks as read, decrements unread count, switches to the notification's session. + +### 4.5 Drawer Toggle (lines 1183-1192) + +`toggleDrawer()` adds/removes the `open` class. The drawer slides in from the right via CSS transform: +```css +.notification-drawer { + transform: translateX(100%); + transition: transform 0.2s ease; +} +.notification-drawer.open { + transform: translateX(0); +} +``` + +### 4.6 CSS Styling (styles.css lines 3965-4151) + +Drawer is 340px wide, fixed position, full height below header, z-index 10001. +Items have color-coded left borders: red (critical), yellow (warning), blue (info). +Mobile override: full-width with safe area padding (mobile.css lines 1049-1058). + +--- + +## 5. Layer 2: Tab Title Flashing (lines 1082-1103) + +### Behavior + +When the tab is not visible and there are unread notifications: +1. `setInterval` at 1500ms toggles between: + - Warning emoji + unread count: `"(3) Claudeman"` + - Original title: `"Claudeman"` +2. Set immediately on first notification (no wait for first interval tick) + +### Stopping + +`onTabVisible()` (line 1241) calls `stopTitleFlash()`, which: +1. Clears the interval +2. Resets `titleFlashState = false` +3. Restores `document.title = this.originalTitle` + +If the drawer is open when tab becomes visible, all notifications are marked as read. + +### Memory Leak Prevention (lines 3294-3304) + +On SSE reconnect (`handleInit()`), the title flash interval is explicitly cleared: +```js +if (this.notificationManager?.titleFlashInterval) { + clearInterval(this.notificationManager.titleFlashInterval); + this.notificationManager.titleFlashInterval = null; +} +``` +Grouping timeouts are also cleared to prevent orphaned timers. + +### Potential Issue + +The title flash uses a Unicode warning emoji (`\u26A0\uFE0F`). This displays correctly on all modern browsers but may not render on very old terminals/browsers. + +--- + +## 6. Layer 3: Browser Notification API (lines 1106-1161) + +### Permission Flow (lines 1107-1120) + +``` +sendBrowserNotif() called + | + +-- prefs.browserNotifications === false? --> RETURN + +-- Notification API undefined? --> RETURN + +-- Notification.permission === 'default'? + | --> Auto-request permission + | --> If granted, re-call sendBrowserNotif() recursively + | --> RETURN (wait for permission dialog) + +-- Notification.permission !== 'granted'? --> RETURN + +-- Rate limit: < 3s since last? --> RETURN + +-- Create Notification +``` + +### Notification Object (lines 1127-1143) + +```js +new Notification(`Claudeman: ${title}`, { + body, + tag, // Groups same-tag notifications (replaces previous with same tag) + icon: '/favicon.ico', + silent: true, // We handle audio ourselves +}); +``` + +- **onclick**: focuses window, switches to session, closes notification +- **Auto-close**: 8 seconds via `setTimeout(() => notif.close(), 8000)` +- **Rate limit**: Max 1 browser notification per 3 seconds (`BROWSER_NOTIF_RATE_LIMIT_MS`) + +### Manual Permission Request (lines 1146-1161) + +The settings UI has an "Ask" button that calls `requestPermission()`: +- Shows toast on success/failure +- Updates permission status display (checkmark/X/?) +- Auto-enables `browserNotifications` preference on grant + +### Permission Status Display + +In settings (index.html line 993), a `?` shows: +- `granted` -> checkmark with green background +- `denied` -> X with red background +- `default` -> `?` + +### HTTPS Requirement + +The settings UI shows a hint (index.html line 996): +``` +For remote access, HTTPS is required. Start with: claudeman web --https +``` + +Browser Notification API requires a secure context (HTTPS or localhost). This hint warns users who access Claudeman remotely over HTTP. + +--- + +## 7. Layer 4: Audio Alerts (lines 1163-1181) + +### Implementation + +Uses Web Audio API to generate a short sine wave beep: +```js +playAudioAlert() { + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(660, ctx.currentTime); // 660 Hz (high E) + gain.gain.setValueAtTime(0.15, ctx.currentTime); // Low volume + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); // 150ms fade + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.15); // 150ms duration +} +``` + +The `AudioContext` is lazily created and reused across alerts. Errors are silently caught. + +### Potential Issues + +1. **Autoplay policy**: Modern browsers block `AudioContext` creation until user interaction. The first `playAudioAlert()` call may silently fail if the user hasn't clicked anything yet. The code handles this gracefully via try/catch, but the user gets no feedback that audio failed. + +2. **Mobile iOS restrictions**: iOS Safari requires `AudioContext.resume()` after user gesture. The current code does not call `resume()`, so audio alerts may never work on iOS unless the user has already interacted with an `AudioContext` (e.g., by clicking something that triggers audio). + +3. **No audio indicator**: There is no visual feedback that an audio alert played (or failed to play). + +--- + +## 8. SSE Event -> Notification Mapping + +The following table maps every SSE event that triggers a notification, with exact line numbers: + +| SSE Event | Category | Urgency | Line | Condition | +|-----------|----------|---------|------|-----------| +| `session:error` | `session-error` | critical | 2341 | Always | +| `session:exit` | `session-crash` | critical | 2360 | Non-zero exit code only | +| `session:idle` | `session-stuck` | warning | 2386 | After stuck threshold timeout (default 10min), only if respawn not enabled | +| `respawn:blocked` | `respawn-blocked` | critical | 2488 | Always (circuit breaker, exit signal, or status blocked) | +| `respawn:autoAcceptSent` | `auto-accept` | info | 2513 | Always | +| `session:autoClear` | `auto-clear` | info | 2623 | Always | +| `session:ralphCompletionDetected` | `ralph-complete` | warning | 2746 | Deduped by completion key (30s cooldown) | +| `session:circuitBreakerUpdate` | `circuit-breaker` | critical | 2772 | Only when state === 'OPEN' | +| `session:exitGateMet` | `exit-gate` | warning | 2787 | Always | +| `hook:idle_prompt` | `hook-idle` | warning | 2823 | Always | +| `hook:permission_prompt` | `hook-permission` | critical | 2841 | Always | +| `hook:elicitation_dialog` | `hook-elicitation` | critical | 2858 | Always | +| `hook:stop` | `hook-stop` | info | 2875 | Always | +| Circuit breaker reset | `circuit-breaker` | info | 10634 | On successful reset | +| Fix plan error | `fix-plan` | error | 10657 | On API error | +| Fix plan copied | `fix-plan` | info | 10719 | On clipboard copy | +| Fix plan written | `fix-plan` | info | 10738 | On successful write | +| Fix plan write error | `fix-plan` | error | 10746 | On write failure | +| Fix plan imported | `fix-plan` | info | 10768 | On successful import | +| Fix plan not found | `fix-plan` | warning | 10777 | When file not found | + +### Category-to-EventType Mapping Gap + +**Bug identified**: The notification categories used in `notify()` calls do NOT always match the event type keys in `preferences.eventTypes`. For example: + +- Category `session-error` is used (line 2343) but the eventType key is `session_error` (underscore, line 902) +- Category `session-crash` (line 2362) has no corresponding eventType entry +- Category `session-stuck` (line 2388) has no corresponding eventType entry +- Category `respawn-blocked` (line 2490) has no corresponding eventType entry +- Category `auto-accept` (line 2515) has no corresponding eventType entry +- Category `auto-clear` (line 2625) has no corresponding eventType entry +- Category `circuit-breaker` (lines 2774, 10636) has no corresponding eventType entry +- Category `exit-gate` (line 2789) has no corresponding eventType entry +- Category `hook-idle` (line 2825) -- should map to `idle_prompt`, but it does not match the key +- Category `hook-permission` (line 2843) -- should map to `permission_prompt`, but does not match +- Category `hook-elicitation` (line 2860) -- should map to `elicitation_dialog`, but does not match +- Category `hook-stop` (line 2877) -- should map to `stop`, but does not match +- Category `fix-plan` (lines 10659, 10721, etc.) has no corresponding eventType entry + +**Impact**: When `notify()` is called with a category that does not exist in `preferences.eventTypes`, the code falls through to the legacy urgency-based muting path (lines 976-983). This means per-event-type browser/audio toggles in the settings UI have **no effect** on the actual hook events, because the hook events use different category strings (`hook-permission`) than the eventType keys (`permission_prompt`). + +For example, unchecking "Browser" for "Permission prompts" in settings sets `eventTypes.permission_prompt.browser = false`. But the actual notification uses category `hook-permission`, which is not found in eventTypes, so it falls back to urgency-based logic where `critical` urgency always gets browser notifications when `browserNotifications` is enabled. + +**This is the most significant bug in the notification system.** + +--- + +## 9. Tab Alert System (Separate from NotificationManager) + +### How It Works + +Tab alerts are a separate visual indicator system that shows blinking session tabs: + +1. **State tracking** (lines 1349-1354): + - `tabAlerts: Map` -- current alert state per tab + - `pendingHooks: Map>` -- pending hook events + +2. **setPendingHook()** (lines 1445-1451): Adds hook type to session's pending set, calls `updateTabAlertFromHooks()`. + +3. **clearPendingHooks()** (lines 1453-1464): Removes specific hook type or all hooks, calls `updateTabAlertFromHooks()`. + +4. **updateTabAlertFromHooks()** (lines 1467-1477): + ``` + No hooks -> remove alert + Has permission_prompt OR elicitation_dialog -> 'action' alert (red blink) + Has idle_prompt -> 'idle' alert (yellow blink) + ``` + +5. **Visual rendering** (lines 3497-3504, 3581-3582): + Tab elements get CSS classes `tab-alert-action` or `tab-alert-idle`. + +### CSS Animations (styles.css lines 329-345) + +```css +.session-tab.tab-alert-action { + animation: tab-blink-red 2.5s ease-in-out infinite; +} +.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); } +} +``` + +### Alert Clearing + +- **`session:working` event** (line 2406-2408): Clears tab alert **only if no pending hooks** remain for that session. This correctly preserves alerts for permission prompts even when Claude starts working again. +- **`hook:stop` event** (line 2873): Clears ALL pending hooks for the session (response complete means all hooks resolved). +- **`selectSession()`** (line 3977): Clears `idle_prompt` hooks (viewing the session means you saw the idle state) but keeps `action` hooks. +- **`sendInput()`** (line 3148): Clears all pending hooks (user sent input, so hooks are resolved). +- **Session deletion** (line 4128-4129): Clears both pending hooks and tab alerts. +- **SSE reconnect** (lines 3287-3289): Clears all pending hooks and tab alerts. + +### Tab Glow Effect (lines 3983-3986) + +When switching sessions, the newly-active tab gets a brief green glow animation: +```css +@keyframes tab-glow { + 0% { box-shadow: none; } + 10% { box-shadow: 0 0 18px 6px rgba(34, 197, 94, 0.7); } + 100% { box-shadow: none; } +} +``` +This is purely cosmetic and not notification-related. + +--- + +## 10. Notification Settings UI (lines 9272-9461) + +### Settings Location + +Under App Settings modal -> "Notifications" tab (if using tabbed settings layout). + +### Controls + +**Global Controls** (index.html lines 981-1013): +| Setting | Element ID | Default | Purpose | +|---------|-----------|---------|---------| +| Enabled | `appSettingsNotifEnabled` | true (desktop), false (mobile) | Master switch | +| Browser | `appSettingsNotifBrowser` | true (desktop), false (mobile) | OS-level notifications | +| Audio Alerts | `appSettingsNotifAudio` | false | Beep sounds | +| Idle Threshold | `appSettingsNotifStuckMins` | 10 | Minutes before "stuck" warning | + +**Legacy Urgency Levels** (index.html lines 1019-1039): +| Setting | Element ID | Default | Purpose | +|---------|-----------|---------|---------| +| Critical | `appSettingsNotifCritical` | checked | Show critical urgency | +| Warning | `appSettingsNotifWarning` | checked | Show warning urgency | +| Info | `appSettingsNotifInfo` | checked | Show info urgency | + +These are stored inverted as `muteCritical`, `muteWarning`, `muteInfo`. + +**Per-Event-Type Grid** (index.html lines 1046-1086): +A 4-column grid (Event / On / Browser / Sound) for 7 event types: + +| Event | On Default | Browser Default | Sound Default | +|-------|-----------|-----------------|---------------| +| Permission prompts | on | on | on | +| Questions from Claude | on | on | on | +| Session idle | on | on | off | +| Response complete | on | off | off | +| Respawn cycles | on | off | off | +| Task complete | on | on | on | +| Subagent activity | off | off | off | + +### Save Flow (lines 9362-9488) + +1. Collect all UI values into a `notifPrefsToSave` object +2. Set `this.notificationManager.preferences = notifPrefsToSave` +3. Call `this.notificationManager.savePreferences()` (writes to localStorage) +4. Send to server via `PUT /api/settings` with `notificationPreferences` field +5. Call `applyHeaderVisibilitySettings()` which hides/shows the bell button + +### Session Error Event Type Bug (line 9425-9429) + +The `session_error` event type in the save flow reuses the `permission_prompt`'s browser checkbox: +```js +session_error: { + enabled: true, + browser: document.getElementById('eventPermissionBrowser').checked, // BUG: wrong checkbox + audio: false, +}, +``` +This means toggling "Permission prompts -> Browser" also affects `session_error` browser notifications, which is likely unintentional. + +--- + +## 11. Mobile-Specific Behavior + +### Device Detection (lines 119-198) + +`MobileDetection` object detects: +- Touch capability (via `ontouchstart`, `maxTouchPoints`, media query) +- iOS devices +- Safari browser +- Screen size categories: mobile (<430px), tablet (430-768px), desktop (768px+) + +Body classes set: `device-mobile`, `device-tablet`, `device-desktop`, `touch-device`, `ios-device`, `safari-browser`. + +### Mobile Notification Defaults (lines 910-914) + +On mobile devices: +- `enabled: false` -- notifications disabled by default +- `browserNotifications: false` -- browser notifications disabled +- `audioAlerts: false` -- audio disabled (same as desktop) + +### Mobile Storage Key (lines 952-956) + +Mobile uses a separate localStorage key (`claudeman-notification-prefs-mobile`) so that enabling notifications on desktop does not accidentally enable them on a mobile device viewing the same Claudeman instance. + +### Mobile Drawer Styling (mobile.css lines 1049-1058) + +```css +.notification-drawer { + width: 100%; + max-width: 100%; + right: 0; + border-radius: 0; + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + padding-bottom: var(--safe-area-bottom); +} +``` + +The drawer takes full width on mobile and respects iOS safe areas (notch, home indicator). + +### Mobile Tab Ordering (lines 3566-3568) + +On mobile, the active session tab is always rendered first: +```js +if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) { + tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)]; +} +``` + +This ensures the active tab's alert animation is always visible. + +--- + +## 12. Team/Agent Notifications + +### Subagent Event Handling + +Subagent events (`subagent:discovered`, `subagent:updated`, etc.) do NOT directly call `notificationManager.notify()`. There are no direct notification calls for subagent spawn/complete events in the SSE handlers. + +The `subagent_spawn` and `subagent_complete` event types exist in the preferences (lines 906-907) and settings UI, but no code currently dispatches notifications with these categories. + +### Teammate Badges (lines 13063-13095) + +Teammate badges are purely visual UI elements on subagent windows -- they are not part of the notification system. They show `@name` with team color (blue, green, yellow). + +### Missing Subagent Notifications + +Despite having settings for "Subagent activity" (on/browser/sound), the system **never dispatches** notifications with category `subagent_spawn` or `subagent_complete`. The UI settings exist but are non-functional for these event types. + +--- + +## 13. Visual Indicators Summary + +### Notification-Related + +| Indicator | Element | Behavior | +|-----------|---------|----------| +| Bell badge | `#notifBadge` | Red circle with count, pulsing animation | +| Tab blink (action) | `.tab-alert-action` | Red blink, 2.5s cycle | +| Tab blink (idle) | `.tab-alert-idle` | Yellow blink, 3.5s cycle | +| Title flash | `document.title` | Alternates with unread count, 1.5s interval | +| Drawer slide-in | `.notification-drawer.open` | Right-to-left slide, 0.2s | +| Item slide-in | `.notif-item` | Right-to-left slide, 0.2s | +| Item urgency border | `.notif-item-critical/warning/info` | Red/yellow/blue left border | +| Unread highlight | `.notif-item.unread` | Subtle blue background | + +### Non-Notification Visual Indicators + +| Indicator | Element | Purpose | +|-----------|---------|---------| +| Tab status dot | `.tab-status` | Green (idle), pulsing green (busy), red (error) | +| Tab glow | `.tab-glow` | Brief green glow on tab switch | +| Connection indicator | `#connectionIndicator` | Shows offline/reconnecting/draining state | +| Ralph status badge | `#ralphStatusBadge` | Active/completed/tracking state | +| Subagent count badge | `#subagentCountBadge` | Active agent count | +| Task badge | `.tab-badge` | Running task count on session tab | + +--- + +## 14. Bugs and Issues + +### 14.1 Category/EventType Mismatch (CRITICAL) + +**Location**: Lines 962-984 (notify flow) vs lines 897-908 (eventTypes definition) + +The categories used in `notify()` calls (`hook-permission`, `hook-idle`, `session-error`, etc.) do not match the eventType keys in preferences (`permission_prompt`, `idle_prompt`, `session_error`, etc.). This means the per-event-type checkboxes in settings have no effect on most notifications. + +**Impact**: Users who disable "Permission prompts -> Browser" in settings still get browser notifications for permission prompts, because the notification uses category `hook-permission` which falls through to urgency-based logic. + +**Fix**: Either change the categories in `notify()` calls to match the eventType keys, or add a mapping layer in `notify()`. + +### 14.2 Session Error Browser Setting Reuse (MINOR) + +**Location**: Line 9427 + +`session_error.browser` reuses `eventPermissionBrowser` checkbox instead of having its own control. + +### 14.3 No Subagent Notifications Dispatched (MINOR) + +**Location**: Event types `subagent_spawn` and `subagent_complete` exist in defaults (lines 906-907) and UI (lines 1082-1085), but no code ever calls `notify()` with these categories. + +### 14.4 AudioContext Autoplay Policy (MINOR) + +**Location**: Line 1167 + +`AudioContext` creation may be blocked by browser autoplay policy. No `resume()` call is made. First audio alert after page load may silently fail. + +### 14.5 Rate Limit Applies Across All Events (MINOR) + +**Location**: Line 1124 + +The 3-second rate limit for browser notifications is global -- a rapid succession of different event types (e.g., permission prompt + session error) will only show the first browser notification. + +### 14.6 Title Flash Shows Emoji That May Not Gate Properly + +**Location**: Line 1088 + +Title flash always shows when tab is hidden and there are unread notifications, regardless of which event types are enabled/disabled. If a user disables all event types but one, the title flash still fires for all unread items. + +This is correct behavior (the flash indicates unread items in the drawer), but it could be confusing if a user thinks disabling an event type should prevent all visual indicators. + +### 14.7 onTabVisible Marks All Read If Drawer Open + +**Location**: Lines 1243-1246 + +When the tab becomes visible and the drawer is open, ALL notifications are marked as read. This could be surprising if the user quickly switches tabs and back -- they lose their unread state. + +--- + +## 15. Cleanup and Memory Safety + +### SSE Reconnect Cleanup (lines 3280-3305) + +On `handleInit()` (SSE reconnect), the following notification state is cleaned up: +- `pendingHooks.clear()` -- prevents stale hook alerts +- `tabAlerts.clear()` -- prevents stale tab blinking +- `_shownCompletions.clear()` -- allows re-notification +- `titleFlashInterval` cleared -- prevents orphaned intervals +- `groupingMap` timeouts cleared -- prevents orphaned timeouts + +### Session Deletion Cleanup (lines 4128-4129) + +When a session is deleted: +- `pendingHooks.delete(sessionId)` +- `tabAlerts.delete(sessionId)` + +### Idle Timer Cleanup (lines 2412-2417) + +When session starts working, its stuck detection timer is cleared: +```js +const timer = this.idleTimers.get(data.id); +if (timer) { + clearTimeout(timer); + this.idleTimers.delete(data.id); +} +``` + +--- + +## 16. Constants Reference + +| Constant | Value | Purpose | +|----------|-------|---------| +| `GROUPING_TIMEOUT_MS` | 5000 | Notification grouping window | +| `NOTIFICATION_LIST_CAP` | 100 | Max notifications in drawer | +| `TITLE_FLASH_INTERVAL_MS` | 1500 | Title blink rate | +| `BROWSER_NOTIF_RATE_LIMIT_MS` | 3000 | Min time between browser notifications | +| `AUTO_CLOSE_NOTIFICATION_MS` | 8000 | Browser notification auto-dismiss | +| `STUCK_THRESHOLD_DEFAULT_MS` | 600000 | Default idle-stuck detection (10 min) | +| `THROTTLE_DELAY_MS` | 100 | General UI throttle | diff --git a/reports/notification-settings.md b/reports/notification-settings.md new file mode 100644 index 00000000..724dfe98 --- /dev/null +++ b/reports/notification-settings.md @@ -0,0 +1,459 @@ +# Notification Settings & Configuration System - Deep Dive + +## 1. Settings UI + +The notification settings live in the **App Settings modal** under the "Notifications" tab. The modal is opened via `openAppSettings()` at line 9234 of `src/web/public/app.js`, and the HTML structure is in `src/web/public/index.html` starting at line 974. + +### Settings Tab Layout (5 tabs total) + +The App Settings modal has tabs: Display, Claude CLI, Models, Paths, **Notifications**. The Notifications tab contains: + +#### Master Control Section +| Setting | Element ID | Type | Default (Desktop) | Default (Mobile) | +|---------|-----------|------|-------------------|-------------------| +| Enable Notifications | `appSettingsNotifEnabled` | checkbox | `true` | `false` | +| Browser Notifications | `appSettingsNotifBrowser` | checkbox | `true` | `false` | +| Browser Permission | `notifPermissionStatus` | status badge | shows checkmark/X/? | same | + +The Browser row includes an "Ask" button that calls `requestPermission()` and a status badge showing the current `Notification.permission` state. + +There is also a hint: _"For remote access, HTTPS is required. Start with: `claudeman web --https`"_ + +#### Alerts Section +| Setting | Element ID | Type | Default | +|---------|-----------|------|---------| +| Audio Alerts | `appSettingsNotifAudio` | checkbox | `false` | +| Idle Threshold | `appSettingsNotifStuckMins` | number input (1-120) | `10` minutes | + +#### Notification Levels Section (3-column grid) +| Level | Element ID | Default | +|-------|-----------|---------| +| Critical | `appSettingsNotifCritical` | checked | +| Warning | `appSettingsNotifWarning` | checked | +| Info | `appSettingsNotifInfo` | checked | + +These map to legacy `muteCritical`/`muteWarning`/`muteInfo` boolean fields (inverted: checked = not muted). + +#### Per-Event Settings (4-column grid: Event / On / Browser / Sound) +| Event | Enabled | Browser | Audio | +|-------|---------|---------|-------| +| Permission prompts | `eventPermissionEnabled` (default: on) | `eventPermissionBrowser` (on) | `eventPermissionAudio` (on) | +| Questions from Claude | `eventQuestionEnabled` (on) | `eventQuestionBrowser` (on) | `eventQuestionAudio` (on) | +| Session idle | `eventIdleEnabled` (on) | `eventIdleBrowser` (on) | `eventIdleAudio` (off) | +| Response complete | `eventStopEnabled` (on) | `eventStopBrowser` (off) | `eventStopAudio` (off) | +| Respawn cycles | `eventRespawnEnabled` (on) | `eventRespawnBrowser` (off) | `eventRespawnAudio` (off) | +| Task complete | `eventRalphEnabled` (on) | `eventRalphBrowser` (on) | `eventRalphAudio` (on) | +| Subagent activity | `eventSubagentEnabled` (off) | `eventSubagentBrowser` (off) | `eventSubagentAudio` (off) | + + +## 2. Settings Persistence + +### Dual-layer persistence: localStorage + Server + +Notification preferences are stored in **two places simultaneously**: + +#### Layer 1: localStorage (primary, device-specific) + +- **Storage key**: `claudeman-notification-prefs` (desktop) or `claudeman-notification-prefs-mobile` (mobile) +- Determined by `NotificationManager.getStorageKey()` at line 953, which calls `MobileDetection.getDeviceType()` +- Device type is based on `window.innerWidth`: `<430` = mobile, `430-768` = tablet, `>=768` = desktop +- Read in `loadPreferences()` (line 896), written in `savePreferences()` (line 958) + +#### Layer 2: Server-side (`~/.claudeman/settings.json`) + +- On save, notification prefs are bundled with app settings: `{ ...settings, notificationPreferences: notifPrefsToSave }` (line 9475) +- Sent via `PUT /api/settings` to the Fastify server +- Server does a shallow merge: `const merged = { ...existing, ...settings }` then writes to `~/.claudeman/settings.json` (line 3098 of server.ts) +- The `notificationPreferences` key sits at the top level of the settings JSON alongside app settings + +#### Load priority + +On startup, `loadAppSettingsFromServer()` (line 9787) fetches from server and: +1. Extracts `notificationPreferences` from the response (line 9793) +2. Only applies server notification prefs **if localStorage has none** (line 9816): `if (!localNotifPrefs)` +3. This means **localStorage always wins** over server for notification prefs, making the server copy essentially a backup for new devices + +### Preferences schema (version 3) + +```javascript +{ + enabled: true, // Master toggle + browserNotifications: true, // Browser Notification API toggle + audioAlerts: false, // Web Audio API toggle + stuckThresholdMs: 600000, // 10 minutes default + muteCritical: false, // Legacy urgency muting + muteWarning: false, + muteInfo: false, + eventTypes: { // Per-event-type prefs (added in v3) + permission_prompt: { enabled: true, browser: true, audio: true }, + elicitation_dialog: { enabled: true, browser: true, audio: true }, + idle_prompt: { enabled: true, browser: true, audio: false }, + stop: { enabled: true, browser: false, audio: false }, + session_error: { enabled: true, browser: true, audio: false }, + respawn_cycle: { enabled: true, browser: false, audio: false }, + token_milestone: { enabled: true, browser: false, audio: false }, + ralph_complete: { enabled: true, browser: true, audio: true }, + subagent_spawn: { enabled: false, browser: false, audio: false }, + subagent_complete: { enabled: false, browser: false, audio: false }, + }, + _version: 3, +} +``` + +### Migration path + +- **v1 -> v2**: `browserNotifications` was changed from defaulting `false` to `true` (line 932) +- **v2 -> v3**: Added `eventTypes` object (line 937) +- Migration happens on load and writes back to localStorage immediately + +### App settings (separate from notification prefs) + +App settings use a different device-specific localStorage key: +- Desktop: `claudeman-app-settings` +- Mobile: `claudeman-app-settings-mobile` +- Determined by `getSettingsStorageKey()` at line 9562 + + +## 3. Settings Application - How Toggles Take Effect + +### The `notify()` method decision tree (line 962) + +When `notify()` is called: + +1. **Master check**: If `!preferences.enabled`, return immediately (no notification at all) +2. **Event type lookup**: Look up `preferences.eventTypes[category]` +3. **If event type found**: + - If `!eventPref.enabled`, return (event type disabled) + - `shouldBrowserNotify = eventPref.browser && preferences.browserNotifications` + - `shouldAudioAlert = eventPref.audio && preferences.audioAlerts` +4. **If event type NOT found** (fallback for unknown categories): + - Check legacy `muteCritical`/`muteWarning`/`muteInfo` based on urgency + - `shouldBrowserNotify` = global browser toggle AND (critical/warning OR tab hidden) + - `shouldAudioAlert` = critical urgency AND global audio toggle + +### CRITICAL BUG: Category Key Mismatch + +The `eventTypes` keys in the preferences schema do NOT match the `category` values used in actual `notify()` calls. This means **per-event-type settings have no effect for most notification categories**: + +| eventTypes Key | Actual category Used in notify() | Match? | +|---------------|----------------------------------|--------| +| `permission_prompt` | `hook-permission` | NO | +| `elicitation_dialog` | `hook-elicitation` | NO | +| `idle_prompt` | `hook-idle` | NO | +| `stop` | `hook-stop` | NO | +| `session_error` | `session-error` | NO | +| `respawn_cycle` | `respawn-blocked` | NO | +| `token_milestone` | (not used anywhere) | N/A | +| `ralph_complete` | `ralph-complete` | NO | +| `subagent_spawn` | (used in subagent code) | Needs verification | +| `subagent_complete` | (used in subagent code) | Needs verification | + +**Impact**: When `notify()` receives `category: 'hook-permission'`, it looks up `eventTypes['hook-permission']`, finds nothing, and falls through to the legacy urgency-based logic. The per-event toggles in the settings UI are effectively non-functional for all hook-based and most other notifications. + +The only categories that have a chance of matching are those used in subagent notification code, which would need separate verification. + +Additional uncategorized notifications that always fall through to urgency-based logic: +- `session-crash` +- `session-stuck` +- `auto-accept` +- `auto-clear` +- `circuit-breaker` +- `exit-gate` +- `fix-plan` + +### Settings application timing + +Settings changes take effect immediately because: +1. `saveAppSettings()` sets `this.notificationManager.preferences = notifPrefsToSave` directly (line 9459) +2. Calls `savePreferences()` to persist to localStorage (line 9460) +3. Calls `applyHeaderVisibilitySettings()` which hides/shows the notification bell icon (line 9647-9657) + +### Bell icon visibility + +The notification bell icon in the header (`btn-notifications`) is hidden when `preferences.enabled` is `false` (line 9649-9651). If notifications are disabled while the drawer is open, the drawer is force-closed (line 9654-9657). + + +## 4. Default Values + +### Desktop defaults +| Setting | Default | Source | +|---------|---------|--------| +| enabled | `true` | `loadPreferences()` line 913 | +| browserNotifications | `true` | line 914, negated `isMobile` | +| audioAlerts | `false` | line 915 | +| stuckThresholdMs | `600000` (10 min) | `STUCK_THRESHOLD_DEFAULT_MS` constant, line 11 | +| muteCritical/Warning/Info | `false` (not muted) | lines 918-920 | + +### Mobile defaults +| Setting | Default | Source | +|---------|---------|--------| +| enabled | `false` | line 913, negated `!isMobile` | +| browserNotifications | `false` | line 914 | +| audioAlerts | `false` | line 915 | + +### CLAUDE.md documentation + +CLAUDE.md states: _"Key defaults: Most panels hidden (monitor, subagents shown), notifications enabled (audio disabled), subagent tracking on, Ralph tracking off."_ + +This is accurate for desktop but does not mention the mobile-specific defaults where notifications are entirely disabled. + +### Constants (line 11-16 of app.js) +```javascript +const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes +const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window +const NOTIFICATION_LIST_CAP = 100; // Max notifications in list +const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate +const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications +const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications +``` + + +## 5. Desktop vs Mobile Settings + +### Separate storage keys - YES + +Desktop and mobile use completely separate localStorage keys: +- **Notification prefs**: `claudeman-notification-prefs` vs `claudeman-notification-prefs-mobile` +- **App settings**: `claudeman-app-settings` vs `claudeman-app-settings-mobile` + +### Different defaults - YES + +Mobile defaults disable everything: +- `enabled: false` (master toggle off) +- `browserNotifications: false` +- All tracking features disabled +- All panels hidden + +Desktop defaults enable notifications but keep audio off. + +### Server-side sync behavior + +When loading from server, display settings (which include panel visibility, tracking toggles, etc.) are filtered out to avoid overwriting mobile-specific defaults (lines 9796-9806). Notification prefs from the server only apply if the device has no local prefs yet (line 9816). + +### Mobile CSS adjustments + +`mobile.css` line 1050 makes the notification drawer full-width on mobile: +```css +.notification-drawer { + width: 100%; + max-width: 100%; + right: 0; + border-radius: 0; + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); + padding-bottom: var(--safe-area-bottom); +} +``` + +### Device type detection + +`MobileDetection.getDeviceType()` (line 153) uses a simple width check: +- `< 430px` = mobile +- `430-768px` = tablet (treated as desktop for settings keys) +- `>= 768px` = desktop + +Note: Only `mobile` vs non-mobile matters for settings keys. Tablet uses the desktop key. + + +## 6. Notification Permission Flow + +### Auto-request on first notification + +When `sendBrowserNotif()` is called and `Notification.permission === 'default'` (never asked), the app **auto-requests permission** (line 1110-1118): +```javascript +if (Notification.permission === 'default') { + Notification.requestPermission().then(result => { + if (result === 'granted') { + this.sendBrowserNotif(title, body, tag, sessionId); // Re-send + } + }); + return; +} +``` + +The first notification that would trigger a browser notification causes the permission prompt. If granted, the notification is re-sent. + +### Manual request via settings + +The "Ask" button in the Notifications settings tab calls `requestPermission()` (line 1146): +```javascript +async requestPermission() { + if (typeof Notification === 'undefined') { + this.app.showToast('Browser notifications not supported', 'warning'); + return; + } + const result = await Notification.requestPermission(); + // Update status badge + if (result === 'granted') { + this.preferences.browserNotifications = true; + this.savePreferences(); + this.app.showToast('Notifications enabled', 'success'); + } +} +``` + +**Side effect**: Granting permission also auto-enables `browserNotifications` toggle (line 1155). + +### Permission status display + +The settings UI shows the current permission state via a status badge: +- Checkmark (granted) - green background +- X (denied) - red background +- ? (default/not asked) - neutral + +### HTTPS requirement + +Browser notifications require HTTPS for remote access. The settings UI includes a hint: _"For remote access, HTTPS is required. Start with: `claudeman web --https`"_. On localhost, HTTP works fine. + + +## 7. Audio Setting + +### Toggle: `audioAlerts` + +The global `audioAlerts` toggle (default: `false`) controls whether audio can play at all. Per-event `audio` toggles further refine which events produce sound. + +### Audio generation + +Audio is generated via the Web Audio API (line 1163-1181), NOT via audio file playback: +```javascript +playAudioAlert() { + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(660, ctx.currentTime); // 660 Hz (E5) + gain.gain.setValueAtTime(0.15, ctx.currentTime); // Low volume + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); // 150ms fade + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + 0.15); +} +``` + +This produces a short 150ms sine wave beep at 660 Hz with a quick exponential fade. + +### Audio decision logic + +For audio to play, ALL of these must be true: +1. `preferences.enabled` = true (master toggle) +2. `preferences.audioAlerts` = true (global audio toggle) +3. For known event types: `eventTypes[category].audio` = true +4. For unknown categories (fallback): urgency must be `'critical'` + +### AudioContext lazy initialization + +The `AudioContext` is created lazily on first use (line 1166-1168). This is important because browsers require a user gesture before creating an AudioContext. The first audio alert may silently fail if no user interaction has occurred. + +### Does it actually work? + +Yes, given the prerequisites above are met. However, due to the category key mismatch (Section 3), per-event audio settings are mostly non-functional. The fallback logic means audio only plays for `critical` urgency notifications when the category is unrecognized (which is most of them). + + +## 8. Per-Session vs Global Settings + +### Global only + +Notification preferences are **strictly global**. There is no per-session notification configuration. + +- The `NotificationManager` is a singleton on the `ClaudemanApp` instance (line 1404) +- Preferences are loaded once from localStorage (line 878) +- All sessions share the same notification rules + +### Per-session data in notifications + +While settings are global, each notification carries `sessionId` and `sessionName` for: +- Displaying which session triggered the notification (session chip in drawer items) +- Click-to-switch: clicking a notification selects that session tab (line 1206-1208) +- Browser notification onclick: focuses the window and selects the session (line 1134-1139) + +### Stuck detection is per-session + +The idle/stuck detection timer is per-session (using `this.idleTimers` Map at line 2383), but the threshold comes from the global `stuckThresholdMs` setting. Respawn-enabled sessions are excluded from stuck detection (line 2381). + +### Case settings (separate system) + +There is a separate "case settings" system (`caseSettings_` in localStorage, lines 14513-14520) but it does not include notification preferences. + + +## 9. Notification Layers (4-layer system) + +The notification system operates in 4 independent layers: + +| Layer | Description | Always On? | Controlled By | +|-------|-------------|-----------|--------------| +| 1. Drawer | In-app notification list (slide-out panel) | Yes (if enabled) | `preferences.enabled` | +| 2. Tab Title | Flashing title with unread count when tab unfocused | Yes (if enabled) | `preferences.enabled` + tab visibility | +| 3. Browser | OS-level Web Notifications | Conditional | `preferences.browserNotifications` + per-event `browser` + `Notification.permission` | +| 4. Audio | Web Audio API beep | Conditional | `preferences.audioAlerts` + per-event `audio` | + +### Rate limiting + +Browser notifications are rate-limited to 1 per 3 seconds (line 1124, `BROWSER_NOTIF_RATE_LIMIT_MS`). + +### Notification grouping + +Same-category notifications for the same session within 5 seconds are grouped (count incremented) instead of creating new entries (line 986-996, `GROUPING_TIMEOUT_MS`). + +### Auto-close + +Browser notifications auto-close after 8 seconds (line 1143, `AUTO_CLOSE_NOTIFICATION_MS`). + +### List cap + +The in-app notification list is capped at 100 entries (line 1014, FIFO eviction). + + +## 10. Key Issues and Recommendations + +### Issue 1: Category Key Mismatch (HIGH PRIORITY) + +The per-event settings in the UI are effectively non-functional because the category strings used in `notify()` calls (`hook-permission`, `hook-idle`, `session-error`, etc.) do not match the `eventTypes` keys in preferences (`permission_prompt`, `idle_prompt`, `session_error`, etc.). + +**Fix options**: +- A) Change all `notify()` category values to match the `eventTypes` keys +- B) Change the `eventTypes` keys to match the categories used in `notify()` calls +- C) Add a mapping function in `notify()` that normalizes categories to eventType keys + +Option A is the cleanest since the eventTypes keys match the hook event names from Claude Code. + +### Issue 2: `session_error` hardcoded in save + +In `saveAppSettings()` at line 9425-9429, `session_error` has its browser setting hardcoded to mirror `permission_prompt`'s browser toggle and its audio is always `false`. There is no dedicated UI row for session errors. Similarly, `token_milestone` is hardcoded to `enabled: true, browser: false, audio: false` with no UI controls (lines 9435-9439). + +### Issue 3: Subagent spawn/complete share a single UI row + +Both `subagent_spawn` and `subagent_complete` are controlled by a single "Subagent activity" row in the UI (lines 9445-9454). This is intentional but worth noting. + +### Issue 4: Mobile default discoverability + +Mobile users have notifications disabled by default. There is no onboarding prompt or toast suggesting they enable notifications. A user on mobile would need to find Settings > Notifications and enable the master toggle. + +### Issue 5: Server-side prefs are write-only in practice + +Because localStorage always wins over server prefs (unless localStorage is empty), the server copy of notification preferences is effectively a one-time bootstrap for new devices. Changes made on one device do not propagate to another device that already has local prefs. + + +## 11. File Reference + +| File | Lines | What | +|------|-------|------| +| `src/web/public/app.js` | 11-16 | Constants (thresholds, caps, intervals) | +| `src/web/public/app.js` | 120-180 | `MobileDetection` utility | +| `src/web/public/app.js` | 859-1253 | `NotificationManager` class | +| `src/web/public/app.js` | 896-950 | `loadPreferences()` with migration | +| `src/web/public/app.js` | 952-960 | `getStorageKey()` and `savePreferences()` | +| `src/web/public/app.js` | 962-1039 | `notify()` decision logic | +| `src/web/public/app.js` | 1106-1161 | Browser notification + permission request | +| `src/web/public/app.js` | 1163-1181 | Audio alert via Web Audio API | +| `src/web/public/app.js` | 9234-9338 | `openAppSettings()` - populates notification UI | +| `src/web/public/app.js` | 9362-9487 | `saveAppSettings()` - saves all prefs | +| `src/web/public/app.js` | 9562-9624 | Device-aware settings storage keys | +| `src/web/public/app.js` | 9626-9657 | `applyHeaderVisibilitySettings()` - bell icon visibility | +| `src/web/public/app.js` | 9787-9828 | `loadAppSettingsFromServer()` - server sync | +| `src/web/public/app.js` | 2335-2883 | SSE event handlers that call `notify()` | +| `src/web/public/index.html` | 63-66 | Notification bell button + badge | +| `src/web/public/index.html` | 974-1092 | Notifications settings tab HTML | +| `src/web/public/index.html` | 1395-1406 | Notification drawer HTML | +| `src/web/public/styles.css` | 2586-2748 | Settings grid + event type grid CSS | +| `src/web/public/styles.css` | 3978-4151 | Notification badge, drawer, items CSS | +| `src/web/public/mobile.css` | 1050-1058 | Mobile notification drawer override | +| `src/web/server.ts` | 3073-3129 | `GET/PUT /api/settings` endpoints | diff --git a/src/web/public/app.js b/src/web/public/app.js index 58c2d12b..bd3a3e1f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -962,8 +962,30 @@ class NotificationManager { 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', + 'hook-teammate-idle': 'idle_prompt', + 'hook-task-completed': 'stop', + }; + const eventTypeKey = categoryToEventType[category] || category; + // Check per-event-type preferences first - const eventPref = this.preferences.eventTypes?.[category]; + const eventPref = this.preferences.eventTypes?.[eventTypeKey]; let shouldBrowserNotify = false; let shouldAudioAlert = false; @@ -1166,6 +1188,9 @@ class NotificationManager { 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(); @@ -2602,6 +2627,19 @@ class ClaudemanApp { } }); + addListener('respawn:error', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'critical', + category: 'session-error', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Respawn Error', + message: data.error || data.message || 'Respawn encountered an error', + }); + }); + addListener('respawn:actionLog', (e) => { const data = JSON.parse(e.data); const { sessionId, action } = data; @@ -2882,6 +2920,32 @@ class ClaudemanApp { }); }); + addListener('hook:teammate_idle', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'warning', + category: 'hook-teammate-idle', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Teammate Idle', + message: `A teammate is idle in ${session?.name || data.sessionId}`, + }); + }); + + addListener('hook:task_completed', (e) => { + const data = JSON.parse(e.data); + const session = this.sessions.get(data.sessionId); + this.notificationManager?.notify({ + urgency: 'info', + category: 'hook-task-completed', + sessionId: data.sessionId, + sessionName: session?.name || data.sessionId, + title: 'Task Completed', + message: `A team task completed in ${session?.name || data.sessionId}`, + }); + }); + // ========== Subagent Events (Claude Code Background Agents) ========== addListener('subagent:discovered', (e) => { @@ -2914,6 +2978,18 @@ class ClaudemanApp { requestAnimationFrame(() => { this.updateConnectionLines(); }); + + // Notify about new subagent discovery + const parentId = this.subagentParentMap.get(data.agentId); + const parentSession = parentId ? this.sessions.get(parentId) : null; + this.notificationManager?.notify({ + urgency: 'info', + category: 'subagent-spawn', + sessionId: parentId || data.sessionId, + sessionName: parentSession?.name || parentId || data.sessionId, + title: 'Subagent Spawned', + message: data.description || 'New background agent started', + }); }); addListener('subagent:updated', (e) => { @@ -3022,6 +3098,18 @@ class ClaudemanApp { } } + // Notify about subagent completion + const parentId = this.subagentParentMap.get(data.agentId); + const parentSession = parentId ? this.sessions.get(parentId) : null; + this.notificationManager?.notify({ + urgency: 'info', + category: 'subagent-complete', + sessionId: parentId || existing?.sessionId || data.sessionId, + sessionName: parentSession?.name || parentId || data.sessionId, + title: 'Subagent Completed', + message: existing?.description || data.description || 'Background agent finished', + }); + // Clean up activity/tool data for completed agents after 5 minutes // This prevents memory leaks from long-running sessions with many subagents setTimeout(() => { @@ -9424,7 +9512,7 @@ class ClaudemanApp { }, session_error: { enabled: true, - browser: document.getElementById('eventPermissionBrowser').checked, // Use permission's browser setting + browser: this.notificationManager?.preferences?.eventTypes?.session_error?.browser ?? true, audio: false, }, respawn_cycle: { diff --git a/src/web/server.ts b/src/web/server.ts index 347b3735..19d2bfa5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -4645,7 +4645,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; private broadcast(event: string, data: unknown): void { // Invalidate caches on any state-changing broadcast - if (event.startsWith('session:') || event === 'respawn:') { + if (event.startsWith('session:') || event.startsWith('respawn:')) { this.cachedLightState = null; this.cachedSessionsList = null; }