From dd1cd24a46ff65b290f9dfc52300c6e9a56bf60b Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 4 Aug 2026 14:25:04 +0800 Subject: [PATCH] feat: per-user notification preference toggles Add per-namespace notification preferences so users can choose which event types trigger push notifications (permission requests, session ready, task notifications, session completion). Adds the notification_preferences table (schema v20), GET/PUT /api/notification-preferences, POST /api/push/test for test pushes, and a Notifications settings page in the web app with a confirm dialog for disabling permission requests. Defaults keep all event types enabled. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude --- hub/src/notifications/notificationHub.test.ts | 154 ++++++++++++++++++ hub/src/notifications/notificationHub.ts | 27 ++- hub/src/startHub.ts | 3 +- hub/src/store/index.ts | 34 +++- hub/src/store/migration-v13.test.ts | 6 +- hub/src/store/migration-v15.test.ts | 6 +- hub/src/store/migration-v18.test.ts | 2 +- hub/src/store/notificationPreferenceStore.ts | 27 +++ hub/src/store/notificationPreferences.test.ts | 50 ++++++ hub/src/store/notificationPreferences.ts | 123 ++++++++++++++ .../routes/notificationPreferences.test.ts | 75 +++++++++ hub/src/web/routes/notificationPreferences.ts | 33 ++++ hub/src/web/routes/push.ts | 28 +++- hub/src/web/server.ts | 8 +- web/src/api/client.ts | 23 +++ web/src/components/settings/SettingsNav.tsx | 1 + web/src/lib/locales/en.ts | 20 +++ web/src/lib/locales/zh-CN.ts | 20 +++ web/src/lib/query-keys.ts | 1 + web/src/router.tsx | 8 + web/src/routes/settings/categories.ts | 1 + .../routes/settings/notifications.test.tsx | 92 +++++++++++ web/src/routes/settings/notifications.tsx | 123 ++++++++++++++ web/src/types/api.ts | 17 ++ 24 files changed, 869 insertions(+), 13 deletions(-) create mode 100644 hub/src/store/notificationPreferenceStore.ts create mode 100644 hub/src/store/notificationPreferences.test.ts create mode 100644 hub/src/store/notificationPreferences.ts create mode 100644 hub/src/web/routes/notificationPreferences.test.ts create mode 100644 hub/src/web/routes/notificationPreferences.ts create mode 100644 web/src/routes/settings/notifications.test.tsx create mode 100644 web/src/routes/settings/notifications.tsx diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index c2844388..e0eea4ad 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' +import type { Store } from '../store' import type { NotificationChannel, TaskNotification } from './notificationTypes' import { NotificationHub } from './notificationHub' @@ -245,4 +246,157 @@ describe('NotificationHub', () => { hub.stop() }) + + it('suppresses all notification types when preferences are disabled', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const store = { + notificationPrefs: { + getPreferenceFlags: () => ({ + permissionRequests: 0, + sessionReady: 0, + taskNotifications: 0, + sessionCompletion: 0 + }) + } + } as unknown as Store + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 5, + readyCooldownMs: 5 + }, store) + + const session = createSession({ + agentState: { + requests: { + req1: { tool: 'Edit', arguments: {}, createdAt: 1 } + } + } + }) + engine.setSession(session) + engine.emit({ type: 'session-updated', sessionId: session.id }) + await sleep(25) + + expect(channel.permissionSessions).toHaveLength(0) + + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: session.id, + message: { + id: 'message-1', + seq: 1, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-1', + type: 'event', + data: { type: 'ready' } + } + } + } + } + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(0) + + const taskEvent: SyncEvent = { + type: 'message-received', + sessionId: session.id, + message: { + id: 'message-task', + seq: 2, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + type: 'output', + data: { + type: 'system', + subtype: 'task_notification', + status: 'completed', + summary: 'Commit T4 finished' + } + } + } + } + } + engine.emit(taskEvent) + await sleep(5) + expect(channel.taskNotifications).toHaveLength(0) + + engine.emit({ + type: 'session-ended', + sessionId: session.id, + reason: 'completed' satisfies SessionEndReason + }) + await sleep(5) + expect(channel.sessionCompletions).toHaveLength(0) + + hub.stop() + }) + + it('filters per event type by namespace preferences', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const store = { + notificationPrefs: { + getPreferenceFlags: () => ({ + permissionRequests: 0, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 0 + }) + } + } as unknown as Store + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 5, + readyCooldownMs: 5 + }, store) + + const session = createSession({ + agentState: { + requests: { + req1: { tool: 'Edit', arguments: {}, createdAt: 1 } + } + } + }) + engine.setSession(session) + engine.emit({ type: 'session-updated', sessionId: session.id }) + await sleep(25) + expect(channel.permissionSessions).toHaveLength(0) + + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: session.id, + message: { + id: 'message-1', + seq: 1, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-1', + type: 'event', + data: { type: 'ready' } + } + } + } + } + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(1) + + engine.emit({ + type: 'session-ended', + sessionId: session.id, + reason: 'completed' satisfies SessionEndReason + }) + await sleep(5) + expect(channel.sessionCompletions).toHaveLength(0) + + hub.stop() + }) }) diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index f9d627e1..fdcfc970 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -1,5 +1,6 @@ import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine' import type { SessionEndReason } from '@hapi/protocol' +import type { Store } from '../store' import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes' import type { NotificationSendContext } from './notificationSendContext' import { extractMessageEventType, extractTaskNotification } from './eventParsing' @@ -16,7 +17,8 @@ export class NotificationHub { constructor( private readonly syncEngine: SyncEngine, channels: NotificationChannel[], - options?: NotificationHubOptions + options?: NotificationHubOptions, + private readonly store?: Store ) { this.channels = channels this.readyCooldownMs = options?.readyCooldownMs ?? 5000 @@ -182,7 +184,21 @@ export class NotificationHub { await this.notifySessionCompletion(session, reason) } + /** + * Per-namespace preference gate. Without a store (or a missing row) every + * event type is enabled, preserving the pre-preferences behavior. + */ + private isEventEnabled(session: Session, flag: 'permissionRequests' | 'sessionReady' | 'taskNotifications' | 'sessionCompletion'): boolean { + if (!this.store) { + return true + } + return Boolean(this.store.notificationPrefs.getPreferenceFlags(session.namespace)[flag]) + } + private async notifyReady(session: Session): Promise { + if (!this.isEventEnabled(session, 'sessionReady')) { + return + } const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { @@ -194,6 +210,9 @@ export class NotificationHub { } private async notifyPermission(session: Session): Promise { + if (!this.isEventEnabled(session, 'permissionRequests')) { + return + } const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { @@ -205,6 +224,9 @@ export class NotificationHub { } private async notifyTask(session: Session, notification: TaskNotification): Promise { + if (!this.isEventEnabled(session, 'taskNotifications')) { + return + } const ctx: NotificationSendContext = { nativeGate: { sent: false } } for (const channel of this.channels) { try { @@ -216,6 +238,9 @@ export class NotificationHub { } private async notifySessionCompletion(session: Session, reason: SessionEndReason): Promise { + if (!this.isEventEnabled(session, 'sessionCompletion')) { + return + } for (const channel of this.channels) { if (typeof channel.sendSessionCompletion !== 'function') { continue diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 4dd29fc1..bc67c5f8 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -246,7 +246,7 @@ export async function startHub(options: StartHubOptions = {}): Promise this.migrateFromV16ToV17(), 17: () => this.migrateFromV17ToV18(), 18: () => this.migrateFromV18ToV19(), + 19: () => this.migrateFromV19ToV20(), }) if (currentVersion === 0) { @@ -478,6 +484,15 @@ export class Store { last_seq INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS notification_preferences ( + namespace TEXT PRIMARY KEY, + permission_requests INTEGER NOT NULL DEFAULT 1, + session_ready INTEGER NOT NULL DEFAULT 1, + task_notifications INTEGER NOT NULL DEFAULT 1, + session_completion INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); `) } @@ -809,6 +824,21 @@ export class Store { `) } + private migrateFromV19ToV20(): void { + // Per-namespace notification preferences. Defaults are all-enabled so + // existing namespaces keep the pre-preferences behavior. + this.db.exec(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + namespace TEXT PRIMARY KEY, + permission_requests INTEGER NOT NULL DEFAULT 1, + session_ready INTEGER NOT NULL DEFAULT 1, + task_notifications INTEGER NOT NULL DEFAULT 1, + session_completion INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL + ); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v13.test.ts b/hub/src/store/migration-v13.test.ts index d53af0aa..4f128d09 100644 --- a/hub/src/store/migration-v13.test.ts +++ b/hub/src/store/migration-v13.test.ts @@ -10,7 +10,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { const store = new Store(':memory:') expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(19) + expect(getUserVersion(store)).toBe(20) store.close() }) @@ -34,7 +34,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(19) + expect(getUserVersion(store)).toBe(20) expect(store.messages.getMessageEpoch('session-1')).toBe(0) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { @@ -72,7 +72,7 @@ describe('Store V12/V13→V14 schema reconciliation', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) expect(tableExists(store, 'session_scratchlist')).toBe(true) - expect(getUserVersion(store)).toBe(19) + expect(getUserVersion(store)).toBe(20) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { store?.close() diff --git a/hub/src/store/migration-v15.test.ts b/hub/src/store/migration-v15.test.ts index de05ed27..02607736 100644 --- a/hub/src/store/migration-v15.test.ts +++ b/hub/src/store/migration-v15.test.ts @@ -19,7 +19,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(19) + expect(getUserVersion(store)).toBe(20) store.close() }) @@ -40,7 +40,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { expect(cols).toContain('attachments') expect(getColumns(store, 'usage_events')).toContain('last_input_tokens') expect(getColumns(store, 'usage_scan_state')).toContain('last_seq') - expect(getUserVersion(store)).toBe(19) + expect(getUserVersion(store)).toBe(20) } finally { store?.close() rmSync(dir, { recursive: true, force: true }) @@ -60,7 +60,7 @@ describe('Store V14→V15 migration: scratchlist attachments column', () => { store2 = new Store(dbPath) const cols2 = getColumns(store2, 'session_scratchlist') expect(cols2).toEqual(cols1) - expect(getUserVersion(store2)).toBe(19) + expect(getUserVersion(store2)).toBe(20) } finally { store2?.close() store1?.close() diff --git a/hub/src/store/migration-v18.test.ts b/hub/src/store/migration-v18.test.ts index 473e8bf8..7a52f8c5 100644 --- a/hub/src/store/migration-v18.test.ts +++ b/hub/src/store/migration-v18.test.ts @@ -38,7 +38,7 @@ describe('Store V18->V19 migration: usage scan state', () => { const usageRows = internalDb.prepare('SELECT COUNT(*) AS count FROM usage_events').get() as { count: number } expect(table?.name).toBe('usage_scan_state') - expect(version.user_version).toBe(19) + expect(version.user_version).toBe(20) expect(usageRows.count).toBe(0) } finally { store?.close() diff --git a/hub/src/store/notificationPreferenceStore.ts b/hub/src/store/notificationPreferenceStore.ts new file mode 100644 index 00000000..9186e1f5 --- /dev/null +++ b/hub/src/store/notificationPreferenceStore.ts @@ -0,0 +1,27 @@ +import type { Database } from 'bun:sqlite' + +import type { NotificationPreferenceFlags, NotificationPreferences } from './notificationPreferences' +import { getPreferenceFlags, getPreferences, setPreferences } from './notificationPreferences' + +export class NotificationPreferenceStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + getPreferences(namespace: string): NotificationPreferences { + return getPreferences(this.db, namespace) + } + + getPreferenceFlags(namespace: string): NotificationPreferenceFlags { + return getPreferenceFlags(this.db, namespace) + } + + setPreferences( + namespace: string, + partial: Partial + ): NotificationPreferences { + return setPreferences(this.db, namespace, partial) + } +} diff --git a/hub/src/store/notificationPreferences.test.ts b/hub/src/store/notificationPreferences.test.ts new file mode 100644 index 00000000..83ac5058 --- /dev/null +++ b/hub/src/store/notificationPreferences.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from './index' + +describe('notificationPreferences', () => { + it('returns all-enabled defaults when no row exists', () => { + const store = new Store(':memory:') + expect(store.notificationPrefs.getPreferences('ns-1')).toEqual({ + namespace: 'ns-1', + permissionRequests: 1, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 1, + updatedAt: 0 + }) + }) + + it('upserts on first set, defaulting untouched fields', () => { + const store = new Store(':memory:') + const prefs = store.notificationPrefs.setPreferences('ns-1', { sessionReady: 0 }) + expect(prefs.sessionReady).toBe(0) + expect(prefs.permissionRequests).toBe(1) + expect(prefs.taskNotifications).toBe(1) + expect(prefs.sessionCompletion).toBe(1) + expect(prefs.updatedAt).toBeGreaterThan(0) + }) + + it('updates only the provided fields on an existing row', () => { + const store = new Store(':memory:') + store.notificationPrefs.setPreferences('ns-1', { sessionReady: 0, taskNotifications: 0 }) + const after = store.notificationPrefs.setPreferences('ns-1', { permissionRequests: 0 }) + expect(after).toMatchObject({ + permissionRequests: 0, + sessionReady: 0, + taskNotifications: 0, + sessionCompletion: 1 + }) + }) + + it('keeps namespaces independent', () => { + const store = new Store(':memory:') + store.notificationPrefs.setPreferences('ns-a', { sessionReady: 0 }) + expect(store.notificationPrefs.getPreferenceFlags('ns-a').sessionReady).toBe(0) + expect(store.notificationPrefs.getPreferenceFlags('ns-b')).toEqual({ + permissionRequests: 1, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 1 + }) + }) +}) diff --git a/hub/src/store/notificationPreferences.ts b/hub/src/store/notificationPreferences.ts new file mode 100644 index 00000000..89d806f5 --- /dev/null +++ b/hub/src/store/notificationPreferences.ts @@ -0,0 +1,123 @@ +import type { Database } from 'bun:sqlite' + +export type NotificationPreferenceFlags = { + permissionRequests: number + sessionReady: number + taskNotifications: number + sessionCompletion: number +} + +export type NotificationPreferences = NotificationPreferenceFlags & { + namespace: string + updatedAt: number +} + +type DbPreferenceRow = { + namespace: string + permission_requests: number + session_ready: number + task_notifications: number + session_completion: number + updated_at: number +} + +// All event types default to enabled — new namespaces behave exactly like the +// pre-preferences behavior (push everything). +const DEFAULTS: NotificationPreferenceFlags = { + permissionRequests: 1, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 1 +} + +function rowToFlags(row: DbPreferenceRow): NotificationPreferenceFlags { + return { + permissionRequests: row.permission_requests, + sessionReady: row.session_ready, + taskNotifications: row.task_notifications, + sessionCompletion: row.session_completion + } +} + +function getRow(db: Database, namespace: string): DbPreferenceRow | undefined { + return db.prepare( + 'SELECT * FROM notification_preferences WHERE namespace = ?' + ).get(namespace) as DbPreferenceRow | undefined +} + +export function getPreferences(db: Database, namespace: string): NotificationPreferences { + const row = getRow(db, namespace) + if (row) { + return { + namespace: row.namespace, + ...rowToFlags(row), + updatedAt: row.updated_at + } + } + return { + namespace, + ...DEFAULTS, + updatedAt: 0 + } +} + +/** + * Fast path for the notification hot loop: flags only, no timestamp. + */ +export function getPreferenceFlags(db: Database, namespace: string): NotificationPreferenceFlags { + const row = getRow(db, namespace) + if (row) { + return rowToFlags(row) + } + return { ...DEFAULTS } +} + +/** + * Upserts a partial preference update. When no row exists yet, defaults are + * merged with the provided flags before inserting. + */ +export function setPreferences( + db: Database, + namespace: string, + partial: Partial +): NotificationPreferences { + const existing = getRow(db, namespace) + const now = Date.now() + if (existing) { + const fields: string[] = ['updated_at = @updated_at'] + const params: Record = { namespace, updated_at: now } + for (const [key, column] of [ + ['permissionRequests', 'permission_requests'], + ['sessionReady', 'session_ready'], + ['taskNotifications', 'task_notifications'], + ['sessionCompletion', 'session_completion'] + ] as const) { + if (partial[key] !== undefined) { + fields.push(`${column} = @${key}`) + params[key] = partial[key] + } + } + db.prepare( + `UPDATE notification_preferences SET ${fields.join(', ')} WHERE namespace = @namespace` + ).run(params) + } else { + const flags = { ...DEFAULTS, ...partial } + db.prepare(` + INSERT INTO notification_preferences ( + namespace, permission_requests, session_ready, + task_notifications, session_completion, updated_at + ) VALUES ( + @namespace, @permissionRequests, @sessionReady, + @taskNotifications, @sessionCompletion, @updatedAt + ) + `).run({ + namespace, + permissionRequests: flags.permissionRequests, + sessionReady: flags.sessionReady, + taskNotifications: flags.taskNotifications, + sessionCompletion: flags.sessionCompletion, + updatedAt: now + }) + } + return getPreferences(db, namespace) +} diff --git a/hub/src/web/routes/notificationPreferences.test.ts b/hub/src/web/routes/notificationPreferences.test.ts new file mode 100644 index 00000000..b9583db9 --- /dev/null +++ b/hub/src/web/routes/notificationPreferences.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import { createNotificationPreferencesRoutes } from './notificationPreferences' + +function createApp(): Hono { + const store = new Store(':memory:') + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'test-ns') + await next() + }) + app.route('/api', createNotificationPreferencesRoutes(store)) + return app +} + +describe('GET /api/notification-preferences', () => { + it('returns all-enabled defaults for a namespace with no row', async () => { + const app = createApp() + const res = await app.request('/api/notification-preferences') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + namespace: 'test-ns', + permissionRequests: 1, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 1, + updatedAt: 0 + }) + }) +}) + +describe('PUT /api/notification-preferences', () => { + it('updates only the provided fields and returns the merged result', async () => { + const app = createApp() + const res = await app.request('/api/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionReady: 0 }) + }) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ + namespace: 'test-ns', + permissionRequests: 1, + sessionReady: 0, + taskNotifications: 1, + sessionCompletion: 1 + }) + + const getRes = await app.request('/api/notification-preferences') + const body = await getRes.json() as { sessionReady: number } + expect(body.sessionReady).toBe(0) + }) + + it('rejects out-of-range values', async () => { + const app = createApp() + const res = await app.request('/api/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionRequests: 2 }) + }) + expect(res.status).toBe(400) + }) + + it('rejects invalid bodies', async () => { + const app = createApp() + const res = await app.request('/api/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionReady: 'yes' }) + }) + expect(res.status).toBe(400) + }) +}) diff --git a/hub/src/web/routes/notificationPreferences.ts b/hub/src/web/routes/notificationPreferences.ts new file mode 100644 index 00000000..658159d8 --- /dev/null +++ b/hub/src/web/routes/notificationPreferences.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' + +const updateSchema = z.object({ + permissionRequests: z.number().min(0).max(1).optional(), + sessionReady: z.number().min(0).max(1).optional(), + taskNotifications: z.number().min(0).max(1).optional(), + sessionCompletion: z.number().min(0).max(1).optional() +}) + +export function createNotificationPreferencesRoutes(store: Store): Hono { + const app = new Hono() + + app.get('/notification-preferences', (c) => { + const namespace = c.get('namespace') + return c.json(store.notificationPrefs.getPreferences(namespace)) + }) + + app.put('/notification-preferences', async (c) => { + const json = await c.req.json().catch(() => null) + const parsed = updateSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400) + } + + const namespace = c.get('namespace') + return c.json(store.notificationPrefs.setPreferences(namespace, parsed.data)) + }) + + return app +} diff --git a/hub/src/web/routes/push.ts b/hub/src/web/routes/push.ts index 0bf74e88..0173ea95 100644 --- a/hub/src/web/routes/push.ts +++ b/hub/src/web/routes/push.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { z } from 'zod' +import type { PushService } from '../../push/pushService' import type { Store } from '../../store' import type { WebAppEnv } from '../middleware/auth' @@ -15,7 +16,11 @@ const unsubscribeSchema = z.object({ endpoint: z.string().min(1) }) -export function createPushRoutes(store: Store, vapidPublicKey: string): Hono { +export function createPushRoutes( + store: Store, + vapidPublicKey: string, + pushService: PushService +): Hono { const app = new Hono() app.get('/push/vapid-public-key', (c) => { @@ -52,5 +57,26 @@ export function createPushRoutes(store: Store, vapidPublicKey: string): Hono { + const namespace = c.get('namespace') + try { + await pushService.sendToNamespace(namespace, { + title: 'HAPI Test Notification', + body: 'Your push notifications are working.', + tag: 'test-push', + data: { + type: 'test', + sessionId: '', + url: '/settings/notifications' + } + }) + return c.json({ ok: true }) + } catch (error) { + console.error('[PushRoutes] Test push failed:', error) + return c.json({ error: 'Failed to send test push' }, 500) + } + }) + return app } diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 6ae596b5..b6f86755 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -10,6 +10,7 @@ import { buildGeminiLiveSetupMessage, QWEN_REALTIME_MODEL } from '@hapi/protocol import { createQwenProxyWebSocketHandler } from './qwenProxyHandler' import { decodeVoiceSystemPromptParam } from '../voiceSystemPromptParam' import type { SyncEngine } from '../sync/syncEngine' +import type { PushService } from '../push/pushService' import { createAuthMiddleware, type WebAppEnv } from './middleware/auth' import { createAuthRoutes } from './routes/auth' import { createBindRoutes } from './routes/bind' @@ -24,6 +25,7 @@ import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' import { createPushRoutes } from './routes/push' +import { createNotificationPreferencesRoutes } from './routes/notificationPreferences' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' @@ -216,6 +218,7 @@ function createWebApp(options: { jwtSecret: Uint8Array store: Store vapidPublicKey: string + pushService: PushService corsOrigins?: string[] embeddedAssetMap: Map | null relayMode?: boolean @@ -258,7 +261,8 @@ function createWebApp(options: { store: options.store, getSyncEngine: options.getSyncEngine })) - app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createPushRoutes(options.store, options.vapidPublicKey, options.pushService)) + app.route('/api', createNotificationPreferencesRoutes(options.store)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) @@ -372,6 +376,7 @@ export async function startWebServer(options: { jwtSecret: Uint8Array store: Store vapidPublicKey: string + pushService: PushService socketEngine: SocketEngine corsOrigins?: string[] relayMode?: boolean @@ -386,6 +391,7 @@ export async function startWebServer(options: { jwtSecret: options.jwtSecret, store: options.store, vapidPublicKey: options.vapidPublicKey, + pushService: options.pushService, corsOrigins: options.corsOrigins, embeddedAssetMap, relayMode: options.relayMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ec3e5706..2a0ee5cc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,9 +14,12 @@ import type { MachinesResponse, MessagesResponse, PermissionMode, + NotificationPreferences, + NotificationPreferencesUpdate, PushSubscriptionPayload, PushUnsubscribePayload, PushVapidPublicKeyResponse, + TestPushResponse, SlashCommandsResponse, SkillsResponse, SpawnResponse, @@ -210,6 +213,26 @@ export class ApiClient { }) } + async getNotificationPreferences(): Promise { + return await this.request('/api/notification-preferences') + } + + async updateNotificationPreferences( + update: NotificationPreferencesUpdate + ): Promise { + return await this.request('/api/notification-preferences', { + method: 'PUT', + body: JSON.stringify(update) + }) + } + + async sendTestPush(): Promise { + return await this.request('/api/push/test', { + method: 'POST', + body: JSON.stringify({}) + }) + } + async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise { // 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。 return await this.request('/api/codex/sync-session', { diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 2763dd02..27019703 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -34,6 +34,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { display: `${t(`settings.display.appearance.${appearance}`)} · ${Math.round(fontScale * 100)}%`, chat: t(`settings.chat.enterBehavior.${composerEnterBehavior}`), voice: t('settings.hub.voice.summary'), + notifications: t('settings.notifications.summary'), machines: t('settings.hub.machines.summary'), storage: t('settings.storage.summary'), usage: t('settings.usage.summary'), diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 25b84185..34f6a339 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -916,6 +916,26 @@ export default { 'settings.companion.copyLink': 'Copy link', 'settings.companion.copied': 'Copied!', 'settings.companion.hide': 'Hide', + 'settings.notifications.title': 'Notifications', + 'settings.notifications.description': 'Choose which events trigger push notifications on your devices.', + 'settings.notifications.summary': 'Push notification preferences', + 'settings.notifications.section': 'Notification types', + 'settings.notifications.permissionRequests': 'Permission requests', + 'settings.notifications.permissionRequestsDescription': 'When an agent needs your approval to run a tool.', + 'settings.notifications.sessionReady': 'Session ready', + 'settings.notifications.sessionReadyDescription': 'When an agent is waiting for your input.', + 'settings.notifications.taskNotifications': 'Task notifications', + 'settings.notifications.taskNotificationsDescription': 'When a task completes or fails.', + 'settings.notifications.sessionCompletion': 'Session completion', + 'settings.notifications.sessionCompletionDescription': 'When a session finishes.', + 'settings.notifications.disablePermissionTitle': 'Turn off permission request notifications?', + 'settings.notifications.disablePermissionDescription': 'You will no longer be notified on your phone when an agent needs your approval. Pending requests will only be visible in the web app.', + 'settings.notifications.disablePermissionConfirm': 'Turn off anyway', + 'settings.notifications.testPush': 'Send test push', + 'settings.notifications.testPushSending': 'Sending…', + 'settings.notifications.testPushSent': 'Test notification sent!', + 'settings.notifications.testPushError': 'Failed to send test notification', + 'settings.notifications.saveError': 'Failed to save preferences', 'settings.machines.title': 'Machines', 'settings.machines.description': 'Give your machines names of your own. Only machines that are currently online are listed.', 'settings.machines.section': 'Your machines', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7aa27bd6..a883e9f7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -915,6 +915,26 @@ export default { 'settings.companion.copyLink': '复制链接', 'settings.companion.copied': '已复制!', 'settings.companion.hide': '隐藏', + 'settings.notifications.title': '通知', + 'settings.notifications.description': '选择哪些事件会触发推送到你设备的通知。', + 'settings.notifications.summary': '推送通知偏好', + 'settings.notifications.section': '通知类型', + 'settings.notifications.permissionRequests': '权限请求', + 'settings.notifications.permissionRequestsDescription': '当 Agent 需要你批准执行某个工具时。', + 'settings.notifications.sessionReady': '会话就绪', + 'settings.notifications.sessionReadyDescription': '当 Agent 等待你的输入时。', + 'settings.notifications.taskNotifications': '任务通知', + 'settings.notifications.taskNotificationsDescription': '当任务完成或失败时。', + 'settings.notifications.sessionCompletion': '会话完成', + 'settings.notifications.sessionCompletionDescription': '当会话结束时。', + 'settings.notifications.disablePermissionTitle': '关闭权限请求通知?', + 'settings.notifications.disablePermissionDescription': '关闭后,当 Agent 需要你批准时手机将不再收到通知,待处理的请求只能通过网页查看。', + 'settings.notifications.disablePermissionConfirm': '仍然关闭', + 'settings.notifications.testPush': '发送测试推送', + 'settings.notifications.testPushSending': '发送中…', + 'settings.notifications.testPushSent': '测试通知已发送!', + 'settings.notifications.testPushError': '发送测试通知失败', + 'settings.notifications.saveError': '保存偏好设置失败', 'settings.machines.title': '设备', 'settings.machines.description': '给设备起自己的名字。这里只列出当前在线的设备。', 'settings.machines.section': '我的设备', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index aceca5df..533ce28e 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,6 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, + notificationPreferences: ['notification-preferences'] as const, usageSummary: (range: string, timeZone: string) => ['usage-summary', range, timeZone] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, diff --git a/web/src/router.tsx b/web/src/router.tsx index 9fe90d1a..5a9d3b1b 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -61,6 +61,7 @@ import SettingsVoicePage from '@/routes/settings/voice' import SettingsVoiceVoicesPage from '@/routes/settings/voice-voices' import SettingsVoiceAdvancedPage from '@/routes/settings/voice-advanced' import SettingsMachinesPage from '@/routes/settings/machines' +import SettingsNotificationsPage from '@/routes/settings/notifications' import SettingsAboutPage from '@/routes/settings/about' import SettingsStoragePage from '@/routes/settings/storage' import SettingsUsagePage from '@/routes/settings/usage' @@ -1166,6 +1167,12 @@ const settingsMachinesRoute = createRoute({ component: SettingsMachinesPage, }) +const settingsNotificationsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: 'notifications', + component: SettingsNotificationsPage, +}) + const settingsAboutRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'about', @@ -1224,6 +1231,7 @@ export const routeTree = rootRoute.addChildren([ settingsVoiceVoicesRoute, settingsVoiceAdvancedRoute, settingsMachinesRoute, + settingsNotificationsRoute, settingsStorageRoute, settingsUsageRoute, settingsAboutRoute, diff --git a/web/src/routes/settings/categories.ts b/web/src/routes/settings/categories.ts index 793efed4..18d03569 100644 --- a/web/src/routes/settings/categories.ts +++ b/web/src/routes/settings/categories.ts @@ -3,6 +3,7 @@ export const settingsCategories = [ { id: 'display', path: '/settings/display', titleKey: 'settings.display.title' }, { id: 'chat', path: '/settings/chat', titleKey: 'settings.chat.title' }, { id: 'voice', path: '/settings/voice', titleKey: 'settings.voice.title' }, + { id: 'notifications', path: '/settings/notifications', titleKey: 'settings.notifications.title' }, { id: 'machines', path: '/settings/machines', titleKey: 'settings.machines.title' }, { id: 'storage', path: '/settings/storage', titleKey: 'settings.storage.title' }, { id: 'usage', path: '/settings/usage', titleKey: 'settings.usage.title' }, diff --git a/web/src/routes/settings/notifications.test.tsx b/web/src/routes/settings/notifications.test.tsx new file mode 100644 index 00000000..4c234338 --- /dev/null +++ b/web/src/routes/settings/notifications.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import SettingsNotificationsPage from './notifications' + +const defaultPrefs = { + namespace: 'default', + permissionRequests: 1, + sessionReady: 1, + taskNotifications: 1, + sessionCompletion: 1, + updatedAt: Date.now(), +} + +const getNotificationPreferences = vi.fn() +const updateNotificationPreferences = vi.fn() +const sendTestPush = vi.fn() + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => ({ + api: { getNotificationPreferences, updateNotificationPreferences, sendTestPush }, + }), +})) + +describe('SettingsNotificationsPage', () => { + beforeEach(() => { + getNotificationPreferences.mockResolvedValue(defaultPrefs) + updateNotificationPreferences.mockResolvedValue(defaultPrefs) + sendTestPush.mockResolvedValue({ ok: true }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + , + ) + } + + it('renders all four toggles from server preferences', async () => { + renderPage() + const permissionSwitch = await screen.findByLabelText('Permission requests') + expect(permissionSwitch).toBeChecked() + expect(screen.getByLabelText('Session ready')).toBeChecked() + expect(screen.getByLabelText('Task notifications')).toBeChecked() + expect(screen.getByLabelText('Session completion')).toBeChecked() + }) + + it('saves a toggle change through the API', async () => { + renderPage() + const sessionReadySwitch = await screen.findByLabelText('Session ready') + fireEvent.click(sessionReadySwitch) + await waitFor(() => { + expect(updateNotificationPreferences).toHaveBeenCalledWith({ sessionReady: 0 }) + }) + }) + + it('asks for confirmation before disabling permission requests', async () => { + renderPage() + const permissionSwitch = await screen.findByLabelText('Permission requests') + fireEvent.click(permissionSwitch) + expect(updateNotificationPreferences).not.toHaveBeenCalled() + expect(screen.getByText('Turn off permission request notifications?')).toBeTruthy() + }) + + it('applies the permission disable after confirming', async () => { + renderPage() + const permissionSwitch = await screen.findByLabelText('Permission requests') + fireEvent.click(permissionSwitch) + const confirmButton = screen.getByText('Turn off anyway') + fireEvent.click(confirmButton) + await waitFor(() => { + expect(updateNotificationPreferences).toHaveBeenCalledWith({ permissionRequests: 0 }) + }) + }) + + it('sends a test push and reports success', async () => { + renderPage() + const button = await screen.findByRole('button', { name: 'Send test push' }) + fireEvent.click(button) + expect(sendTestPush).toHaveBeenCalled() + expect(await screen.findByText('Test notification sent!')).toBeTruthy() + }) +}) diff --git a/web/src/routes/settings/notifications.tsx b/web/src/routes/settings/notifications.tsx new file mode 100644 index 00000000..0528f83d --- /dev/null +++ b/web/src/routes/settings/notifications.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { SettingsPageContent, SettingsRow, SettingsSection, SettingsSwitch } from '@/components/settings/SettingsPrimitives' +import { useAppContext } from '@/lib/app-context' +import { queryKeys } from '@/lib/query-keys' +import { useTranslation } from '@/lib/use-translation' +import type { NotificationPreferencesUpdate } from '@/types/api' + +type ToggleKey = keyof NotificationPreferencesUpdate + +export default function SettingsNotificationsPage() { + const { api } = useAppContext() + const { t } = useTranslation() + const queryClient = useQueryClient() + const [confirmDisablePermission, setConfirmDisablePermission] = useState(false) + const [testPushLabel, setTestPushLabel] = useState(null) + const [saveError, setSaveError] = useState(null) + + const query = useQuery({ + queryKey: queryKeys.notificationPreferences, + queryFn: async () => { + if (!api) throw new Error('API unavailable') + return await api.getNotificationPreferences() + }, + enabled: Boolean(api), + staleTime: 0, + retry: false, + }) + + const mutation = useMutation({ + mutationFn: async (update: NotificationPreferencesUpdate) => { + if (!api) throw new Error('API unavailable') + return await api.updateNotificationPreferences(update) + }, + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.notificationPreferences, data) + setSaveError(null) + }, + onError: () => { + setSaveError(t('settings.notifications.saveError')) + }, + }) + + const handleToggle = (key: ToggleKey) => (checked: boolean) => { + // Turning off permission requests needs explicit confirmation — it + // disables phone-side approval, the core remote-control flow. + if (key === 'permissionRequests' && !checked) { + setConfirmDisablePermission(true) + return + } + mutation.mutate({ [key]: checked ? 1 : 0 }) + } + + const sendTestPush = async () => { + if (!api) return + setTestPushLabel(t('settings.notifications.testPushSending')) + try { + const result = await api.sendTestPush() + setTestPushLabel('ok' in result + ? t('settings.notifications.testPushSent') + : t('settings.notifications.testPushError')) + } catch { + setTestPushLabel(t('settings.notifications.testPushError')) + } + setTimeout(() => setTestPushLabel(null), 3000) + } + + const prefs = query.data + + return ( + + + + + + + {saveError ? : null} + + + setConfirmDisablePermission(false)} + title={t('settings.notifications.disablePermissionTitle')} + description={t('settings.notifications.disablePermissionDescription')} + confirmLabel={t('settings.notifications.disablePermissionConfirm')} + confirmingLabel={t('settings.notifications.disablePermissionConfirm')} + onConfirm={async () => { + mutation.mutate({ permissionRequests: 0 }) + }} + isPending={mutation.isPending} + destructive + /> + + ) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0ecf12a1..5be55acf 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -176,6 +176,23 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type NotificationPreferences = { + namespace: string + permissionRequests: number + sessionReady: number + taskNotifications: number + sessionCompletion: number + updatedAt: number +} + +export type NotificationPreferencesUpdate = Partial< + Pick +> + +export type TestPushResponse = + | { ok: true } + | { error: string } + export type CodexDesktopScriptResponse = { success: boolean message?: string