mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1527
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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<FastifyReply>` (`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<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 |
|
||||
@@ -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
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,...">
|
||||
```
|
||||
|
||||
It shows a lightning bolt icon on a dark background. The favicon is **static** and never changes programmatically.
|
||||
|
||||
Browser notifications reference `/favicon.ico` as their icon (line 1130):
|
||||
```js
|
||||
const notif = new Notification(`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).
|
||||
@@ -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
|
||||
<div class="notification-drawer" id="notifDrawer">
|
||||
<div class="notif-drawer-header">
|
||||
<span class="notif-drawer-title">Notifications</span>
|
||||
<div class="notif-drawer-actions">
|
||||
<button onclick="markAllRead()">checkmark</button>
|
||||
<button onclick="clearAll()">trash</button>
|
||||
<button onclick="toggleNotifications()">X</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-drawer-list" id="notifList"></div>
|
||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4.2 Bell Button (index.html lines 63-66)
|
||||
|
||||
```html
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()">
|
||||
<svg><!-- bell icon --></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
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 `<span class="settings-status" id="notifPermissionStatus">?</span>` 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<sessionId, 'action' | 'idle'>` -- current alert state per tab
|
||||
- `pendingHooks: Map<sessionId, Set<hookType>>` -- 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 |
|
||||
@@ -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_<caseName>` 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 |
|
||||
+90
-2
@@ -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: {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user