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 <noreply@hapi.run>
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-05 11:16:08 +08:00
co-authored by HAPI Claude
parent 27bc6bade3
commit dd1cd24a46
24 changed files with 869 additions and 13 deletions
@@ -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()
})
})
+26 -1
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
if (!this.isEventEnabled(session, 'sessionCompletion')) {
return
}
for (const channel of this.channels) {
if (typeof channel.sendSessionCompletion !== 'function') {
continue
+2 -1
View File
@@ -246,7 +246,7 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
}
}
notificationHub = new NotificationHub(syncEngine, notificationChannels)
notificationHub = new NotificationHub(syncEngine, notificationChannels, undefined, store)
// Start HTTP service first (before tunnel, so tunnel has something to forward to)
webServer = await startWebServer({
@@ -256,6 +256,7 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
jwtSecret,
store,
vapidPublicKey: vapidKeys.publicKey,
pushService,
socketEngine: socketServer.engine,
corsOrigins,
relayMode: relayFlag.enabled,
+32 -2
View File
@@ -8,6 +8,7 @@ import { addMessage } from './messages'
import type { StoredMessage } from './types'
import { PushStore } from './pushStore'
import { FcmStore } from './fcmStore'
import { NotificationPreferenceStore } from './notificationPreferenceStore'
import { ScratchlistStore } from './scratchlistStore'
import { SessionStore } from './sessionStore'
import { UserStore } from './userStore'
@@ -28,12 +29,13 @@ export { MachineStore } from './machineStore'
export { MessageStore } from './messageStore'
export { PushStore } from './pushStore'
export { FcmStore } from './fcmStore'
export { NotificationPreferenceStore } from './notificationPreferenceStore'
export { ScratchlistStore } from './scratchlistStore'
export { SessionStore } from './sessionStore'
export { UserStore } from './userStore'
export { UsageStore } from './usageStore'
const SCHEMA_VERSION: number = 19
const SCHEMA_VERSION: number = 20
const REQUIRED_TABLES = [
'sessions',
'machines',
@@ -44,7 +46,8 @@ const REQUIRED_TABLES = [
'fcm_devices',
'session_scratchlist',
'usage_events',
'usage_scan_state'
'usage_scan_state',
'notification_preferences'
] as const
export class Store {
@@ -60,6 +63,7 @@ export class Store {
readonly fcm: FcmStore
readonly scratchlist: ScratchlistStore
readonly usage: UsageStore
readonly notificationPrefs: NotificationPreferenceStore
/**
* Filesystem path of the underlying SQLite database, or ':memory:' for
@@ -113,6 +117,7 @@ export class Store {
this.fcm = new FcmStore(this.db)
this.scratchlist = new ScratchlistStore(this.db)
this.usage = new UsageStore(this.db)
this.notificationPrefs = new NotificationPreferenceStore(this.db)
}
/**
@@ -287,6 +292,7 @@ export class Store {
16: () => 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<string> {
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
+3 -3
View File
@@ -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()
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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()
@@ -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<NotificationPreferenceFlags>
): NotificationPreferences {
return setPreferences(this.db, namespace, partial)
}
}
@@ -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
})
})
})
+123
View File
@@ -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<NotificationPreferenceFlags>
): NotificationPreferences {
const existing = getRow(db, namespace)
const now = Date.now()
if (existing) {
const fields: string[] = ['updated_at = @updated_at']
const params: Record<string, number | string> = { 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)
}
@@ -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<WebAppEnv> {
const store = new Store(':memory:')
const app = new Hono<WebAppEnv>()
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)
})
})
@@ -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<WebAppEnv> {
const app = new Hono<WebAppEnv>()
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
}
+27 -1
View File
@@ -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<WebAppEnv> {
export function createPushRoutes(
store: Store,
vapidPublicKey: string,
pushService: PushService
): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.get('/push/vapid-public-key', (c) => {
@@ -52,5 +57,26 @@ export function createPushRoutes(store: Store, vapidPublicKey: string): Hono<Web
return c.json({ ok: true })
})
// User-initiated test push: deliberately bypasses notification preferences.
app.post('/push/test', async (c) => {
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
}
+7 -1
View File
@@ -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<string, EmbeddedWebAsset> | 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,
+23
View File
@@ -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<NotificationPreferences> {
return await this.request<NotificationPreferences>('/api/notification-preferences')
}
async updateNotificationPreferences(
update: NotificationPreferencesUpdate
): Promise<NotificationPreferences> {
return await this.request<NotificationPreferences>('/api/notification-preferences', {
method: 'PUT',
body: JSON.stringify(update)
})
}
async sendTestPush(): Promise<TestPushResponse> {
return await this.request<TestPushResponse>('/api/push/test', {
method: 'POST',
body: JSON.stringify({})
})
}
async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise<CodexDesktopScriptResponse> {
// 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。
return await this.request<CodexDesktopScriptResponse>('/api/codex/sync-session', {
@@ -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'),
+20
View File
@@ -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',
+20
View File
@@ -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': '我的设备',
+1
View File
@@ -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,
+8
View File
@@ -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,
+1
View File
@@ -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' },
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider>
<SettingsNotificationsPage />
</I18nProvider>
</QueryClientProvider>,
)
}
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()
})
})
+123
View File
@@ -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<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(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 (
<SettingsPageContent description={t('settings.notifications.description')}>
<SettingsSection title={t('settings.notifications.section')}>
<SettingsSwitch
label={t('settings.notifications.permissionRequests')}
description={t('settings.notifications.permissionRequestsDescription')}
checked={prefs ? Boolean(prefs.permissionRequests) : true}
onChange={handleToggle('permissionRequests')}
/>
<SettingsSwitch
label={t('settings.notifications.sessionReady')}
description={t('settings.notifications.sessionReadyDescription')}
checked={prefs ? Boolean(prefs.sessionReady) : true}
onChange={handleToggle('sessionReady')}
/>
<SettingsSwitch
label={t('settings.notifications.taskNotifications')}
description={t('settings.notifications.taskNotificationsDescription')}
checked={prefs ? Boolean(prefs.taskNotifications) : true}
onChange={handleToggle('taskNotifications')}
/>
<SettingsSwitch
label={t('settings.notifications.sessionCompletion')}
description={t('settings.notifications.sessionCompletionDescription')}
checked={prefs ? Boolean(prefs.sessionCompletion) : true}
onChange={handleToggle('sessionCompletion')}
/>
{saveError ? <SettingsRow label={saveError} /> : null}
</SettingsSection>
<button
type="button"
onClick={() => void sendTestPush()}
disabled={testPushLabel !== null || !Boolean(api)}
className="rounded-lg bg-[var(--app-button)] px-3 py-2 text-sm font-medium text-[var(--app-button-text)] disabled:opacity-50"
>
{testPushLabel ?? t('settings.notifications.testPush')}
</button>
<ConfirmDialog
isOpen={confirmDisablePermission}
onClose={() => 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
/>
</SettingsPageContent>
)
}
+17
View File
@@ -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<NotificationPreferences, 'permissionRequests' | 'sessionReady' | 'taskNotifications' | 'sessionCompletion'>
>
export type TestPushResponse =
| { ok: true }
| { error: string }
export type CodexDesktopScriptResponse = {
success: boolean
message?: string