diff --git a/.gitignore b/.gitignore index 89164b35d..31ab125b7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ coverage/ .claude/settings.local.json localdocs/ execplan/ +.worktrees/ # Generated npm bundle output (local) cli/npm/main/ diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..1b22a4a92 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -5,7 +5,8 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], - initializeCalls: [] as unknown[] + initializeCalls: [] as unknown[], + turnCompletion: { status: 'Completed' } as { status: string; message?: string } })); vi.mock('./codexAppServerClient', () => { @@ -40,7 +41,7 @@ vi.mock('./codexAppServerClient', () => { harness.notifications.push({ method: 'turn/started', params: started }); this.notificationHandler?.('turn/started', started); - const completed = { status: 'Completed', turn: {} }; + const completed = { ...harness.turnCompletion, turn: {} }; harness.notifications.push({ method: 'turn/completed', params: completed }); this.notificationHandler?.('turn/completed', completed); @@ -168,6 +169,7 @@ describe('codexRemoteLauncher', () => { harness.notifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; + harness.turnCompletion = { status: 'Completed' }; }); it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => { @@ -198,4 +200,20 @@ describe('codexRemoteLauncher', () => { expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); }); + + it('persists failed terminal events so the hub can notify for attention', async () => { + harness.turnCompletion = { status: 'Failed', message: 'boom' }; + const { + session, + codexMessages + } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'task_failed', + error: 'boom' + })); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 53024deec..5cbd5f60e 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -285,6 +285,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { allowAnonymousTerminalEvent = false; } + if (msgType === 'turn_aborted' || msgType === 'task_failed') { + session.sendAgentMessage(msg); + } + if (msgType === 'agent_message') { const message = asString(msg.message); if (message) { diff --git a/cli/src/utils/spawnHappyCLI.test.ts b/cli/src/utils/spawnHappyCLI.test.ts index 69c2b7a2e..d70a183f3 100644 --- a/cli/src/utils/spawnHappyCLI.test.ts +++ b/cli/src/utils/spawnHappyCLI.test.ts @@ -1,5 +1,7 @@ import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SpawnOptions } from 'child_process'; +import { join } from 'node:path'; +import { projectPath } from '@/projectPath'; const spawnMock = vi.fn((..._args: any[]) => ({ pid: 12345 } as any)); @@ -100,14 +102,16 @@ describe('spawnHappyCLI windowsHide behavior', () => { const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']); const isBunRuntime = Boolean((process.versions as Record).bun); + const expectedProjectRoot = projectPath().replace(/\\/g, '/'); + const expectedEntrypoint = join(projectPath(), 'src', 'index.ts').replace(/\\/g, '/'); expect(command.command).toBe(process.execPath); if (isBunRuntime) { expect(command.args[0]).toBe('--cwd'); - expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/hapi\/cli$/); - expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/hapi\/cli\/src\/index\.ts$/); + expect(command.args[1].replace(/\\/g, '/')).toBe(expectedProjectRoot); + expect(command.args[2].replace(/\\/g, '/')).toBe(expectedEntrypoint); } else { - expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/hapi/cli/src/index.ts'))).toBe(true); + expect(command.args.map((arg) => arg.replace(/\\/g, '/'))).toContain(expectedEntrypoint); } }); diff --git a/docs/superpowers/plans/2026-04-18-mobile-attention-notifications.md b/docs/superpowers/plans/2026-04-18-mobile-attention-notifications.md new file mode 100644 index 000000000..ea2fa7310 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-mobile-attention-notifications.md @@ -0,0 +1,1557 @@ +# Mobile Attention Notifications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add phone-friendly HAPI notifications for attention-worthy events only: permission requests, ready/finished sessions, and failure/interruption events. + +**Architecture:** Extend the existing `NotificationHub` pipeline rather than adding a second notification system. Hub classifies sync events into ready, permission, or attention notifications; `PushNotificationChannel` delivers foreground SSE toasts first and falls back to Web Push; the web app exposes an explicit notification settings control and stops auto-prompting permissions. + +**Tech Stack:** Bun, TypeScript, Hono hub, Web Push/VAPID, React 19, Vite PWA, Vitest/Bun tests, Workbox service worker. + +--- + +## File Structure + +- Modify: `hub/src/notifications/notificationTypes.ts` — add `AttentionReason`, `NotificationChannel.sendAttention`, and `attentionCooldownMs` option. +- Modify: `hub/src/notifications/eventParsing.ts` — add `extractAttentionReason()` and `isAgentMessageEvent()` helpers. +- Modify: `hub/src/notifications/eventParsing.test.ts` — cover supported failure/interruption event types and ordinary messages. +- Modify: `hub/src/notifications/notificationHub.ts` — track agent activity, thinking transitions, ready cooldown, attention cooldown, and channel dispatch. +- Modify: `hub/src/notifications/notificationHub.test.ts` — add TDD coverage for transition-ready, permission suppression, attention events, and cooldown. +- Create: `hub/src/push/pushNotificationChannel.test.ts` — cover foreground toast vs Web Push fallback and attention payload formatting. +- Modify: `hub/src/push/pushNotificationChannel.ts` — add `sendAttention()` and shared foreground-toast delivery helper. +- Modify: `hub/src/push/pushService.ts` — narrow `PushPayload.data.type` to notification payload types. +- Create: `web/src/hooks/useAutoPushSubscription.ts` — isolate startup auto-subscribe behavior; it subscribes only when permission is already granted. +- Create: `web/src/hooks/useAutoPushSubscription.test.tsx` — verify startup behavior never requests permission. +- Modify: `web/src/App.tsx` — replace auto permission prompt effect with `useAutoPushSubscription()`. +- Modify: `web/src/hooks/usePushNotifications.ts` — expose `refreshSubscription` so settings can update state after enable/resubscribe. +- Create: `web/src/hooks/usePushNotifications.test.tsx` — test unsupported and successful subscribe flows. +- Modify: `web/src/routes/settings/index.tsx` — add Notifications section using `useAppContext()` and `usePushNotifications()`. +- Modify: `web/src/routes/settings/index.test.tsx` — mock app context and push hook; verify notification state and explicit button behavior. +- Modify: `web/src/lib/locales/en.ts` and `web/src/lib/locales/zh-CN.ts` — add notification settings labels. +- Modify: `web/src/sw.ts` — focus an existing HAPI client and navigate it on notification click before opening a new window. + +## Spec Coverage Map + +- Permission requests: Task 2 keeps the existing `sendPermissionRequest()` hub flow and Task 3 keeps the `permission-request` payload path when shared delivery is introduced. +- Ready/finished sessions: Task 2 sends `sendReady()` both for existing ready events and for the new thinking-stops-after-agent-activity transition. +- Failure/interruption events: Tasks 1–3 add `AttentionReason`, parse supported event types, and deliver `sendAttention()` through SSE toast first, then fall back to Web Push when no visible client receives the toast. +- Mobile opt-in: Task 4 removes startup permission prompts; startup auto-subscribe only runs when `permission === 'granted'`, while Task 5 adds the explicit Settings button that calls `requestPermission()`. +- Notification click navigation: Task 6 updates the service worker to focus an existing HAPI window and navigate it before opening a new one. + +## Task 1: Hub Notification Types and Event Parsing + +**Files:** +- Modify: `hub/src/notifications/notificationTypes.ts` +- Modify: `hub/src/notifications/eventParsing.ts` +- Modify: `hub/src/notifications/eventParsing.test.ts` + +- [ ] **Step 1: Write failing parser/type tests** + +Append these tests to `hub/src/notifications/eventParsing.test.ts` inside the existing `describe('extractMessageEventType', () => { ... })` block, after the current tests: + +```ts + it('returns attention reason for supported failure and interruption event types', () => { + const makeEvent = (type: string): SyncEvent => ({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: `message-${type}`, + seq: 10, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: `event-${type}`, + type: 'event', + data: { type } + } + } + } + }) + + expect(extractAttentionReason(makeEvent('error'))).toBe('failed') + expect(extractAttentionReason(makeEvent('failed'))).toBe('failed') + expect(extractAttentionReason(makeEvent('task-failed'))).toBe('failed') + expect(extractAttentionReason(makeEvent('aborted'))).toBe('interrupted') + expect(extractAttentionReason(makeEvent('interrupted'))).toBe('interrupted') + }) + + it('returns null attention reason for ready and ordinary message events', () => { + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'message-ready', + seq: 11, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-ready', + type: 'event', + data: { type: 'ready' } + } + } + } + } + + const textEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'message-text', + seq: 12, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { type: 'text', text: 'done' } + } + } + } + + expect(extractAttentionReason(readyEvent)).toBeNull() + expect(extractAttentionReason(textEvent)).toBeNull() + }) + + it('detects agent message events without treating user messages as agent activity', () => { + const agentEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-message', + seq: 13, + localId: null, + createdAt: 0, + content: { role: 'agent', content: { type: 'text', text: 'done' } } + } + } + const userEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'user-message', + seq: 14, + localId: null, + createdAt: 0, + content: { role: 'user', content: { type: 'text', text: 'hello' } } + } + } + + expect(isAgentMessageEvent(agentEvent)).toBe(true) + expect(isAgentMessageEvent(userEvent)).toBe(false) + }) +``` + +Also update the import line in `eventParsing.test.ts` to: + +```ts +import { extractAttentionReason, extractMessageEventType, isAgentMessageEvent } from './eventParsing' +``` + +- [ ] **Step 2: Run parser tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/notifications/eventParsing.test.ts +``` + +Expected: FAIL because `extractAttentionReason` and `isAgentMessageEvent` are not exported. + +- [ ] **Step 3: Implement notification types and parsing helpers** + +Replace `hub/src/notifications/notificationTypes.ts` with: + +```ts +import type { Session } from '../sync/syncEngine' + +export type AttentionReason = 'failed' | 'interrupted' + +export type NotificationChannel = { + sendReady: (session: Session) => Promise + sendPermissionRequest: (session: Session) => Promise + sendAttention: (session: Session, reason: AttentionReason) => Promise +} + +export type NotificationHubOptions = { + readyCooldownMs?: number + permissionDebounceMs?: number + attentionCooldownMs?: number +} +``` + +Replace `hub/src/notifications/eventParsing.ts` with: + +```ts +import { isObject } from '@hapi/protocol' +import type { SyncEvent } from '../sync/syncEngine' +import type { AttentionReason } from './notificationTypes' + +type EventEnvelope = { + type?: unknown + data?: unknown +} + +function extractEventEnvelope(message: unknown): EventEnvelope | null { + if (!isObject(message)) { + return null + } + + if (message.type === 'event') { + return message as EventEnvelope + } + + const content = message.content + if (!isObject(content) || content.type !== 'event') { + return null + } + + return content as EventEnvelope +} + +function extractMessageContent(event: SyncEvent): unknown { + if (event.type !== 'message-received') { + return null + } + return event.message?.content +} + +export function extractMessageEventType(event: SyncEvent): string | null { + const envelope = extractEventEnvelope(extractMessageContent(event)) + if (!envelope) { + return null + } + + const data = isObject(envelope.data) ? envelope.data : null + const eventType = data?.type + return typeof eventType === 'string' ? eventType : null +} + +export function extractAttentionReason(event: SyncEvent): AttentionReason | null { + const eventType = extractMessageEventType(event) + if (eventType === 'error' || eventType === 'failed' || eventType === 'task-failed') { + return 'failed' + } + if (eventType === 'aborted' || eventType === 'interrupted') { + return 'interrupted' + } + return null +} + +export function isAgentMessageEvent(event: SyncEvent): boolean { + const content = extractMessageContent(event) + if (!isObject(content)) { + return false + } + return content.role === 'agent' +} +``` + +- [ ] **Step 4: Run parser tests to verify GREEN** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/notifications/eventParsing.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit parser/type changes** + +Run: + +```bash +git add hub/src/notifications/notificationTypes.ts hub/src/notifications/eventParsing.ts hub/src/notifications/eventParsing.test.ts +git commit -m "feat: classify attention notification events" +``` + +## Task 2: NotificationHub Attention Logic + +**Files:** +- Modify: `hub/src/notifications/notificationHub.ts` +- Modify: `hub/src/notifications/notificationHub.test.ts` + +- [ ] **Step 1: Write failing NotificationHub tests** + +Update `StubChannel` in `hub/src/notifications/notificationHub.test.ts` to track attention notifications: + +```ts +class StubChannel implements NotificationChannel { + readonly readySessions: Session[] = [] + readonly permissionSessions: Session[] = [] + readonly attentionNotifications: Array<{ session: Session; reason: 'failed' | 'interrupted' }> = [] + + async sendReady(session: Session): Promise { + this.readySessions.push(session) + } + + async sendPermissionRequest(session: Session): Promise { + this.permissionSessions.push(session) + } + + async sendAttention(session: Session, reason: 'failed' | 'interrupted'): Promise { + this.attentionNotifications.push({ session, reason }) + } +} +``` + +Append these tests inside the existing `describe('NotificationHub', () => { ... })` block: + +```ts + it('sends ready when thinking stops after agent activity', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 5 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-text', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'text', text: 'done' } } + } + }) + + engine.setSession(createSession({ thinking: false, thinkingAt: 3 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + await sleep(10) + + expect(channel.readySessions).toHaveLength(1) + hub.stop() + }) + + it('does not send transition-ready when a permission request is pending', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 5, + readyCooldownMs: 5 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-text', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'text', text: 'needs approval' } } + } + }) + engine.setSession(createSession({ + thinking: false, + thinkingAt: 3, + agentState: { + requests: { + req1: { tool: 'Edit', arguments: {}, createdAt: 3 } + } + } + })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + await sleep(20) + + expect(channel.readySessions).toHaveLength(0) + expect(channel.permissionSessions).toHaveLength(1) + hub.stop() + }) + + it('sends attention notification for failure and interruption events with cooldown', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 1, + attentionCooldownMs: 20 + }) + engine.setSession(createSession()) + + const failedEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'failed-1', + seq: 4, + localId: null, + createdAt: 4, + content: { role: 'agent', content: { type: 'event', data: { type: 'failed' } } } + } + } + + engine.emit(failedEvent) + await sleep(5) + engine.emit(failedEvent) + await sleep(5) + + expect(channel.attentionNotifications).toHaveLength(1) + expect(channel.attentionNotifications[0]?.reason).toBe('failed') + + await sleep(25) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'aborted-1', + seq: 5, + localId: null, + createdAt: 5, + content: { role: 'agent', content: { type: 'event', data: { type: 'aborted' } } } + } + }) + await sleep(5) + + expect(channel.attentionNotifications).toHaveLength(2) + expect(channel.attentionNotifications[1]?.reason).toBe('interrupted') + hub.stop() + }) +``` + +- [ ] **Step 2: Run NotificationHub tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/notifications/notificationHub.test.ts +``` + +Expected: FAIL because `NotificationHub` does not call `sendAttention()` and does not detect thinking transitions. + +- [ ] **Step 3: Implement NotificationHub state and dispatch** + +Modify `hub/src/notifications/notificationHub.ts` as follows: + +- Change imports to: + +```ts +import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' +import type { AttentionReason, NotificationChannel, NotificationHubOptions } from './notificationTypes' +import { extractAttentionReason, extractMessageEventType, isAgentMessageEvent } from './eventParsing' +``` + +- Add class fields near the existing maps: + +```ts + private readonly attentionCooldownMs: number + private readonly lastAttentionNotificationAt: Map = new Map() + private readonly lastThinkingBySession: Map = new Map() + private readonly agentActivityBySession: Map = new Map() +``` + +- In the constructor, set: + +```ts + this.attentionCooldownMs = options?.attentionCooldownMs ?? 5000 +``` + +- In `stop()` and `clearSessionState()`, clear the three new maps for the relevant session/all sessions. + +- Replace the `session-updated` / `session-added` branch in `handleSyncEvent()` with this logic: + +```ts + if ((event.type === 'session-updated' || event.type === 'session-added') && event.sessionId) { + const session = this.syncEngine.getSession(event.sessionId) + if (!session || !session.active) { + this.clearSessionState(event.sessionId) + return + } + + this.checkForPermissionNotification(session) + this.checkForThinkingStoppedNotification(session) + this.lastThinkingBySession.set(session.id, session.thinking) + return + } +``` + +- Replace the `message-received` branch with this logic: + +```ts + if (event.type === 'message-received' && event.sessionId) { + if (isAgentMessageEvent(event)) { + this.agentActivityBySession.set(event.sessionId, true) + } + + const attentionReason = extractAttentionReason(event) + if (attentionReason) { + this.sendAttentionNotification(event.sessionId, attentionReason).catch((error) => { + console.error('[NotificationHub] Failed to send attention notification:', error) + }) + return + } + + const eventType = extractMessageEventType(event) + if (eventType === 'ready') { + this.sendReadyNotification(event.sessionId).catch((error) => { + console.error('[NotificationHub] Failed to send ready notification:', error) + }) + } + } +``` + +- Add these private methods before `notifyReady()`: + +```ts + private hasPendingPermissionRequest(session: Session): boolean { + const requests = session.agentState?.requests + return Boolean(requests && Object.keys(requests).length > 0) + } + + private checkForThinkingStoppedNotification(session: Session): void { + const wasThinking = this.lastThinkingBySession.get(session.id) + if (wasThinking !== true || session.thinking) { + return + } + if (!this.agentActivityBySession.get(session.id)) { + return + } + this.agentActivityBySession.delete(session.id) + if (this.hasPendingPermissionRequest(session)) { + return + } + + this.sendReadyNotification(session.id).catch((error) => { + console.error('[NotificationHub] Failed to send ready notification:', error) + }) + } + + private async sendAttentionNotification(sessionId: string, reason: AttentionReason): Promise { + const session = this.getNotifiableSession(sessionId) + if (!session) { + return + } + + const now = Date.now() + const last = this.lastAttentionNotificationAt.get(sessionId) ?? 0 + if (now - last < this.attentionCooldownMs) { + return + } + this.lastAttentionNotificationAt.set(sessionId, now) + + await this.notifyAttention(session, reason) + } +``` + +- Add `notifyAttention()` after `notifyPermission()`: + +```ts + private async notifyAttention(session: Session, reason: AttentionReason): Promise { + for (const channel of this.channels) { + try { + await channel.sendAttention(session, reason) + } catch (error) { + console.error('[NotificationHub] Failed to send attention notification:', error) + } + } + } +``` + +- [ ] **Step 4: Run NotificationHub tests to verify GREEN** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/notifications/notificationHub.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit NotificationHub changes** + +Run: + +```bash +git add hub/src/notifications/notificationHub.ts hub/src/notifications/notificationHub.test.ts +git commit -m "feat: notify when sessions need attention" +``` + +## Task 3: Push Notification Channel Delivery and Payloads + +**Files:** +- Create: `hub/src/push/pushNotificationChannel.test.ts` +- Modify: `hub/src/push/pushNotificationChannel.ts` +- Modify: `hub/src/push/pushService.ts` + +- [ ] **Step 1: Write failing PushNotificationChannel tests** + +Create `hub/src/push/pushNotificationChannel.test.ts`: + +```ts +import { describe, expect, it } from 'bun:test' +import { PushNotificationChannel } from './pushNotificationChannel' +import type { PushPayload, PushService } from './pushService' +import type { Session } from '../sync/syncEngine' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' + +type ToastEvent = { + type: 'toast' + data: { + title: string + body: string + sessionId: string + url: string + } +} + +class FakePushService { + readonly sent: Array<{ namespace: string; payload: PushPayload }> = [] + + async sendToNamespace(namespace: string, payload: PushPayload): Promise { + this.sent.push({ namespace, payload }) + } +} + +class FakeSSEManager { + readonly toasts: Array<{ namespace: string; event: ToastEvent }> = [] + delivered = 0 + + async sendToast(namespace: string, event: ToastEvent): Promise { + this.toasts.push({ namespace, event }) + return this.delivered + } +} + +class FakeVisibilityTracker { + visible = false + + hasVisibleConnection(_namespace: string): boolean { + return this.visible + } +} + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { path: '/repo', host: 'mac', summary: { text: 'Build UI', updatedAt: 1 } }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } +} + +function createChannel() { + const push = new FakePushService() + const sse = new FakeSSEManager() + const visibility = new FakeVisibilityTracker() + const channel = new PushNotificationChannel( + push as unknown as PushService, + sse as unknown as SSEManager, + visibility as unknown as VisibilityTracker, + '' + ) + return { channel, push, sse, visibility } +} + +describe('PushNotificationChannel', () => { + it('sends foreground toast and skips Web Push when visible toast is delivered', async () => { + const { channel, push, sse, visibility } = createChannel() + visibility.visible = true + sse.delivered = 1 + + await channel.sendReady(createSession()) + + expect(sse.toasts).toHaveLength(1) + expect(sse.toasts[0]?.event.data.title).toBe('Ready for input') + expect(push.sent).toHaveLength(0) + }) + + it('falls back to Web Push when there is no visible delivered toast', async () => { + const { channel, push, sse, visibility } = createChannel() + visibility.visible = true + sse.delivered = 0 + + await channel.sendReady(createSession()) + + expect(sse.toasts).toHaveLength(1) + expect(push.sent).toHaveLength(1) + expect(push.sent[0]?.payload.data?.type).toBe('ready') + }) + + it('formats attention notification payloads', async () => { + const { channel, push } = createChannel() + + await channel.sendAttention(createSession(), 'failed') + + expect(push.sent).toHaveLength(1) + expect(push.sent[0]?.payload).toEqual({ + title: 'Task needs attention', + body: 'Build UI stopped or failed', + tag: 'attention-session-1', + data: { + type: 'attention', + sessionId: 'session-1', + url: '/sessions/session-1' + } + }) + }) +}) +``` + +- [ ] **Step 2: Run PushNotificationChannel tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/push/pushNotificationChannel.test.ts +``` + +Expected: FAIL because `sendAttention()` does not exist. + +- [ ] **Step 3: Implement attention payload and shared delivery helper** + +In `hub/src/push/pushService.ts`, replace the `PushPayload` type with: + +```ts +export type PushPayload = { + title: string + body: string + tag?: string + data?: { + type: 'permission-request' | 'ready' | 'attention' + sessionId: string + url: string + } +} +``` + +In `hub/src/push/pushNotificationChannel.ts`: + +- Change the type import to: + +```ts +import type { AttentionReason, NotificationChannel } from '../notifications/notificationTypes' +``` + +- Replace repeated toast/push delivery code by adding this private helper before `buildSessionPath()`: + +```ts + private async deliver(session: Session, payload: PushPayload): Promise { + const url = payload.data?.url ?? this.buildSessionPath(session.id) + if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { + const delivered = await this.sseManager.sendToast(session.namespace, { + type: 'toast', + data: { + title: payload.title, + body: payload.body, + sessionId: session.id, + url + } + }) + if (delivered > 0) { + return + } + } + + await this.pushService.sendToNamespace(session.namespace, payload) + } +``` + +- In `sendPermissionRequest()` and `sendReady()`, replace the local toast/push block with: + +```ts + await this.deliver(session, payload) +``` + +- Add this method after `sendReady()`: + +```ts + async sendAttention(session: Session, _reason: AttentionReason): Promise { + if (!session.active) { + return + } + + const name = getSessionName(session) + const payload: PushPayload = { + title: 'Task needs attention', + body: `${name} stopped or failed`, + tag: `attention-${session.id}`, + data: { + type: 'attention', + sessionId: session.id, + url: this.buildSessionPath(session.id) + } + } + + await this.deliver(session, payload) + } +``` + +- [ ] **Step 4: Run PushNotificationChannel tests to verify GREEN** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test hub/src/push/pushNotificationChannel.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run hub typecheck for channel interface compatibility** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run typecheck:hub +``` + +Expected: PASS. + +- [ ] **Step 6: Commit push channel changes** + +Run: + +```bash +git add hub/src/push/pushNotificationChannel.ts hub/src/push/pushNotificationChannel.test.ts hub/src/push/pushService.ts +git commit -m "feat: deliver attention push notifications" +``` + +## Task 4: Web Push Hook Tests and Startup Auto-Subscribe + +**Files:** +- Create: `web/src/hooks/usePushNotifications.test.tsx` +- Create: `web/src/hooks/useAutoPushSubscription.ts` +- Create: `web/src/hooks/useAutoPushSubscription.test.tsx` +- Modify: `web/src/hooks/usePushNotifications.ts` +- Modify: `web/src/App.tsx` + +- [ ] **Step 1: Write failing `usePushNotifications` tests** + +Create `web/src/hooks/usePushNotifications.test.tsx`: + +```tsx +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import { usePushNotifications } from './usePushNotifications' + +function installUnsupportedPushGlobals() { + Reflect.deleteProperty(window.navigator, 'serviceWorker') + Reflect.deleteProperty(window, 'PushManager') + Reflect.deleteProperty(window, 'Notification') +} + +function installSupportedPushGlobals(options?: { permission?: NotificationPermission }) { + const permission = options?.permission ?? 'granted' + const subscriptionJson = { + endpoint: 'https://push.example/subscription', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' } + } + const subscription = { + endpoint: subscriptionJson.endpoint, + toJSON: () => subscriptionJson, + unsubscribe: vi.fn(async () => true) + } + const pushManager = { + getSubscription: vi.fn(async () => null), + subscribe: vi.fn(async () => subscription) + } + const ready = Promise.resolve({ pushManager }) + + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: { ready } + }) + Object.defineProperty(window, 'PushManager', { + configurable: true, + value: function PushManager() {} + }) + Object.defineProperty(window, 'Notification', { + configurable: true, + value: { + permission, + requestPermission: vi.fn(async () => permission) + } + }) + + return { pushManager, subscription } +} + +function createApi(): ApiClient & { + subscribed: unknown[] +} { + const subscribed: unknown[] = [] + return { + subscribed, + getPushVapidPublicKey: vi.fn(async () => ({ publicKey: 'AQAB' })), + subscribePushNotifications: vi.fn(async (payload: unknown) => { + subscribed.push(payload) + }), + unsubscribePushNotifications: vi.fn(async () => {}) + } as unknown as ApiClient & { subscribed: unknown[] } +} + +describe('usePushNotifications', () => { + beforeEach(() => { + vi.restoreAllMocks() + installUnsupportedPushGlobals() + }) + + it('reports unsupported browsers and exposes refreshSubscription', async () => { + const { result } = renderHook(() => usePushNotifications(null)) + + await waitFor(() => { + expect(result.current.isSupported).toBe(false) + expect(result.current.isSubscribed).toBe(false) + }) + expect(typeof result.current.refreshSubscription).toBe('function') + }) + + it('subscribes and posts endpoint keys when permission is granted', async () => { + const { pushManager } = installSupportedPushGlobals({ permission: 'granted' }) + const api = createApi() + const { result } = renderHook(() => usePushNotifications(api)) + + await act(async () => { + const ok = await result.current.subscribe() + expect(ok).toBe(true) + }) + + expect(pushManager.subscribe).toHaveBeenCalledWith(expect.objectContaining({ userVisibleOnly: true })) + expect(api.subscribePushNotifications).toHaveBeenCalledWith({ + endpoint: 'https://push.example/subscription', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' } + }) + expect(result.current.isSubscribed).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run hook tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test web/src/hooks/usePushNotifications.test.tsx +``` + +Expected: FAIL because `refreshSubscription` is not returned from `usePushNotifications()`. + +- [ ] **Step 3: Expose `refreshSubscription` from `usePushNotifications`** + +In `web/src/hooks/usePushNotifications.ts`, add `refreshSubscription` to the returned object: + +```ts + return { + isSupported, + permission, + isSubscribed, + refreshSubscription, + requestPermission, + subscribe, + unsubscribe + } +``` + +- [ ] **Step 4: Write failing auto-subscribe hook tests** + +Create `web/src/hooks/useAutoPushSubscription.ts` with this initial exported type only: + +```ts +import type { ApiClient } from '@/api/client' + +export type AutoPushSubscriptionOptions = { + api: ApiClient | null + token: string | null + isTelegram: boolean + isSupported: boolean + permission: NotificationPermission + subscribe: () => Promise +} +``` + +Create `web/src/hooks/useAutoPushSubscription.test.tsx`: + +```tsx +import { renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useAutoPushSubscription } from './useAutoPushSubscription' + +describe('useAutoPushSubscription', () => { + it('subscribes automatically only when permission is already granted', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: false, + isSupported: true, + permission: 'granted', + subscribe + })) + + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + }) + + it('does not request or subscribe when permission is default', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: false, + isSupported: true, + permission: 'default', + subscribe + })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(subscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe inside Telegram', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: true, + isSupported: true, + permission: 'granted', + subscribe + })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(subscribe).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 5: Run auto-subscribe tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test web/src/hooks/useAutoPushSubscription.test.tsx +``` + +Expected: FAIL because `useAutoPushSubscription` is not exported. + +- [ ] **Step 6: Implement `useAutoPushSubscription`** + +Replace `web/src/hooks/useAutoPushSubscription.ts` with: + +```ts +import { useEffect, useRef } from 'react' +import type { ApiClient } from '@/api/client' + +export type AutoPushSubscriptionOptions = { + api: ApiClient | null + token: string | null + isTelegram: boolean + isSupported: boolean + permission: NotificationPermission + subscribe: () => Promise +} + +export function useAutoPushSubscription(options: AutoPushSubscriptionOptions): void { + const attemptedRef = useRef(false) + + useEffect(() => { + if (!options.api || !options.token) { + attemptedRef.current = false + return + } + if (options.isTelegram || !options.isSupported || options.permission !== 'granted') { + return + } + if (attemptedRef.current) { + return + } + attemptedRef.current = true + + void options.subscribe() + }, [ + options.api, + options.isSupported, + options.isTelegram, + options.permission, + options.subscribe, + options.token + ]) +} +``` + +- [ ] **Step 7: Integrate the hook in App without automatic permission prompting** + +In `web/src/App.tsx`: + +- Add import: + +```ts +import { useAutoPushSubscription } from '@/hooks/useAutoPushSubscription' +``` + +- Remove `pushPromptedRef`. +- Change the push hook destructuring to: + +```ts + const { isSupported: isPushSupported, permission: pushPermission, subscribe } = usePushNotifications(api) +``` + +- Replace the whole push `useEffect` block with: + +```ts + useAutoPushSubscription({ + api, + token, + isTelegram: isTelegramApp(), + isSupported: isPushSupported, + permission: pushPermission, + subscribe + }) +``` + +- [ ] **Step 8: Run web hook tests to verify GREEN** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test web/src/hooks/usePushNotifications.test.tsx web/src/hooks/useAutoPushSubscription.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 9: Commit web hook and App changes** + +Run: + +```bash +git add web/src/hooks/usePushNotifications.ts web/src/hooks/usePushNotifications.test.tsx web/src/hooks/useAutoPushSubscription.ts web/src/hooks/useAutoPushSubscription.test.tsx web/src/App.tsx +git commit -m "feat: make push subscription user initiated" +``` + +## Task 5: Notification Settings UI + +**Files:** +- Modify: `web/src/routes/settings/index.tsx` +- Modify: `web/src/routes/settings/index.test.tsx` +- Modify: `web/src/lib/locales/en.ts` +- Modify: `web/src/lib/locales/zh-CN.ts` + +- [ ] **Step 1: Write failing settings tests** + +In `web/src/routes/settings/index.test.tsx`: + +- Add import: + +```ts +import { fireEvent, waitFor } from '@testing-library/react' +``` + +- Add mocks near existing mocks: + +```ts +const mockRequestPermission = vi.fn(async () => true) +const mockSubscribe = vi.fn(async () => true) +const mockRefreshSubscription = vi.fn(async () => {}) +let mockPushState = { + isSupported: true, + permission: 'default' as NotificationPermission, + isSubscribed: false +} + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: {}, token: 'token', baseUrl: 'http://localhost' }) +})) + +vi.mock('@/hooks/usePushNotifications', () => ({ + usePushNotifications: () => ({ + ...mockPushState, + requestPermission: mockRequestPermission, + subscribe: mockSubscribe, + refreshSubscription: mockRefreshSubscription, + unsubscribe: vi.fn(async () => true) + }) +})) +``` + +- In `beforeEach()`, reset push state: + +```ts + mockPushState = { + isSupported: true, + permission: 'default', + isSubscribed: false + } + mockRequestPermission.mockClear() + mockSubscribe.mockClear() + mockRefreshSubscription.mockClear() +``` + +- Append tests inside `describe('SettingsPage', () => { ... })`: + +```tsx + it('renders notification settings state and enable button', () => { + renderWithProviders() + + expect(screen.getAllByText('Notifications').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Not enabled').length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'Enable notifications' })).toBeInTheDocument() + }) + + it('enables notifications only after clicking the explicit button', async () => { + renderWithProviders() + + expect(mockRequestPermission).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Enable notifications' })) + + await waitFor(() => expect(mockRequestPermission).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(mockSubscribe).toHaveBeenCalledTimes(1)) + }) + + it('renders resubscribe button when permission is granted but subscription is missing', () => { + mockPushState = { + isSupported: true, + permission: 'granted', + isSubscribed: false + } + + renderWithProviders() + + expect(screen.getAllByText('Permission granted, not subscribed').length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'Resubscribe notifications' })).toBeInTheDocument() + }) + + it('shows help text when notification permission is denied', () => { + mockPushState = { + isSupported: true, + permission: 'denied', + isSubscribed: false + } + + renderWithProviders() + + expect(screen.getAllByText('Blocked by browser settings').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Enable notifications from browser or system settings, then return here.')).toBeInTheDocument() + }) +``` + +- [ ] **Step 2: Run settings tests to verify RED** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test web/src/routes/settings/index.test.tsx +``` + +Expected: FAIL because the notifications section does not exist. + +- [ ] **Step 3: Add locale keys** + +Add these keys near the settings keys in `web/src/lib/locales/en.ts`: + +```ts + 'settings.notifications.title': 'Notifications', + 'settings.notifications.status': 'Notification Status', + 'settings.notifications.unsupported': 'Not supported in this browser', + 'settings.notifications.default': 'Not enabled', + 'settings.notifications.grantedSubscribed': 'Enabled', + 'settings.notifications.grantedUnsubscribed': 'Permission granted, not subscribed', + 'settings.notifications.denied': 'Blocked by browser settings', + 'settings.notifications.enable': 'Enable notifications', + 'settings.notifications.resubscribe': 'Resubscribe notifications', + 'settings.notifications.deniedHelp': 'Enable notifications from browser or system settings, then return here.', + 'settings.notifications.unsupportedHelp': 'Use Safari on iOS or a browser with Web Push support.', +``` + +Add these keys near the settings keys in `web/src/lib/locales/zh-CN.ts`: + +```ts + 'settings.notifications.title': '通知', + 'settings.notifications.status': '通知状态', + 'settings.notifications.unsupported': '当前浏览器不支持', + 'settings.notifications.default': '未启用', + 'settings.notifications.grantedSubscribed': '已启用', + 'settings.notifications.grantedUnsubscribed': '已授权,尚未订阅', + 'settings.notifications.denied': '已被浏览器设置阻止', + 'settings.notifications.enable': '启用通知', + 'settings.notifications.resubscribe': '重新订阅通知', + 'settings.notifications.deniedHelp': '请先在浏览器或系统设置中允许通知,然后回到这里。', + 'settings.notifications.unsupportedHelp': '请在 iOS Safari 或支持 Web Push 的浏览器中使用。', +``` + +- [ ] **Step 4: Implement Settings notifications section** + +In `web/src/routes/settings/index.tsx`: + +- Add imports: + +```ts +import { useAppContext } from '@/lib/app-context' +import { usePushNotifications } from '@/hooks/usePushNotifications' +``` + +- Inside `SettingsPage()`, after `const { appearance, setAppearance } = useAppearance()`, add: + +```ts + const { api } = useAppContext() + const { + isSupported: isPushSupported, + permission: pushPermission, + isSubscribed: isPushSubscribed, + requestPermission, + subscribe, + refreshSubscription + } = usePushNotifications(api) + const [isNotificationBusy, setIsNotificationBusy] = useState(false) +``` + +- Add helper state before `return (`: + +```ts + const notificationStatusLabel = (() => { + if (!isPushSupported) return t('settings.notifications.unsupported') + if (pushPermission === 'denied') return t('settings.notifications.denied') + if (pushPermission === 'granted' && isPushSubscribed) return t('settings.notifications.grantedSubscribed') + if (pushPermission === 'granted') return t('settings.notifications.grantedUnsubscribed') + return t('settings.notifications.default') + })() + + const notificationButtonLabel = pushPermission === 'granted' + ? t('settings.notifications.resubscribe') + : t('settings.notifications.enable') + + const canEnableNotifications = isPushSupported + && pushPermission !== 'denied' + && !(pushPermission === 'granted' && isPushSubscribed) + + const handleEnableNotifications = async () => { + if (!canEnableNotifications || isNotificationBusy) return + setIsNotificationBusy(true) + try { + const granted = pushPermission === 'granted' || await requestPermission() + if (granted) { + await subscribe() + } + await refreshSubscription() + } finally { + setIsNotificationBusy(false) + } + } +``` + +- Insert this section between Display and Voice Assistant sections: + +```tsx + {/* Notifications section */} +
+
+ {t('settings.notifications.title')} +
+
+ {t('settings.notifications.status')} + {notificationStatusLabel} +
+ {pushPermission === 'denied' && ( +
+ {t('settings.notifications.deniedHelp')} +
+ )} + {!isPushSupported && ( +
+ {t('settings.notifications.unsupportedHelp')} +
+ )} + {canEnableNotifications && ( +
+ +
+ )} +
+``` + +- [ ] **Step 5: Run settings tests to verify GREEN** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun test web/src/routes/settings/index.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit settings UI changes** + +Run: + +```bash +git add web/src/routes/settings/index.tsx web/src/routes/settings/index.test.tsx web/src/lib/locales/en.ts web/src/lib/locales/zh-CN.ts +git commit -m "feat: add notification settings controls" +``` + +## Task 6: Service Worker Notification Click Focus + +**Files:** +- Modify: `web/src/sw.ts` + +- [ ] **Step 1: Update notification click handler** + +Replace the existing `notificationclick` listener in `web/src/sw.ts` with: + +```ts +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const data = event.notification.data as { url?: string } | undefined + const url = data?.url ?? '/' + + event.waitUntil((async () => { + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }) + + for (const client of windowClients) { + if ('focus' in client) { + if ('navigate' in client) { + await client.navigate(url) + } + return await client.focus() + } + } + + return await self.clients.openWindow(url) + })()) +}) +``` + +- [ ] **Step 2: Run web build-focused typecheck** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run typecheck:web +``` + +Expected: PASS. + +- [ ] **Step 3: Commit service worker change** + +Run: + +```bash +git add web/src/sw.ts +git commit -m "feat: focus session from push notification clicks" +``` + +## Task 7: Full Verification + +**Files:** +- Verify all touched files. + +- [ ] **Step 1: Run hub tests** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run test:hub +``` + +Expected: PASS with 0 failing tests. + +- [ ] **Step 2: Run web tests** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run test:web +``` + +Expected: PASS with 0 failing tests. + +- [ ] **Step 3: Run hub typecheck** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run typecheck:hub +``` + +Expected: PASS. + +- [ ] **Step 4: Run web typecheck** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run typecheck:web +``` + +Expected: PASS. + +- [ ] **Step 5: Browser smoke test notification settings** + +Run the app: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +bun run dev +``` + +Open HAPI Web, sign in, go to `/settings`, and verify: + +- Notifications section appears. +- Permission `default` shows `Not enabled` and `Enable notifications`. +- Clicking `Enable notifications` prompts the browser permission dialog. +- Granting permission stores a push subscription in the hub database. +- Foreground attention events show SSE toast instead of system push. + +- [ ] **Step 6: Review diff** + +Run: + +```bash +cd /Users/tehao/Documents/Playground/hapi-source +git diff --stat HEAD~6..HEAD +git log --oneline -6 +``` + +Expected: six feature commits after this plan commit, with changes limited to hub notifications, push channel, web push hooks, settings UI, locales, and service worker. diff --git a/docs/superpowers/specs/2026-04-18-mobile-attention-notifications-design.md b/docs/superpowers/specs/2026-04-18-mobile-attention-notifications-design.md new file mode 100644 index 000000000..f179b78a7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-mobile-attention-notifications-design.md @@ -0,0 +1,212 @@ +# Mobile Attention Notifications Design + +## Goal + +Implement phone-friendly HAPI notifications for events that need the user's attention, without notifying every assistant token, tool log, or ordinary transcript update. + +The first version targets HAPI's existing mobile surfaces: the installable PWA and Telegram Mini App-compatible web client. It builds on the current Web Push, service worker, SSE toast, VAPID, and notification hub infrastructure already present in `hub/` and `web/`. + +## Non-Goals + +- No native iOS or Android app. +- No per-session notification preferences in this iteration. +- No notification for every assistant message. +- No push for tool-call logs, function output, streaming deltas, user messages, or duplicate transcript reconciliation. +- No new third-party notification provider beyond existing Web Push and existing Telegram integration. + +## Existing Context + +HAPI already has these pieces: + +- `web/src/hooks/usePushNotifications.ts` subscribes the browser to Web Push through `PushManager`. +- `web/src/sw.ts` receives push payloads and calls `showNotification`. +- `hub/src/web/routes/push.ts` exposes VAPID public key, subscribe, and unsubscribe endpoints. +- `hub/src/push/pushService.ts` sends Web Push payloads and removes expired subscriptions. +- `hub/src/push/pushNotificationChannel.ts` converts notification hub events into SSE toast or Web Push. +- `hub/src/notifications/notificationHub.ts` already detects permission requests and `ready` events. +- `hub/src/visibility/visibilityTracker.ts` tracks visible web clients so foreground users can get toasts instead of system pushes. + +## Notification Scope + +Only send notifications for events requiring attention: + +1. **Permission request** + - Trigger: an active session gains a new `agentState.requests` id. + - Existing behavior stays, with tests preserved. + - Title: `Permission Request`. + - Body: session name, plus the tool name when the pending request includes a tool field. + - Tag: `permission-`. + +2. **Ready for input** + - Trigger: a `message-received` sync event carries an event envelope with `data.type === 'ready'`. + - Existing behavior stays, with cooldown. + - Title: `Ready for input`. + - Body: ` is waiting in `. + - Tag: `ready-`. + +3. **Session stopped after agent activity** + - Trigger: an active session transitions from `thinking: true` to `thinking: false`, and the session recently had agent activity. + - Purpose: cover agents that finish without emitting an explicit `ready` event. + - Reuses the ready notification channel and payload shape. + - Suppressed when there is a pending permission request, because permission request gets its own notification. + - Suppressed by the same ready cooldown to prevent duplicates with explicit `ready` events. + +4. **Failure or interruption requiring attention** + - Trigger: a `message-received` sync event carries an event envelope whose `data.type` is one of: + - `error` + - `failed` + - `aborted` + - `interrupted` + - `task-failed` + - Title: `Task needs attention`. + - Body: ` stopped or failed`. + - Tag: `attention-`. + - Uses its own cooldown map so repeated failure events do not spam. + +## De-Duping and Noise Control + +- Foreground visible clients must receive an SSE toast first. +- If an SSE toast is delivered to a visible client, do not send Web Push. +- If no visible client receives the toast, send Web Push to namespace subscriptions. +- Ready/session-stopped notifications share one cooldown per session. +- Failure/interruption notifications use a separate cooldown per session. +- Permission request notifications keep the existing request-id based debounce. +- Do not notify on: + - user messages, + - ordinary assistant text, + - function calls, + - function outputs, + - token counts, + - session list refreshes, + - duplicate imported messages. + +## Browser and PWA Behavior + +The service worker continues to display notifications using the push payload: + +- `title` +- `body` +- `icon` +- `badge` +- `tag` +- `data.type` +- `data.sessionId` +- `data.url` + +Clicking a notification must navigate to the session URL. If an existing HAPI client window is open, focus it and navigate it to the notification URL. If no window exists, open a new window. + +## Subscription UX + +Change notification permission from automatic prompting to user-initiated enabling: + +- The app may automatically re-subscribe when permission is already `granted`. +- The app must not call `Notification.requestPermission()` automatically on normal startup. +- Settings page must expose current notification state: + - unsupported, + - permission `default`, + - permission `granted`, + - permission `denied`, + - subscribed or not subscribed. +- Settings page must provide one explicit button: + - `Enable notifications` when permission is `default`. + - `Resubscribe notifications` when permission is `granted` but no subscription is registered. + - disabled/help text when permission is `denied`. + +This fits iOS/Safari and Android browser expectations because permission requests happen inside a user gesture. + +## Data Flow + +1. CLI or sync source sends session/message updates to hub. +2. Hub's sync engine emits `session-updated`, `session-added`, `session-removed`, or `message-received`. +3. `NotificationHub` evaluates whether the event requires attention. +4. `NotificationHub` calls a `NotificationChannel` method: + - `sendPermissionRequest(session)` + - `sendReady(session)` + - `sendAttention(session, reason)` +5. `PushNotificationChannel` tries SSE toast when namespace has visible clients. +6. If no toast is delivered, `PushService` sends Web Push to stored subscriptions. +7. `web/src/sw.ts` displays the notification. +8. Notification click focuses or opens `/sessions/`. + +## API and Type Changes + +Extend `NotificationChannel` with a focused attention method: + +```ts +export type AttentionReason = 'failed' | 'interrupted' + +export type NotificationChannel = { + sendReady: (session: Session) => Promise + sendPermissionRequest: (session: Session) => Promise + sendAttention: (session: Session, reason: AttentionReason) => Promise +} +``` + +`PushPayload.data.type` must accept these values: + +- `permission-request` +- `ready` +- `attention` + +The external push subscribe API does not change. + +## Error Handling + +- If a channel throws, log the error and continue with the next channel. +- If a Web Push subscription returns `410`, remove it from storage as today. +- If a Web Push provider fails for another reason, log and continue. +- If notification parsing cannot identify a supported attention event, do nothing. +- If the session is inactive or missing by the time a debounce fires, do nothing. + +## Testing Strategy + +Use TDD for implementation. + +Hub tests: + +- `NotificationHub` sends permission notifications only for new request ids. +- `NotificationHub` throttles ready notifications per session. +- `NotificationHub` sends ready when a session transitions from thinking to not thinking after agent activity. +- `NotificationHub` suppresses transition-ready when a pending permission request exists. +- `NotificationHub` sends attention for supported failure/interruption event types. +- `NotificationHub` applies attention cooldown. +- `PushNotificationChannel` sends SSE toast and skips Web Push when a visible client receives the toast. +- `PushNotificationChannel` sends Web Push when no visible client receives the toast. +- `PushNotificationChannel` formats attention payloads correctly. + +Web tests: + +- `usePushNotifications` reports unsupported browsers. +- `usePushNotifications.subscribe()` posts endpoint and keys. +- Settings page renders permission/subscription state. +- Settings page calls `requestPermission()` only from the explicit enable button. +- `App.tsx` no longer auto-prompts permission on startup, but still auto-subscribes if permission is already granted. + +Service worker changes must remain minimal in this iteration. Verification for notification click/focus behavior is the browser/PWA smoke test listed below. + +## Rollout and Verification + +Implementation verification commands: + +```bash +bun run test:hub +bun run test:web +bun run typecheck:hub +bun run typecheck:web +``` + +Manual smoke test: + +1. Start hub and web locally. +2. Open HAPI Web in a browser and enable notifications from settings. +3. Confirm a push subscription is stored in the hub database. +4. Trigger a permission request and confirm either foreground toast or phone notification. +5. Trigger a ready/stop event and confirm notification cooldown prevents duplicates. +6. Click notification and confirm it opens the relevant session route. + +## Open Decisions Resolved + +- Notification scope is option A: only attention-worthy events. +- First version has no per-session notification preferences. +- Existing Telegram notification behavior remains in place. +- Web Push is the primary phone notification path for PWA. diff --git a/hub/src/notifications/eventParsing.test.ts b/hub/src/notifications/eventParsing.test.ts index 4a7575409..d9cbc47ac 100644 --- a/hub/src/notifications/eventParsing.test.ts +++ b/hub/src/notifications/eventParsing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' import type { SyncEvent } from '../sync/syncEngine' -import { extractMessageEventType } from './eventParsing' +import { extractAttentionReason, extractMessageEventType, isAgentMessageEvent } from './eventParsing' describe('extractMessageEventType', () => { it('returns the event type from a role-wrapped envelope', () => { @@ -75,4 +75,122 @@ describe('extractMessageEventType', () => { expect(extractMessageEventType(event)).toBeNull() }) + + it('returns attention reason for supported failure and interruption event types', () => { + const makeEvent = (type: string): SyncEvent => ({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: `message-${type}`, + seq: 10, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: `event-${type}`, + type: 'event', + data: { type } + } + } + } + }) + + expect(extractAttentionReason(makeEvent('error'))).toBe('failed') + expect(extractAttentionReason(makeEvent('failed'))).toBe('failed') + expect(extractAttentionReason(makeEvent('task-failed'))).toBe('failed') + expect(extractAttentionReason(makeEvent('aborted'))).toBe('interrupted') + expect(extractAttentionReason(makeEvent('interrupted'))).toBe('interrupted') + }) + + it('returns attention reason from Codex terminal payloads wrapped by sendAgentMessage', () => { + const makeCodexTerminalEvent = (type: string): SyncEvent => ({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: `codex-${type}`, + seq: 10, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + type: 'codex', + data: { type } + } + } + } + }) + + expect(extractMessageEventType(makeCodexTerminalEvent('task_failed'))).toBe('task_failed') + expect(extractAttentionReason(makeCodexTerminalEvent('task_failed'))).toBe('failed') + expect(extractAttentionReason(makeCodexTerminalEvent('turn_aborted'))).toBe('interrupted') + }) + + it('returns null attention reason for ready and ordinary message events', () => { + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'message-ready', + seq: 11, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-ready', + type: 'event', + data: { type: 'ready' } + } + } + } + } + + const textEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'message-text', + seq: 12, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { type: 'text', text: 'done' } + } + } + } + + expect(extractAttentionReason(readyEvent)).toBeNull() + expect(extractAttentionReason(textEvent)).toBeNull() + }) + + it('detects agent message events without treating user messages as agent activity', () => { + const agentEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-message', + seq: 13, + localId: null, + createdAt: 0, + content: { role: 'agent', content: { type: 'text', text: 'done' } } + } + } + const userEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'user-message', + seq: 14, + localId: null, + createdAt: 0, + content: { role: 'user', content: { type: 'text', text: 'hello' } } + } + } + + expect(isAgentMessageEvent(agentEvent)).toBe(true) + expect(isAgentMessageEvent(userEvent)).toBe(false) + }) }) diff --git a/hub/src/notifications/eventParsing.ts b/hub/src/notifications/eventParsing.ts index ac615520d..586a62aaf 100644 --- a/hub/src/notifications/eventParsing.ts +++ b/hub/src/notifications/eventParsing.ts @@ -1,5 +1,7 @@ import { isObject } from '@hapi/protocol' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol/modes' import type { SyncEvent } from '../sync/syncEngine' +import type { AttentionReason } from './notificationTypes' type EventEnvelope = { type?: unknown @@ -7,29 +9,46 @@ type EventEnvelope = { } function extractEventEnvelope(message: unknown): EventEnvelope | null { + const directEnvelope = extractContentEnvelope(message) + if (directEnvelope) { + return directEnvelope + } + if (!isObject(message)) { return null } - if (message.type === 'event') { - return message as EventEnvelope - } + return extractContentEnvelope(message.content) +} - const content = message.content - if (!isObject(content) || content.type !== 'event') { +function extractContentEnvelope(content: unknown): EventEnvelope | null { + if (!isObject(content)) { return null } - return content as EventEnvelope + if (content.type === 'event') { + return content as EventEnvelope + } + + if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE || content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (data && typeof data.type === 'string') { + return { type: 'event', data } + } + } + + return null } -export function extractMessageEventType(event: SyncEvent): string | null { +function extractMessageContent(event: SyncEvent): unknown { if (event.type !== 'message-received') { return null } + return event.message?.content +} - const message = event.message?.content - const envelope = extractEventEnvelope(message) +export function extractMessageEventType(event: SyncEvent): string | null { + const envelope = extractEventEnvelope(extractMessageContent(event)) if (!envelope) { return null } @@ -38,3 +57,28 @@ export function extractMessageEventType(event: SyncEvent): string | null { const eventType = data?.type return typeof eventType === 'string' ? eventType : null } + +export function extractAttentionReason(event: SyncEvent): AttentionReason | null { + const eventType = extractMessageEventType(event) + if ( + eventType === 'error' + || eventType === 'failed' + || eventType === 'task-failed' + || eventType === 'task_failed' + ) { + return 'failed' + } + if (eventType === 'aborted' || eventType === 'interrupted' || eventType === 'turn_aborted') { + return 'interrupted' + } + return null +} + +export function isAgentMessageEvent(event: SyncEvent): boolean { + const content = extractMessageContent(event) + if (!isObject(content)) { + return false + } + + return content.role === 'agent' +} diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index 0cfaa7c4f..c6370d639 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test' import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine' -import type { NotificationChannel } from './notificationTypes' +import type { AttentionReason, NotificationChannel } from './notificationTypes' import { NotificationHub } from './notificationHub' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -32,6 +32,7 @@ class FakeSyncEngine { class StubChannel implements NotificationChannel { readonly readySessions: Session[] = [] readonly permissionSessions: Session[] = [] + readonly attentionNotifications: Array<{ session: Session; reason: 'failed' | 'interrupted' }> = [] async sendReady(session: Session): Promise { this.readySessions.push(session) @@ -40,6 +41,10 @@ class StubChannel implements NotificationChannel { async sendPermissionRequest(session: Session): Promise { this.permissionSessions.push(session) } + + async sendAttention(session: Session, reason: 'failed' | 'interrupted'): Promise { + this.attentionNotifications.push({ session, reason }) + } } function createSession(overrides: Partial = {}): Session { @@ -156,4 +161,262 @@ describe('NotificationHub', () => { hub.stop() }) + + it('sends ready when thinking stops after agent activity', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 5 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-text', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'text', text: 'done' } } + } + }) + + engine.setSession(createSession({ thinking: false, thinkingAt: 3 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + await sleep(10) + + expect(channel.readySessions).toHaveLength(1) + hub.stop() + }) + + it('does not send transition-ready when a permission request is pending', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 5, + readyCooldownMs: 5 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-text', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'text', text: 'needs approval' } } + } + }) + engine.setSession(createSession({ + thinking: false, + thinkingAt: 3, + agentState: { + requests: { + req1: { tool: 'Edit', arguments: {}, createdAt: 3 } + } + } + })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + await sleep(20) + + expect(channel.readySessions).toHaveLength(0) + expect(channel.permissionSessions).toHaveLength(1) + hub.stop() + }) + + it('sends attention notification for failure and interruption events with cooldown', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 1, + attentionCooldownMs: 20 + }) + engine.setSession(createSession()) + + const failedEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'failed-1', + seq: 4, + localId: null, + createdAt: 4, + content: { role: 'agent', content: { type: 'event', data: { type: 'failed' } } } + } + } + + engine.emit(failedEvent) + await sleep(5) + engine.emit(failedEvent) + await sleep(5) + + expect(channel.attentionNotifications).toHaveLength(1) + expect(channel.attentionNotifications[0]?.reason).toBe('failed') + + await sleep(25) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'aborted-1', + seq: 5, + localId: null, + createdAt: 5, + content: { role: 'agent', content: { type: 'event', data: { type: 'aborted' } } } + } + }) + await sleep(5) + + expect(channel.attentionNotifications).toHaveLength(2) + expect(channel.attentionNotifications[1]?.reason).toBe('interrupted') + hub.stop() + }) + + it('does not treat pre-thinking agent activity as current thinking-cycle activity', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 0 + }) + + engine.setSession(createSession({ thinking: false })) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'agent-text', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'text', text: 'stale' } } + } + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 3 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + + engine.setSession(createSession({ thinking: false, thinkingAt: 4 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + + expect(channel.readySessions).toHaveLength(0) + hub.stop() + }) + + it('does not add extra transition-ready after an explicit ready event while thinking', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 0 + }) + + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'ready-event', + seq: 1, + localId: null, + createdAt: 0, + content: { role: 'agent', content: { type: 'event', data: { type: 'ready' } } } + } + } + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit(readyEvent) + + engine.setSession(createSession({ thinking: false, thinkingAt: 2 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + + expect(channel.readySessions).toHaveLength(1) + hub.stop() + }) + + it('does not schedule transition-ready after attention events for the same run', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 0 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'failed-event', + seq: 2, + localId: null, + createdAt: 2, + content: { role: 'agent', content: { type: 'event', data: { type: 'failed' } } } + } + }) + + engine.setSession(createSession({ thinking: false, thinkingAt: 3 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + + expect(channel.attentionNotifications).toHaveLength(1) + expect(channel.attentionNotifications[0]?.reason).toBe('failed') + expect(channel.readySessions).toHaveLength(0) + hub.stop() + }) + + it('does not send ready immediately after an attention event for the same session', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 0, + attentionCooldownMs: 20 + }) + + engine.setSession(createSession({ thinking: true, thinkingAt: 1 })) + engine.emit({ type: 'session-updated', sessionId: 'session-1' }) + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'codex-task-failed', + seq: 2, + localId: null, + createdAt: 2, + content: { + role: 'agent', + content: { + type: 'codex', + data: { type: 'task_failed', error: 'boom' } + } + } + } + }) + await sleep(5) + + engine.emit({ + type: 'message-received', + sessionId: 'session-1', + message: { + id: 'ready-event', + seq: 3, + localId: null, + createdAt: 3, + content: { role: 'agent', content: { type: 'event', data: { type: 'ready' } } } + } + }) + await sleep(5) + + expect(channel.attentionNotifications).toHaveLength(1) + expect(channel.readySessions).toHaveLength(0) + hub.stop() + }) }) diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index b4a3d16ee..a15447f69 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,14 +1,18 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' -import type { NotificationChannel, NotificationHubOptions } from './notificationTypes' -import { extractMessageEventType } from './eventParsing' +import type { AttentionReason, NotificationChannel, NotificationHubOptions } from './notificationTypes' +import { extractAttentionReason, extractMessageEventType, isAgentMessageEvent } from './eventParsing' export class NotificationHub { private readonly channels: NotificationChannel[] private readonly readyCooldownMs: number private readonly permissionDebounceMs: number + private readonly attentionCooldownMs: number private readonly lastKnownRequests: Map> = new Map() private readonly notificationDebounce: Map = new Map() private readonly lastReadyNotificationAt: Map = new Map() + private readonly lastAttentionNotificationAt: Map = new Map() + private readonly lastThinkingBySession: Map = new Map() + private readonly agentActivityBySession: Map = new Map() private unsubscribeSyncEvents: (() => void) | null = null constructor( @@ -19,6 +23,7 @@ export class NotificationHub { this.channels = channels this.readyCooldownMs = options?.readyCooldownMs ?? 5000 this.permissionDebounceMs = options?.permissionDebounceMs ?? 500 + this.attentionCooldownMs = options?.attentionCooldownMs ?? 5000 this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => { this.handleSyncEvent(event) }) @@ -36,6 +41,9 @@ export class NotificationHub { this.notificationDebounce.clear() this.lastKnownRequests.clear() this.lastReadyNotificationAt.clear() + this.lastAttentionNotificationAt.clear() + this.lastThinkingBySession.clear() + this.agentActivityBySession.clear() } private handleSyncEvent(event: SyncEvent): void { @@ -45,7 +53,15 @@ export class NotificationHub { this.clearSessionState(event.sessionId) return } + + const wasThinking = this.lastThinkingBySession.get(session.id) + if (wasThinking !== true && session.thinking) { + this.agentActivityBySession.delete(session.id) + } + this.checkForPermissionNotification(session) + this.checkForThinkingStoppedNotification(session) + this.lastThinkingBySession.set(session.id, session.thinking) return } @@ -55,8 +71,27 @@ export class NotificationHub { } if (event.type === 'message-received' && event.sessionId) { + if (isAgentMessageEvent(event)) { + const session = this.syncEngine.getSession(event.sessionId) + if (session?.active && session.thinking) { + this.agentActivityBySession.set(event.sessionId, true) + } else { + this.agentActivityBySession.delete(event.sessionId) + } + } + const eventType = extractMessageEventType(event) + const attentionReason = extractAttentionReason(event) + if (attentionReason) { + this.agentActivityBySession.delete(event.sessionId) + this.sendAttentionNotification(event.sessionId, attentionReason).catch((error) => { + console.error('[NotificationHub] Failed to send attention notification:', error) + }) + return + } + if (eventType === 'ready') { + this.agentActivityBySession.delete(event.sessionId) this.sendReadyNotification(event.sessionId).catch((error) => { console.error('[NotificationHub] Failed to send ready notification:', error) }) @@ -72,6 +107,9 @@ export class NotificationHub { } this.lastKnownRequests.delete(sessionId) this.lastReadyNotificationAt.delete(sessionId) + this.lastAttentionNotificationAt.delete(sessionId) + this.lastThinkingBySession.delete(sessionId) + this.agentActivityBySession.delete(sessionId) } private getNotifiableSession(sessionId: string): Session | null { @@ -130,6 +168,45 @@ export class NotificationHub { await this.notifyPermission(session) } + private hasPendingPermissionRequest(session: Session): boolean { + const requests = session.agentState?.requests + return Boolean(requests && Object.keys(requests).length > 0) + } + + private checkForThinkingStoppedNotification(session: Session): void { + const wasThinking = this.lastThinkingBySession.get(session.id) + if (wasThinking !== true || session.thinking) { + return + } + if (!this.agentActivityBySession.get(session.id)) { + return + } + this.agentActivityBySession.delete(session.id) + if (this.hasPendingPermissionRequest(session)) { + return + } + + this.sendReadyNotification(session.id).catch((error) => { + console.error('[NotificationHub] Failed to send ready notification:', error) + }) + } + + private async sendAttentionNotification(sessionId: string, reason: AttentionReason): Promise { + const session = this.getNotifiableSession(sessionId) + if (!session) { + return + } + + const now = Date.now() + const last = this.lastAttentionNotificationAt.get(sessionId) ?? 0 + if (now - last < this.attentionCooldownMs) { + return + } + this.lastAttentionNotificationAt.set(sessionId, now) + + await this.notifyAttention(session, reason) + } + private async sendReadyNotification(sessionId: string): Promise { const session = this.getNotifiableSession(sessionId) if (!session) { @@ -137,6 +214,11 @@ export class NotificationHub { } const now = Date.now() + const lastAttention = this.lastAttentionNotificationAt.get(sessionId) ?? 0 + if (now - lastAttention < this.attentionCooldownMs) { + return + } + const last = this.lastReadyNotificationAt.get(sessionId) ?? 0 if (now - last < this.readyCooldownMs) { return @@ -165,4 +247,14 @@ export class NotificationHub { } } } + + private async notifyAttention(session: Session, reason: AttentionReason): Promise { + for (const channel of this.channels) { + try { + await channel.sendAttention(session, reason) + } catch (error) { + console.error('[NotificationHub] Failed to send attention notification:', error) + } + } + } } diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts index 3e3ba2895..e6d0de3e3 100644 --- a/hub/src/notifications/notificationTypes.ts +++ b/hub/src/notifications/notificationTypes.ts @@ -1,11 +1,15 @@ import type { Session } from '../sync/syncEngine' +export type AttentionReason = 'failed' | 'interrupted' + export type NotificationChannel = { sendReady: (session: Session) => Promise sendPermissionRequest: (session: Session) => Promise + sendAttention: (session: Session, reason: AttentionReason) => Promise } export type NotificationHubOptions = { readyCooldownMs?: number permissionDebounceMs?: number + attentionCooldownMs?: number } diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts new file mode 100644 index 000000000..ab20f005c --- /dev/null +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'bun:test' +import { PushNotificationChannel } from './pushNotificationChannel' +import type { PushPayload, PushService } from './pushService' +import type { Session } from '../sync/syncEngine' +import type { SSEManager } from '../sse/sseManager' +import type { VisibilityTracker } from '../visibility/visibilityTracker' + +type ToastEvent = { + type: 'toast' + data: { + title: string + body: string + sessionId: string + url: string + } +} + +class FakePushService { + readonly sent: Array<{ namespace: string; payload: PushPayload }> = [] + + async sendToNamespace(namespace: string, payload: PushPayload): Promise { + this.sent.push({ namespace, payload }) + } +} + +class FakeSSEManager { + readonly toasts: Array<{ namespace: string; event: ToastEvent }> = [] + delivered = 0 + + async sendToast(namespace: string, event: ToastEvent): Promise { + this.toasts.push({ namespace, event }) + return this.delivered + } +} + +class FakeVisibilityTracker { + visible = false + + hasVisibleConnection(_namespace: string): boolean { + return this.visible + } +} + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: { path: '/repo', host: 'mac', summary: { text: 'Build UI', updatedAt: 1 } }, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + ...overrides + } +} + +function createChannel() { + const push = new FakePushService() + const sse = new FakeSSEManager() + const visibility = new FakeVisibilityTracker() + const channel = new PushNotificationChannel( + push as unknown as PushService, + sse as unknown as SSEManager, + visibility as unknown as VisibilityTracker, + '' + ) + return { channel, push, sse, visibility } +} + +describe('PushNotificationChannel', () => { + it('sends foreground toast and skips Web Push when visible toast is delivered', async () => { + const { channel, push, sse, visibility } = createChannel() + visibility.visible = true + sse.delivered = 1 + + await channel.sendReady(createSession()) + + expect(sse.toasts).toHaveLength(1) + expect(sse.toasts[0]?.event.data.title).toBe('Ready for input') + expect(push.sent).toHaveLength(0) + }) + + it('falls back to Web Push when there is no visible delivered toast', async () => { + const { channel, push, sse, visibility } = createChannel() + visibility.visible = true + sse.delivered = 0 + + await channel.sendReady(createSession()) + + expect(sse.toasts).toHaveLength(1) + expect(push.sent).toHaveLength(1) + expect(push.sent[0]?.payload.data?.type).toBe('ready') + }) + + it('formats attention notification payloads', async () => { + const { channel, push } = createChannel() + + await channel.sendAttention(createSession(), 'failed') + + expect(push.sent).toHaveLength(1) + expect(push.sent[0]?.payload).toEqual({ + title: 'Task needs attention', + body: 'Build UI stopped or failed', + tag: 'attention-session-1', + data: { + type: 'attention', + sessionId: 'session-1', + url: '/sessions/session-1' + } + }) + }) +}) diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index 76e73d24a..e8cc65951 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -1,5 +1,5 @@ import type { Session } from '../sync/syncEngine' -import type { NotificationChannel } from '../notifications/notificationTypes' +import type { AttentionReason, NotificationChannel } from '../notifications/notificationTypes' import { getAgentName, getSessionName } from '../notifications/sessionInfo' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' @@ -35,23 +35,7 @@ export class PushNotificationChannel implements NotificationChannel { } } - const url = payload.data?.url ?? this.buildSessionPath(session.id) - if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { - const delivered = await this.sseManager.sendToast(session.namespace, { - type: 'toast', - data: { - title: payload.title, - body: payload.body, - sessionId: session.id, - url - } - }) - if (delivered > 0) { - return - } - } - - await this.pushService.sendToNamespace(session.namespace, payload) + await this.deliver(session, payload) } async sendReady(session: Session): Promise { @@ -73,6 +57,30 @@ export class PushNotificationChannel implements NotificationChannel { } } + await this.deliver(session, payload) + } + + async sendAttention(session: Session, _reason: AttentionReason): Promise { + if (!session.active) { + return + } + + const name = getSessionName(session) + const payload: PushPayload = { + title: 'Task needs attention', + body: `${name} stopped or failed`, + tag: `attention-${session.id}`, + data: { + type: 'attention', + sessionId: session.id, + url: this.buildSessionPath(session.id) + } + } + + await this.deliver(session, payload) + } + + private async deliver(session: Session, payload: PushPayload): Promise { const url = payload.data?.url ?? this.buildSessionPath(session.id) if (this.visibilityTracker.hasVisibleConnection(session.namespace)) { const delivered = await this.sseManager.sendToast(session.namespace, { diff --git a/hub/src/push/pushService.ts b/hub/src/push/pushService.ts index 3a02d1d92..1b34d626d 100644 --- a/hub/src/push/pushService.ts +++ b/hub/src/push/pushService.ts @@ -7,7 +7,7 @@ export type PushPayload = { body: string tag?: string data?: { - type: string + type: 'permission-request' | 'ready' | 'attention' sessionId: string url: string } diff --git a/hub/src/telegram/bot.ts b/hub/src/telegram/bot.ts index dc70121de..e8513b71e 100644 --- a/hub/src/telegram/bot.ts +++ b/hub/src/telegram/bot.ts @@ -10,7 +10,7 @@ import { SyncEngine, Session } from '../sync/syncEngine' import { handleCallback, CallbackContext } from './callbacks' import { formatSessionNotification, createNotificationKeyboard } from './sessionView' import { getAgentName } from '../notifications/sessionInfo' -import type { NotificationChannel } from '../notifications/notificationTypes' +import type { AttentionReason, NotificationChannel } from '../notifications/notificationTypes' import type { Store } from '../store' export interface BotContext extends Context { @@ -241,6 +241,12 @@ export class HappyBot implements NotificationChannel { } } } + + async sendAttention(_session: Session, _reason: AttentionReason): Promise { + // Mobile attention notifications are delivered via web push; Telegram has no behavior here. + void _session + void _reason + } } function buildMiniAppDeepLink(baseUrl: string, startParam: string): string { diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ab0798c2..5a3ca1adf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' +import { useAutoPushSubscription } from '@/hooks/useAutoPushSubscription' import { useViewportHeight } from '@/hooks/useViewportHeight' import { useVisibilityReporter } from '@/hooks/useVisibilityReporter' import { queryKeys } from '@/lib/query-keys' @@ -125,8 +126,7 @@ function AppInner() { const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) - const pushPromptedRef = useRef(false) - const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) + const { isSupported: isPushSupported, permission: pushPermission, subscribe } = usePushNotifications(api) useEffect(() => { if (baseUrlRef.current === baseUrl) { @@ -154,34 +154,14 @@ function AppInner() { router.history.replace(nextHref, state) }, [token, api, router]) - useEffect(() => { - if (!api || !token) { - pushPromptedRef.current = false - return - } - if (isTelegramApp() || !isPushSupported) { - return - } - if (pushPromptedRef.current) { - return - } - pushPromptedRef.current = true - - const run = async () => { - if (pushPermission === 'granted') { - await subscribe() - return - } - if (pushPermission === 'default') { - const granted = await requestPermission() - if (granted) { - await subscribe() - } - } - } - - void run() - }, [api, isPushSupported, pushPermission, requestPermission, subscribe, token]) + useAutoPushSubscription({ + api, + token, + isTelegram: isTelegramApp(), + isSupported: isPushSupported, + permission: pushPermission, + subscribe + }) const handleSseConnect = useCallback(() => { // Clear disconnected state on successful connection diff --git a/web/src/hooks/useAutoPushSubscription.test.tsx b/web/src/hooks/useAutoPushSubscription.test.tsx new file mode 100644 index 000000000..b2937e398 --- /dev/null +++ b/web/src/hooks/useAutoPushSubscription.test.tsx @@ -0,0 +1,140 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useAutoPushSubscription } from './useAutoPushSubscription' + +describe('useAutoPushSubscription', () => { + it('subscribes automatically only when permission is already granted', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: false, + isSupported: true, + permission: 'granted', + subscribe + })) + + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + }) + + it('does not request or subscribe when permission is default', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: false, + isSupported: true, + permission: 'default', + subscribe + })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(subscribe).not.toHaveBeenCalled() + }) + + it('does not subscribe inside Telegram', async () => { + const subscribe = vi.fn(async () => true) + + renderHook(() => useAutoPushSubscription({ + api: {} as never, + token: 'token', + isTelegram: true, + isSupported: true, + permission: 'granted', + subscribe + })) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(subscribe).not.toHaveBeenCalled() + }) + + it('retries when auth context changes', async () => { + const subscribe = vi.fn(async () => true) + const firstApi = {} as never + const secondApi = {} as never + + const { rerender } = renderHook( + ({ api, token }) => useAutoPushSubscription({ + api, + token, + isTelegram: false, + isSupported: true, + permission: 'granted', + subscribe + }), + { + initialProps: { + api: firstApi, + token: 'token-1' + } + } + ) + + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + + rerender({ api: secondApi, token: 'token-2' }) + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)) + }) + + it('retries when subscribe resolves false and auth context changes', async () => { + const subscribe = vi.fn(async () => false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const firstApi = {} as never + const secondApi = {} as never + + const { rerender } = renderHook( + ({ api, token }) => useAutoPushSubscription({ + api, + token, + isTelegram: false, + isSupported: true, + permission: 'granted', + subscribe + }), + { + initialProps: { + api: firstApi, + token: 'token-1' + } + } + ) + + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + + rerender({ api: secondApi, token: 'token-2' }) + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)) + }) + + it('retries when subscribe rejects and auth context changes', async () => { + const subscribe = vi.fn() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(true) + const firstApi = {} as never + const secondApi = {} as never + + const { rerender } = renderHook( + ({ api, token }) => useAutoPushSubscription({ + api, + token, + isTelegram: false, + isSupported: true, + permission: 'granted', + subscribe + }), + { + initialProps: { + api: firstApi, + token: 'token-1' + } + } + ) + + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)) + + rerender({ api: secondApi, token: 'token-2' }) + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)) + }) +}) diff --git a/web/src/hooks/useAutoPushSubscription.ts b/web/src/hooks/useAutoPushSubscription.ts new file mode 100644 index 000000000..ae6dc0fc1 --- /dev/null +++ b/web/src/hooks/useAutoPushSubscription.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef } from 'react' +import type { ApiClient } from '@/api/client' + +export type AutoPushSubscriptionOptions = { + api: ApiClient | null + token: string | null + isTelegram: boolean + isSupported: boolean + permission: NotificationPermission + subscribe: () => Promise +} + +type AutoPushAttemptContext = { + api: ApiClient + token: string +} + +function isSameAuthContext( + a: AutoPushAttemptContext | null, + b: AutoPushAttemptContext +): boolean { + return a !== null && a.api === b.api && a.token === b.token +} + +export function useAutoPushSubscription(options: AutoPushSubscriptionOptions): void { + const attemptedContextRef = useRef(null) + + useEffect(() => { + if (!options.api || !options.token) { + attemptedContextRef.current = null + return + } + + if (options.isTelegram || !options.isSupported || options.permission !== 'granted') { + return + } + + const authContext: AutoPushAttemptContext = { + api: options.api, + token: options.token + } + + if (isSameAuthContext(attemptedContextRef.current, authContext)) { + return + } + + attemptedContextRef.current = authContext + + void options.subscribe() + .then((success) => { + if (!success && isSameAuthContext(attemptedContextRef.current, authContext)) { + attemptedContextRef.current = null + } + }) + .catch(() => { + if (isSameAuthContext(attemptedContextRef.current, authContext)) { + attemptedContextRef.current = null + } + }) + }, [ + options.api, + options.isSupported, + options.isTelegram, + options.permission, + options.subscribe, + options.token + ]) +} diff --git a/web/src/hooks/usePushNotifications.test.tsx b/web/src/hooks/usePushNotifications.test.tsx new file mode 100644 index 000000000..45ffbad0f --- /dev/null +++ b/web/src/hooks/usePushNotifications.test.tsx @@ -0,0 +1,112 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import { usePushNotifications } from './usePushNotifications' + +function installUnsupportedPushGlobals() { + Reflect.deleteProperty(window.navigator, 'serviceWorker') + Reflect.deleteProperty(window, 'PushManager') + Reflect.deleteProperty(window, 'Notification') +} + +function installSupportedPushGlobals(options?: { permission?: NotificationPermission }) { + const permission = options?.permission ?? 'granted' + const subscriptionJson = { + endpoint: 'https://push.example/subscription', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' } + } + const subscription = { + endpoint: subscriptionJson.endpoint, + toJSON: () => subscriptionJson, + unsubscribe: vi.fn(async () => true) + } + const pushManager = { + getSubscription: vi.fn(async () => null), + subscribe: vi.fn(async () => subscription) + } + const ready = Promise.resolve({ pushManager }) + + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: { ready } + }) + Object.defineProperty(window, 'PushManager', { + configurable: true, + value: function PushManager() {} + }) + Object.defineProperty(window, 'Notification', { + configurable: true, + value: { + permission, + requestPermission: vi.fn(async () => permission) + } + }) + + return { pushManager, subscription } +} + +function createApi(): ApiClient & { + subscribed: unknown[] +} { + const subscribed: unknown[] = [] + return { + subscribed, + getPushVapidPublicKey: vi.fn(async () => ({ publicKey: 'AQAB' })), + subscribePushNotifications: vi.fn(async (payload: unknown) => { + subscribed.push(payload) + }), + unsubscribePushNotifications: vi.fn(async () => {}) + } as unknown as ApiClient & { subscribed: unknown[] } +} + +describe('usePushNotifications', () => { + beforeEach(() => { + vi.restoreAllMocks() + installUnsupportedPushGlobals() + }) + + it('reports unsupported browsers and exposes refreshSubscription', async () => { + const { result } = renderHook(() => usePushNotifications(null)) + + await waitFor(() => { + expect(result.current.isSupported).toBe(false) + expect(result.current.isSubscribed).toBe(false) + }) + expect(typeof result.current.refreshSubscription).toBe('function') + }) + + it('subscribes and posts endpoint keys when permission is granted', async () => { + const { pushManager } = installSupportedPushGlobals({ permission: 'granted' }) + const api = createApi() + const { result } = renderHook(() => usePushNotifications(api)) + + await act(async () => { + const ok = await result.current.subscribe() + expect(ok).toBe(true) + }) + + expect(pushManager.subscribe).toHaveBeenCalledWith(expect.objectContaining({ userVisibleOnly: true })) + expect(api.subscribePushNotifications).toHaveBeenCalledWith({ + endpoint: 'https://push.example/subscription', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' } + }) + expect(result.current.isSubscribed).toBe(true) + }) + + it('unsubscribes the browser subscription when hub registration fails', async () => { + const { subscription } = installSupportedPushGlobals({ permission: 'granted' }) + const api = createApi() + vi.mocked(api.subscribePushNotifications).mockRejectedValueOnce(new Error('hub down')) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { result } = renderHook(() => usePushNotifications(api)) + + await act(async () => { + const ok = await result.current.subscribe() + expect(ok).toBe(false) + }) + + expect(consoleError).toHaveBeenCalledWith('[PushNotifications] Failed to subscribe:', expect.any(Error)) + expect(subscription.unsubscribe).toHaveBeenCalledTimes(1) + expect(result.current.isSubscribed).toBe(false) + }) +}) diff --git a/web/src/hooks/usePushNotifications.ts b/web/src/hooks/usePushNotifications.ts index 786ecf4b0..38c7cc073 100644 --- a/web/src/hooks/usePushNotifications.ts +++ b/web/src/hooks/usePushNotifications.ts @@ -73,19 +73,27 @@ export function usePushNotifications(api: ApiClient | null) { return false } + let activeSubscription: PushSubscription | null = null try { const registration = await navigator.serviceWorker.ready const existing = await registration.pushManager.getSubscription() const { publicKey } = await api.getPushVapidPublicKey() const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer + let createdSubscription = false const subscription = existing ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) + createdSubscription = !existing + activeSubscription = subscription const json = subscription.toJSON() const keys = json.keys if (!json.endpoint || !keys?.p256dh || !keys.auth) { + if (createdSubscription) { + await subscription.unsubscribe() + } + setIsSubscribed(false) return false } @@ -100,6 +108,18 @@ export function usePushNotifications(api: ApiClient | null) { return true } catch (error) { console.error('[PushNotifications] Failed to subscribe:', error) + try { + if (activeSubscription) { + await activeSubscription.unsubscribe() + } else { + const registration = await navigator.serviceWorker.ready + const subscription = await registration.pushManager.getSubscription() + await subscription?.unsubscribe() + } + } catch (cleanupError) { + console.error('[PushNotifications] Failed to clean up local subscription:', cleanupError) + } + setIsSubscribed(false) return false } }, [api]) @@ -132,6 +152,7 @@ export function usePushNotifications(api: ApiClient | null) { isSupported, permission, isSubscribed, + refreshSubscription, requestPermission, subscribe, unsubscribe diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 77b5e1fe0..d5c444705 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -264,6 +264,17 @@ export default { 'settings.display.appearance.light': 'Light', 'settings.display.fontSize': 'Font Size', 'settings.display.terminalFontSize': 'Terminal Font Size', + 'settings.notifications.title': 'Notifications', + 'settings.notifications.status': 'Notification Status', + 'settings.notifications.unsupported': 'Not supported in this browser', + 'settings.notifications.default': 'Not enabled', + 'settings.notifications.grantedSubscribed': 'Enabled', + 'settings.notifications.grantedUnsubscribed': 'Permission granted, not subscribed', + 'settings.notifications.denied': 'Blocked by browser settings', + 'settings.notifications.enable': 'Enable notifications', + 'settings.notifications.resubscribe': 'Resubscribe notifications', + 'settings.notifications.deniedHelp': 'Enable notifications from browser or system settings, then return here.', + 'settings.notifications.unsupportedHelp': 'Use Safari on iOS or a browser with Web Push support.', 'settings.voice.title': 'Voice Assistant', 'settings.voice.language': 'Voice Language', 'settings.voice.autoDetect': 'Auto-detect', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ca698dce3..4e6affa95 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -266,6 +266,17 @@ export default { 'settings.display.appearance.light': '浅色', 'settings.display.fontSize': '字体大小', 'settings.display.terminalFontSize': '终端字体大小', + 'settings.notifications.title': '通知', + 'settings.notifications.status': '通知状态', + 'settings.notifications.unsupported': '当前浏览器不支持', + 'settings.notifications.default': '未启用', + 'settings.notifications.grantedSubscribed': '已启用', + 'settings.notifications.grantedUnsubscribed': '已授权,尚未订阅', + 'settings.notifications.denied': '已被浏览器设置阻止', + 'settings.notifications.enable': '启用通知', + 'settings.notifications.resubscribe': '重新订阅通知', + 'settings.notifications.deniedHelp': '请先在浏览器或系统设置中允许通知,然后回到这里。', + 'settings.notifications.unsupportedHelp': '请在 iOS Safari 或支持 Web Push 的浏览器中使用。', 'settings.voice.title': '语音助手', 'settings.voice.language': '语音语言', 'settings.voice.autoDetect': '自动检测', diff --git a/web/src/lib/notificationClick.test.ts b/web/src/lib/notificationClick.test.ts new file mode 100644 index 000000000..3ace3dc91 --- /dev/null +++ b/web/src/lib/notificationClick.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { focusOrOpenNotificationUrl } from './notificationClick' + +describe('focusOrOpenNotificationUrl', () => { + it('focuses the navigated existing client when navigation succeeds', async () => { + const navigatedClient = { + focus: vi.fn(async () => undefined), + } + const existingClient = { + navigate: vi.fn(async () => navigatedClient), + focus: vi.fn(async () => undefined), + } + const clientsApi = { + matchAll: vi.fn(async () => [existingClient]), + openWindow: vi.fn(async () => null), + } + + await focusOrOpenNotificationUrl(clientsApi, '/sessions/session-1') + + expect(existingClient.navigate).toHaveBeenCalledWith('/sessions/session-1') + expect(navigatedClient.focus).toHaveBeenCalledTimes(1) + expect(existingClient.focus).not.toHaveBeenCalled() + expect(clientsApi.openWindow).not.toHaveBeenCalled() + }) + + it('opens a new window instead of focusing a stale client when navigation fails', async () => { + const existingClient = { + navigate: vi.fn(async () => null), + focus: vi.fn(async () => undefined), + } + const clientsApi = { + matchAll: vi.fn(async () => [existingClient]), + openWindow: vi.fn(async () => null), + } + const logger = { warn: vi.fn() } + + await focusOrOpenNotificationUrl(clientsApi, '/sessions/session-2', logger) + + expect(existingClient.navigate).toHaveBeenCalledWith('/sessions/session-2') + expect(existingClient.focus).not.toHaveBeenCalled() + expect(clientsApi.openWindow).toHaveBeenCalledWith('/sessions/session-2') + }) +}) diff --git a/web/src/lib/notificationClick.ts b/web/src/lib/notificationClick.ts new file mode 100644 index 000000000..e67a60cbb --- /dev/null +++ b/web/src/lib/notificationClick.ts @@ -0,0 +1,53 @@ +export type NotificationWindowClientLike = { + focus?: () => Promise + navigate?: (url: string) => Promise +} + +export type NotificationClientsLike = { + matchAll: (options: { type: 'window'; includeUncontrolled: true }) => Promise + openWindow: (url: string) => Promise +} + +type NotificationClickLogger = Pick + +export async function focusOrOpenNotificationUrl( + clientsApi: NotificationClientsLike, + url: string, + logger: NotificationClickLogger = console +): Promise { + const windowClients = await clientsApi.matchAll({ + type: 'window', + includeUncontrolled: true + }) + + for (const client of windowClients) { + if (!client.navigate || !client.focus) { + continue + } + + let navigatedClient: NotificationWindowClientLike | null = null + try { + navigatedClient = await client.navigate(url) + } catch (error) { + logger.warn('Failed to navigate existing window client from notification click', error) + continue + } + + if (!navigatedClient?.focus) { + continue + } + + try { + await navigatedClient.focus() + return + } catch (error) { + logger.warn('Failed to focus existing window client from notification click', error) + } + } + + try { + await clientsApi.openWindow(url) + } catch (error) { + logger.warn('Failed to open window from notification click', error) + } +} diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index c837cd72c..ec317417a 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { I18nContext, I18nProvider } from '@/lib/i18n-context' import { en } from '@/lib/locales' import { PROTOCOL_VERSION } from '@hapi/protocol' @@ -54,6 +54,29 @@ vi.mock('@/lib/languages', () => ({ getLanguageDisplayName: (lang: { code: string | null; name: string }) => lang.name, })) +const mockRequestPermission = vi.fn(async () => true) +const mockSubscribe = vi.fn(async () => true) +const mockRefreshSubscription = vi.fn(async () => {}) +let mockPushState = { + isSupported: true, + permission: 'default' as NotificationPermission, + isSubscribed: false, +} + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ api: {}, token: 'token', baseUrl: 'http://localhost' }), +})) + +vi.mock('@/hooks/usePushNotifications', () => ({ + usePushNotifications: () => ({ + ...mockPushState, + requestPermission: mockRequestPermission, + subscribe: mockSubscribe, + refreshSubscription: mockRefreshSubscription, + unsubscribe: vi.fn(async () => true), + }), +})) + function renderWithProviders(ui: React.ReactElement) { return render( @@ -76,6 +99,14 @@ function renderWithSpyT(ui: React.ReactElement) { describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks() + mockPushState = { + isSupported: true, + permission: 'default', + isSubscribed: false, + } + mockRequestPermission.mockClear() + mockSubscribe.mockClear() + mockRefreshSubscription.mockClear() // Mock localStorage const localStorageMock = { getItem: vi.fn(() => 'en'), @@ -85,6 +116,10 @@ describe('SettingsPage', () => { Object.defineProperty(window, 'localStorage', { value: localStorageMock }) }) + afterEach(() => { + cleanup() + }) + it('renders the About section', () => { renderWithProviders() expect(screen.getByText('About')).toBeInTheDocument() @@ -140,4 +175,58 @@ describe('SettingsPage', () => { expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1) }) + + it('renders notification settings state and enable button', () => { + renderWithProviders() + + expect(screen.getAllByText('Notifications').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('Not enabled').length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'Enable notifications' })).toBeInTheDocument() + }) + + it('enables notifications only after clicking the explicit button', async () => { + renderWithProviders() + + expect(mockRequestPermission).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Enable notifications' })) + + await waitFor(() => expect(mockRequestPermission).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(mockSubscribe).toHaveBeenCalledTimes(1)) + }) + + it('does not refresh into an enabled state when hub subscription registration fails', async () => { + mockSubscribe.mockResolvedValueOnce(false) + renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: 'Enable notifications' })) + + await waitFor(() => expect(mockSubscribe).toHaveBeenCalledTimes(1)) + expect(mockRefreshSubscription).not.toHaveBeenCalled() + }) + + it('renders resubscribe button when permission is granted but subscription is missing', () => { + mockPushState = { + isSupported: true, + permission: 'granted', + isSubscribed: false, + } + + renderWithProviders() + + expect(screen.getAllByText('Permission granted, not subscribed').length).toBeGreaterThanOrEqual(1) + expect(screen.getByRole('button', { name: 'Resubscribe notifications' })).toBeInTheDocument() + }) + + it('shows help text when notification permission is denied', () => { + mockPushState = { + isSupported: true, + permission: 'denied', + isSubscribed: false, + } + + renderWithProviders() + + expect(screen.getAllByText('Blocked by browser settings').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Enable notifications from browser or system settings, then return here.')).toBeInTheDocument() + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 971f2171f..ebda5d3a6 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -1,10 +1,12 @@ import { useState, useRef, useEffect } from 'react' import { useTranslation, type Locale } from '@/lib/use-translation' +import { useAppContext } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages' import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale' import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize' import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme' +import { usePushNotifications } from '@/hooks/usePushNotifications' import { PROTOCOL_VERSION } from '@hapi/protocol' const locales: { value: Locale; nativeLabel: string }[] = [ @@ -87,6 +89,16 @@ export default function SettingsPage() { const { fontScale, setFontScale } = useFontScale() const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { appearance, setAppearance } = useAppearance() + const { api } = useAppContext() + const { + isSupported: isPushSupported, + permission: pushPermission, + isSubscribed: isPushSubscribed, + requestPermission, + subscribe, + refreshSubscription, + } = usePushNotifications(api) + const [isNotificationBusy, setIsNotificationBusy] = useState(false) // Voice language state - read from localStorage const [voiceLanguage, setVoiceLanguage] = useState(() => { @@ -132,6 +144,39 @@ export default function SettingsPage() { setIsVoiceOpen(false) } + const notificationStatusLabel = (() => { + if (!isPushSupported) return t('settings.notifications.unsupported') + if (pushPermission === 'denied') return t('settings.notifications.denied') + if (pushPermission === 'granted' && isPushSubscribed) return t('settings.notifications.grantedSubscribed') + if (pushPermission === 'granted') return t('settings.notifications.grantedUnsubscribed') + return t('settings.notifications.default') + })() + + const notificationButtonLabel = pushPermission === 'granted' + ? t('settings.notifications.resubscribe') + : t('settings.notifications.enable') + + const canEnableNotifications = isPushSupported + && pushPermission !== 'denied' + && !(pushPermission === 'granted' && isPushSubscribed) + + const handleEnableNotifications = async () => { + if (!canEnableNotifications || isNotificationBusy) return + setIsNotificationBusy(true) + try { + const granted = pushPermission === 'granted' || await requestPermission() + let subscribed = false + if (granted) { + subscribed = await subscribe() + } + if (subscribed) { + await refreshSubscription() + } + } finally { + setIsNotificationBusy(false) + } + } + // Close dropdown when clicking outside useEffect(() => { if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isVoiceOpen) return @@ -399,6 +444,39 @@ export default function SettingsPage() { + {/* Notifications section */} +
+
+ {t('settings.notifications.title')} +
+
+ {t('settings.notifications.status')} + {notificationStatusLabel} +
+ {pushPermission === 'denied' && ( +
+ {t('settings.notifications.deniedHelp')} +
+ )} + {!isPushSupported && ( +
+ {t('settings.notifications.unsupportedHelp')} +
+ )} + {canEnableNotifications && ( +
+ +
+ )} +
+ {/* Voice Assistant section */}
diff --git a/web/src/sw.ts b/web/src/sw.ts index ebe55dc0a..89c8aa758 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -3,6 +3,7 @@ import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' +import { focusOrOpenNotificationUrl, type NotificationClientsLike } from './lib/notificationClick' declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array @@ -119,5 +120,5 @@ self.addEventListener('notificationclick', (event) => { event.notification.close() const data = event.notification.data as { url?: string } | undefined const url = data?.url ?? '/' - event.waitUntil(self.clients.openWindow(url)) + event.waitUntil(focusOrOpenNotificationUrl(self.clients as unknown as NotificationClientsLike, url)) }) diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index a9d0dd31a..9c10ffdea 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,53 @@ import '@testing-library/jest-dom/vitest' + +function createMemoryStorage(): Storage { + const values = new Map() + + return { + get length() { + return values.size + }, + key(index: number) { + return Array.from(values.keys())[index] ?? null + }, + getItem(key: string) { + return values.get(key) ?? null + }, + setItem(key: string, value: string) { + values.set(key, value) + }, + removeItem(key: string) { + values.delete(key) + }, + clear() { + values.clear() + } + } +} + +function getLocalStorage(): Storage | null { + try { + return window.localStorage + } catch { + return null + } +} + +const localStorageCandidate = getLocalStorage() +if ( + !localStorageCandidate + || typeof localStorageCandidate.getItem !== 'function' + || typeof localStorageCandidate.setItem !== 'function' + || typeof localStorageCandidate.removeItem !== 'function' + || typeof localStorageCandidate.clear !== 'function' +) { + const memoryStorage = createMemoryStorage() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: memoryStorage + }) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage + }) +}