From 5ff897a8bf59179f4bfbc3f11f5ff0052d3d04ac Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 4 Aug 2026 15:10:33 +0800 Subject: [PATCH] feat: customizable push notification copy (web push) Server-level title/body templates with {variable} placeholders for web push notifications, configured via settings.json `notificationCopy` and an admin-only editing section on the Notifications settings page with live preview and variable chips. Empty templates fall back to the hardcoded defaults; the channel also now delivers session-completion pushes, which the existing preference toggle previously had no web push effect for. via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude --- hub/src/config/settings.ts | 4 + hub/src/push/notificationCopy.test.ts | 163 ++++++++++++++ hub/src/push/notificationCopy.ts | 148 +++++++++++++ hub/src/push/pushNotificationChannel.test.ts | 138 +++++++++++- hub/src/push/pushNotificationChannel.ts | 90 +++++--- hub/src/startHub.ts | 4 +- hub/src/web/routes/notificationCopy.test.ts | 117 ++++++++++ hub/src/web/routes/notificationCopy.ts | 69 ++++++ hub/src/web/server.ts | 2 + web/src/api/client.ts | 13 ++ web/src/lib/locales/en.ts | 14 ++ web/src/lib/locales/zh-CN.ts | 14 ++ web/src/lib/query-keys.ts | 1 + web/src/lib/template.ts | 10 + .../routes/settings/notifications.test.tsx | 65 +++++- web/src/routes/settings/notifications.tsx | 208 +++++++++++++++++- web/src/types/api.ts | 18 ++ 17 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 hub/src/push/notificationCopy.test.ts create mode 100644 hub/src/push/notificationCopy.ts create mode 100644 hub/src/web/routes/notificationCopy.test.ts create mode 100644 hub/src/web/routes/notificationCopy.ts create mode 100644 web/src/lib/template.ts diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 0c2be780..4b12955d 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import type { NotificationCopyConfig } from '../push/notificationCopy' + export interface Settings { machineId?: string machineIdConfirmedByServer?: boolean @@ -22,6 +24,8 @@ export interface Settings { corsOrigins?: string[] /** Per-hub relay auth key issued by the relay server (/issue) */ relayAuthKey?: string + /** Custom push notification copy templates (web push only). Empty fields fall back to defaults. */ + notificationCopy?: NotificationCopyConfig } export function getSettingsFile(dataDir: string): string { diff --git a/hub/src/push/notificationCopy.test.ts b/hub/src/push/notificationCopy.test.ts new file mode 100644 index 00000000..ba7df570 --- /dev/null +++ b/hub/src/push/notificationCopy.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Session } from '../sync/syncEngine' +import { + DEFAULT_COPY, + buildPermissionRequestCopy, + buildReadyCopy, + buildSessionCompletionCopy, + buildTaskCopy, + isTaskFailure, + loadNotificationCopy, + renderTemplate, + resolveCopy +} from './notificationCopy' + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + active: true, + metadata: { name: 'Demo task', flavor: 'codex' }, + ...overrides + } as Session +} + +describe('renderTemplate', () => { + it('replaces known variables and leaves unknown ones as-is', () => { + expect(renderTemplate('{agentName} in {sessionName} and {typo}', { + agentName: 'Codex', + sessionName: 'Demo task' + })).toBe('Codex in Demo task and {typo}') + }) + + it('handles empty vars', () => { + expect(renderTemplate('{agentName} hello', {})).toBe('{agentName} hello') + }) +}) + +describe('loadNotificationCopy', () => { + it('returns empty config when settings.json is missing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hapi-copy-test-')) + try { + expect(await loadNotificationCopy(dir)).toEqual({}) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('returns stored overrides and ignores other keys', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hapi-copy-test-')) + try { + await writeFile(join(dir, 'settings.json'), JSON.stringify({ + cliApiToken: 'abc', + notificationCopy: { + ready: { title: 'Hey!', body: '{agentName} wants you' } + } + })) + expect(await loadNotificationCopy(dir)).toEqual({ + ready: { title: 'Hey!', body: '{agentName} wants you' } + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('resolveCopy', () => { + it('returns the default when no stored template exists', () => { + expect(resolveCopy('ready', {})).toEqual(DEFAULT_COPY.ready) + }) + + it('returns the default when title is empty', () => { + expect(resolveCopy('ready', { ready: { title: ' ', body: 'custom' } })).toEqual(DEFAULT_COPY.ready) + }) + + it('returns the default when body is empty', () => { + expect(resolveCopy('ready', { ready: { title: 'custom', body: '' } })).toEqual(DEFAULT_COPY.ready) + }) + + it('returns the stored template when both fields are non-empty', () => { + expect(resolveCopy('ready', { ready: { title: 'custom', body: 'hello {agentName}' } })) + .toEqual({ title: 'custom', body: 'hello {agentName}' }) + }) +}) + +describe('isTaskFailure', () => { + it('treats failed/error/killed/aborted as failure, case-insensitively', () => { + for (const status of ['failed', 'error', 'killed', 'aborted', 'FAILED', ' Error ']) { + expect(isTaskFailure(status)).toBe(true) + } + }) + + it('treats completed and undefined as success', () => { + expect(isTaskFailure('completed')).toBe(false) + expect(isTaskFailure(undefined)).toBe(false) + }) +}) + +describe('build*Copy', () => { + it('substitutes variables in permission copy with tool formatting', () => { + const session = createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + }) + const result = buildPermissionRequestCopy(session, {}, '/sessions/session-1') + expect(result).toEqual({ + title: 'Permission Request', + body: 'Demo task (Bash)' + }) + }) + + it('omits tool formatting when no tool is requested', () => { + const result = buildPermissionRequestCopy(createSession(), {}, '/sessions/session-1') + expect(result.body).toBe('Demo task') + }) + + it('applies a custom permission template', () => { + const session = createSession({ + agentState: { requests: { 'r-1': { tool: 'Bash', arguments: {} } } } + }) + // Note: {tool} includes the leading space + parens (e.g. " (Bash)"), + // matching the pre-customization body format. + const result = buildPermissionRequestCopy(session, { + permissionRequest: { title: '{agentName} needs approval', body: '{sessionName} wants{tool} at {url}' } + }, '/sessions/session-1') + expect(result).toEqual({ + title: 'Codex needs approval', + body: 'Demo task wants (Bash) at /sessions/session-1' + }) + }) + + it('renders ready copy with defaults', () => { + expect(buildReadyCopy(createSession(), {}, '/sessions/session-1')).toEqual({ + title: 'Ready for input', + body: 'Codex is waiting in Demo task' + }) + }) + + it('selects taskFailed for failure statuses and renders variables', () => { + const result = buildTaskCopy(createSession(), { status: 'failed', summary: 'Build broke' }, {}, '/sessions/session-1') + expect(result.isFailure).toBe(true) + expect(result.title).toBe('Task failed') + expect(result.body).toBe('Codex · Demo task · Build broke') + }) + + it('selects taskCompleted for success statuses', () => { + const result = buildTaskCopy(createSession(), { status: 'completed', summary: 'All green' }, {}, '/sessions/session-1') + expect(result.isFailure).toBe(false) + expect(result.title).toBe('Task completed') + expect(result.body).toBe('Codex · Demo task · All green') + }) + + it('renders session completion copy with reason variable', () => { + const result = buildSessionCompletionCopy(createSession(), 'completed', { + sessionCompletion: { title: '{sessionName} finished', body: 'via {reason} — {agentName}' } + }, '/sessions/session-1') + expect(result).toEqual({ + title: 'Demo task finished', + body: 'via completed — Codex' + }) + }) +}) diff --git a/hub/src/push/notificationCopy.ts b/hub/src/push/notificationCopy.ts new file mode 100644 index 00000000..02c30dae --- /dev/null +++ b/hub/src/push/notificationCopy.ts @@ -0,0 +1,148 @@ +import type { SessionEndReason } from '@hapi/protocol' +import { getSettingsFile, readSettings } from '../config/settings' +import type { TaskNotification } from '../notifications/notificationTypes' +import { getAgentName, getSessionName } from '../notifications/sessionInfo' +import type { Session } from '../sync/syncEngine' + +export type CopyKey = 'permissionRequest' | 'ready' | 'taskCompleted' | 'taskFailed' | 'sessionCompletion' + +export type CopyTemplate = { + title: string + body: string +} + +export type NotificationCopyConfig = Partial> + +export const COPY_KEYS: readonly CopyKey[] = [ + 'permissionRequest', + 'ready', + 'taskCompleted', + 'taskFailed', + 'sessionCompletion' +] as const + +/** + * Default copy mirrors the pre-customization hardcoded strings exactly, so a + * hub with no `notificationCopy` in settings.json behaves identically. + */ +export const DEFAULT_COPY: Record = { + permissionRequest: { title: 'Permission Request', body: '{sessionName}{tool}' }, + ready: { title: 'Ready for input', body: '{agentName} is waiting in {sessionName}' }, + taskCompleted: { title: 'Task completed', body: '{agentName} · {sessionName} · {summary}' }, + taskFailed: { title: 'Task failed', body: '{agentName} · {sessionName} · {summary}' }, + sessionCompletion: { title: 'Session completed', body: '{agentName} · {sessionName}' } +} + +export async function loadNotificationCopy(dataDir: string): Promise { + try { + const settings = await readSettings(getSettingsFile(dataDir)) + return settings?.notificationCopy ?? {} + } catch { + return {} + } +} + +/** + * A stored template only takes effect when BOTH title and body are non-empty; + * an empty field falls back to the default template. + */ +export function resolveCopy(key: CopyKey, stored: NotificationCopyConfig): CopyTemplate { + const template = stored[key] + if (template && template.title.trim() && template.body.trim()) { + return { title: template.title, body: template.body } + } + return DEFAULT_COPY[key] +} + +/** + * Replaces `{var}` placeholders. Unknown placeholders are left as-is so a + * typo stays visible in the delivered notification instead of vanishing. + */ +export function renderTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + return key in vars ? vars[key] : match + }) +} + +export type NotificationCopyResult = { + title: string + body: string +} + +function render(template: CopyTemplate, vars: Record): NotificationCopyResult { + return { + title: renderTemplate(template.title, vars), + body: renderTemplate(template.body, vars) + } +} + +/** + * `{tool}` resolves to ` (ToolName)` (leading space + parens) or the empty + * string — matching the pre-customization body format. + */ +export function buildPermissionRequestCopy( + session: Session, + stored: NotificationCopyConfig, + url: string +): NotificationCopyResult { + const request = Object.entries(session.agentState?.requests ?? {})[0]?.[1] ?? null + const tool = request?.tool ? ` (${request.tool})` : '' + return render(resolveCopy('permissionRequest', stored), { + agentName: getAgentName(session), + sessionName: getSessionName(session), + tool, + url + }) +} + +export function buildReadyCopy( + session: Session, + stored: NotificationCopyConfig, + url: string +): NotificationCopyResult { + return render(resolveCopy('ready', stored), { + agentName: getAgentName(session), + sessionName: getSessionName(session), + url + }) +} + +export function isTaskFailure(status: string | undefined): boolean { + const normalized = status?.trim().toLowerCase() + return normalized === 'failed' + || normalized === 'error' + || normalized === 'killed' + || normalized === 'aborted' +} + +export function buildTaskCopy( + session: Session, + notification: TaskNotification, + stored: NotificationCopyConfig, + url: string +): NotificationCopyResult & { isFailure: boolean } { + const isFailure = isTaskFailure(notification.status) + const key: CopyKey = isFailure ? 'taskFailed' : 'taskCompleted' + const rendered = render(resolveCopy(key, stored), { + agentName: getAgentName(session), + sessionName: getSessionName(session), + summary: notification.summary, + status: notification.status ?? '', + url + }) + return { ...rendered, isFailure } +} + +export function buildSessionCompletionCopy( + session: Session, + reason: SessionEndReason, + stored: NotificationCopyConfig, + url: string +): NotificationCopyResult { + return render(resolveCopy('sessionCompletion', stored), { + agentName: getAgentName(session), + sessionName: getSessionName(session), + reason, + url + }) +} diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts index bd0d0c33..4c8a42d5 100644 --- a/hub/src/push/pushNotificationChannel.test.ts +++ b/hub/src/push/pushNotificationChannel.test.ts @@ -7,9 +7,8 @@ function createSession(overrides: Partial = {}): Session { return { id: 'session-task-toast', namespace: 'default', - name: 'Demo task', active: true, - metadata: { flavor: 'codex' }, + metadata: { name: 'Demo task', flavor: 'codex' }, ...overrides } as Session } @@ -153,6 +152,141 @@ describe('PushNotificationChannel', () => { expect(pushed).toHaveLength(1) }) + it('applies custom copy from getCopyConfig', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + async () => ({ + ready: { title: 'Yo {agentName}', body: '{sessionName} is ready at {url}' } + }) + ) + + await channel.sendReady(createSession()) + + expect(pushed).toHaveLength(1) + expect(pushed[0].payload.title).toBe('Yo Codex') + expect(pushed[0].payload.body).toBe('Demo task is ready at /sessions/session-task-toast') + }) + + it('falls back to defaults for empty template fields', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + async () => ({ + ready: { title: '', body: ' ' } + }) + ) + + await channel.sendReady(createSession()) + + expect(pushed[0].payload.title).toBe('Ready for input') + expect(pushed[0].payload.body).toBe('Codex is waiting in Demo task') + }) + + it('selects taskFailed vs taskCompleted copy by status', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + async () => ({ + taskFailed: { title: 'Oops', body: '{summary}' }, + taskCompleted: { title: 'Nice', body: '{summary}' } + }) + ) + + await channel.sendTaskNotification(createSession(), { status: 'failed', summary: 'Broke' }) + await channel.sendTaskNotification(createSession(), { status: 'completed', summary: 'Passed' }) + + expect(pushed[0].payload.title).toBe('Oops') + expect(pushed[1].payload.title).toBe('Nice') + }) + + it('delivers session completion via web push even when the session is inactive', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + async () => ({ + sessionCompletion: { title: '{sessionName} done', body: '{agentName} finished' } + }) + ) + + await channel.sendSessionCompletion(createSession({ active: false }), 'completed') + + expect(pushed).toHaveLength(1) + expect(pushed[0].payload.tag).toBe('session-completion-session-task-toast') + expect(pushed[0].payload.title).toBe('Demo task done') + expect(pushed[0].payload.body).toBe('Codex finished') + }) + + it('still delivers with defaults when getCopyConfig throws', async () => { + const pushed: Array<{ namespace: string; payload: PushPayload }> = [] + const channel = new PushNotificationChannel( + { + sendToNamespace: async (namespace: string, payload: PushPayload) => { + pushed.push({ namespace, payload }) + } + } as never, + { + sendToast: async () => 0 + } as never, + { + hasVisibleConnection: () => false + } as never, + '', + async () => { + throw new Error('settings unreadable') + } + ) + + await channel.sendReady(createSession()) + + expect(pushed).toHaveLength(1) + expect(pushed[0].payload.title).toBe('Ready for input') + }) + it('also skips SSE in-page toast when native gate reports delivery', async () => { const pushed: Array<{ namespace: string; payload: PushPayload }> = [] const toasts: unknown[] = [] diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts index bfe751f5..4d80a6e5 100644 --- a/hub/src/push/pushNotificationChannel.ts +++ b/hub/src/push/pushNotificationChannel.ts @@ -1,9 +1,11 @@ -import type { Session } from '../sync/syncEngine' +import type { SessionEndReason } from '@hapi/protocol' import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes' import type { NotificationSendContext } from '../notifications/notificationSendContext' -import { getAgentName, getSessionName } from '../notifications/sessionInfo' import type { SSEManager } from '../sse/sseManager' +import type { Session } from '../sync/syncEngine' import type { VisibilityTracker } from '../visibility/visibilityTracker' +import type { NotificationCopyConfig } from './notificationCopy' +import { buildPermissionRequestCopy, buildReadyCopy, buildSessionCompletionCopy, buildTaskCopy, DEFAULT_COPY } from './notificationCopy' import type { PushPayload, PushService } from './pushService' export class PushNotificationChannel implements NotificationChannel { @@ -11,7 +13,8 @@ export class PushNotificationChannel implements NotificationChannel { private readonly pushService: PushService, private readonly sseManager: SSEManager, private readonly visibilityTracker: VisibilityTracker, - _appUrl: string + _appUrl: string, + private readonly getCopyConfig: () => Promise = async () => ({}) ) {} /** @@ -26,25 +29,37 @@ export class PushNotificationChannel implements NotificationChannel { console.log(`[Push.${method}] ns=${namespace} ${branch}${note}`) } + /** + * Loads custom copy, never failing a notification because of bad config: + * any loader error degrades to the hardcoded defaults. + */ + private async loadCopy(): Promise { + try { + return await this.getCopyConfig() + } catch { + return DEFAULT_COPY + } + } + async sendPermissionRequest(session: Session, ctx?: NotificationSendContext): Promise { if (!session.active) { return } - const name = getSessionName(session) - const requests = session.agentState?.requests ?? null - const requestEntries = requests ? Object.entries(requests) : [] - const [requestId, request] = requestEntries[0] ?? [undefined, null] - const toolName = request?.tool ? ` (${request.tool})` : '' + const requestEntries = Object.entries(session.agentState?.requests ?? {}) + const [requestId] = requestEntries[0] ?? [undefined] + const stored = await this.loadCopy() + const url = this.buildSessionPath(session.id) + const { title, body } = buildPermissionRequestCopy(session, stored, url) const payload: PushPayload = { - title: 'Permission Request', - body: `${name}${toolName}`, + title, + body, tag: `permission-${session.id}`, data: { type: 'permission-request', sessionId: session.id, - url: this.buildSessionPath(session.id), + url, requestId } } @@ -57,17 +72,18 @@ export class PushNotificationChannel implements NotificationChannel { return } - const agentName = getAgentName(session) - const name = getSessionName(session) + const stored = await this.loadCopy() + const url = this.buildSessionPath(session.id) + const { title, body } = buildReadyCopy(session, stored, url) const payload: PushPayload = { - title: 'Ready for input', - body: `${agentName} is waiting in ${name}`, + title, + body, tag: `ready-${session.id}`, data: { type: 'ready', sessionId: session.id, - url: this.buildSessionPath(session.id) + url } } @@ -79,32 +95,52 @@ export class PushNotificationChannel implements NotificationChannel { return } - const agentName = getAgentName(session) - const name = getSessionName(session) - const normalizedStatus = notification.status?.trim().toLowerCase() - const isFailure = normalizedStatus === 'failed' - || normalizedStatus === 'error' - || normalizedStatus === 'killed' - || normalizedStatus === 'aborted' + const stored = await this.loadCopy() + const url = this.buildSessionPath(session.id) + const { title, body } = buildTaskCopy(session, notification, stored, url) const payload: PushPayload = { - title: isFailure ? 'Task failed' : 'Task completed', - body: `${agentName} · ${name} · ${notification.summary}`, + title, + body, data: { type: 'task-notification', sessionId: session.id, - url: this.buildSessionPath(session.id) + url } } await this.deliverWebOrToast(session, payload, ctx, 'task') } + /** + * Session-completion pushes. Deliberately no `session.active` gate: the + * session has already become inactive by the time this fires (see + * NotificationHub.sendSessionCompletion, which reads the session directly). + */ + async sendSessionCompletion(session: Session, reason: SessionEndReason, ctx?: NotificationSendContext): Promise { + const stored = await this.loadCopy() + const url = this.buildSessionPath(session.id) + const { title, body } = buildSessionCompletionCopy(session, reason, stored, url) + + const payload: PushPayload = { + title, + body, + tag: `session-completion-${session.id}`, + data: { + type: 'session-completion', + sessionId: session.id, + url + } + } + + await this.deliverWebOrToast(session, payload, ctx, 'session-completion') + } + private async deliverWebOrToast( session: Session, payload: PushPayload, ctx: NotificationSendContext | undefined, - method: 'permission' | 'ready' | 'task' + method: 'permission' | 'ready' | 'task' | 'session-completion' ): Promise { if (ctx?.nativeGate?.sent) { this.logBranch(method, session.namespace, 'defer-to-native', 'fcm-delivered-this-dispatch') diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index bc67c5f8..828c90c4 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -11,6 +11,7 @@ import { SSEManager } from './sse/sseManager' import { getOrCreateVapidKeys } from './config/vapidKeys' import { PushService } from './push/pushService' import { PushNotificationChannel } from './push/pushNotificationChannel' +import { loadNotificationCopy } from './push/notificationCopy' import { FcmService } from './fcm/fcmService' import { FcmNotificationChannel } from './fcm/fcmNotificationChannel' import { resolveFcmConfig } from './fcm/fcmConfig' @@ -224,7 +225,8 @@ export async function startHub(options: StartHubOptions = {}): Promise loadNotificationCopy(config.dataDir) ) ) diff --git a/hub/src/web/routes/notificationCopy.test.ts b/hub/src/web/routes/notificationCopy.test.ts new file mode 100644 index 00000000..edefde73 --- /dev/null +++ b/hub/src/web/routes/notificationCopy.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import type { WebAppEnv } from '../middleware/auth' +import { createNotificationCopyRoutes } from './notificationCopy' + +let dir: string + +async function createApp(namespace: string): Promise> { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createNotificationCopyRoutes(dir)) + return app +} + +function readSettings(): Promise> { + return readFile(join(dir, 'settings.json'), 'utf8').then(JSON.parse) +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'hapi-copy-route-')) + await writeFile(join(dir, 'settings.json'), JSON.stringify({ cliApiToken: 'abc' })) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('GET /api/notification-copy', () => { + it('rejects non-default namespaces with 403', async () => { + const app = await createApp('user-1') + const res = await app.request('/api/notification-copy') + expect(res.status).toBe(403) + }) + + it('returns stored copy plus defaults for the admin namespace', async () => { + await writeFile(join(dir, 'settings.json'), JSON.stringify({ + cliApiToken: 'abc', + notificationCopy: { ready: { title: 'Hey', body: '{agentName}' } } + })) + const app = await createApp('default') + const res = await app.request('/api/notification-copy') + expect(res.status).toBe(200) + const body = await res.json() as { copy: Record; defaults: Record } + expect(body.copy).toEqual({ ready: { title: 'Hey', body: '{agentName}' } }) + expect(body.defaults.ready).toBeTruthy() + }) + + it('returns empty copy when no notificationCopy key exists', async () => { + const app = await createApp('default') + const res = await app.request('/api/notification-copy') + const body = await res.json() as { copy: Record } + expect(body.copy).toEqual({}) + }) +}) + +describe('PUT /api/notification-copy', () => { + it('rejects non-default namespaces with 403', async () => { + const app = await createApp('user-1') + const res = await app.request('/api/notification-copy', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ready: { title: 'x', body: 'y' } }) + }) + expect(res.status).toBe(403) + }) + + it('stores only the provided keys and preserves other settings', async () => { + const app = await createApp('default') + const res = await app.request('/api/notification-copy', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ready: { title: 'Hey', body: '{agentName}' } }) + }) + expect(res.status).toBe(200) + const settings = await readSettings() + expect(settings.cliApiToken).toBe('abc') + expect(settings.notificationCopy).toEqual({ ready: { title: 'Hey', body: '{agentName}' } }) + }) + + it('stores empty templates as reset-to-default markers', async () => { + const app = await createApp('default') + const res = await app.request('/api/notification-copy', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ready: { title: '', body: '' } }) + }) + expect(res.status).toBe(200) + const settings = await readSettings() + expect(settings.notificationCopy).toEqual({ ready: { title: '', body: '' } }) + }) + + it('rejects title over 500 chars', async () => { + const app = await createApp('default') + const res = await app.request('/api/notification-copy', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ready: { title: 'x'.repeat(501), body: 'y' } }) + }) + expect(res.status).toBe(400) + }) + + it('rejects invalid bodies', async () => { + const app = await createApp('default') + const res = await app.request('/api/notification-copy', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ready: { title: 42 } }) + }) + expect(res.status).toBe(400) + }) +}) diff --git a/hub/src/web/routes/notificationCopy.ts b/hub/src/web/routes/notificationCopy.ts new file mode 100644 index 00000000..3d8d3e90 --- /dev/null +++ b/hub/src/web/routes/notificationCopy.ts @@ -0,0 +1,69 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { getSettingsFile, readSettingsOrThrow, writeSettings } from '../../config/settings' +import { COPY_KEYS, DEFAULT_COPY, type CopyKey, type NotificationCopyConfig } from '../../push/notificationCopy' +import type { WebAppEnv } from '../middleware/auth' + +const copyTemplateSchema = z.object({ + title: z.string().max(500), + body: z.string().max(500) +}) + +const updateSchema = z.object({ + permissionRequest: copyTemplateSchema.optional(), + ready: copyTemplateSchema.optional(), + taskCompleted: copyTemplateSchema.optional(), + taskFailed: copyTemplateSchema.optional(), + sessionCompletion: copyTemplateSchema.optional() +}) + +function isAdmin(namespace: string): boolean { + return namespace === 'default' +} + +export function createNotificationCopyRoutes(dataDir: string): Hono { + const app = new Hono() + const settingsFile = getSettingsFile(dataDir) + + app.get('/notification-copy', async (c) => { + const namespace = c.get('namespace') + if (!isAdmin(namespace)) { + return c.json({ error: 'Forbidden: admin only' }, 403) + } + const settings = await readSettingsOrThrow(settingsFile) + return c.json({ + copy: settings.notificationCopy ?? {}, + defaults: DEFAULT_COPY + }) + }) + + app.put('/notification-copy', async (c) => { + const namespace = c.get('namespace') + if (!isAdmin(namespace)) { + return c.json({ error: 'Forbidden: admin only' }, 403) + } + 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) + } + + // Read-modify-write: preserve every other settings.json key. + const settings = await readSettingsOrThrow(settingsFile) + const copy: NotificationCopyConfig = {} + for (const key of COPY_KEYS) { + const template = parsed.data[key] + if (template) { + copy[key as CopyKey] = template + } + } + settings.notificationCopy = copy + await writeSettings(settingsFile, settings) + return c.json({ + copy, + defaults: DEFAULT_COPY + }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b6f86755..a946bf65 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -28,6 +28,7 @@ import { createPushRoutes } from './routes/push' import { createNotificationPreferencesRoutes } from './routes/notificationPreferences' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' +import { createNotificationCopyRoutes } from './routes/notificationCopy' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' import type { Server as BunServer, ServerWebSocket } from 'bun' @@ -263,6 +264,7 @@ function createWebApp(options: { })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey, options.pushService)) app.route('/api', createNotificationPreferencesRoutes(options.store)) + app.route('/api', createNotificationCopyRoutes(configuration.dataDir)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes()) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2a0ee5cc..6dd06128 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,6 +14,8 @@ import type { MachinesResponse, MessagesResponse, PermissionMode, + NotificationCopyConfig, + NotificationCopyResponse, NotificationPreferences, NotificationPreferencesUpdate, PushSubscriptionPayload, @@ -233,6 +235,17 @@ export class ApiClient { }) } + async getNotificationCopy(): Promise { + return await this.request('/api/notification-copy') + } + + async updateNotificationCopy(copy: NotificationCopyConfig): Promise { + return await this.request('/api/notification-copy', { + method: 'PUT', + body: JSON.stringify(copy) + }) + } + async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise { // 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。 return await this.request('/api/codex/sync-session', { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 34f6a339..36cc5b96 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -936,6 +936,20 @@ export default { 'settings.notifications.testPushSent': 'Test notification sent!', 'settings.notifications.testPushError': 'Failed to send test notification', 'settings.notifications.saveError': 'Failed to save preferences', + 'settings.notifications.copy.title': 'Push notification copy', + 'settings.notifications.copy.description': 'Customize the title and body of push notifications. {variable} placeholders are replaced when sending. Leave a field empty to use the default copy.', + 'settings.notifications.copy.titleLabel': 'Title', + 'settings.notifications.copy.bodyLabel': 'Body', + 'settings.notifications.copy.preview': 'Preview', + 'settings.notifications.copy.resetDefault': 'Reset to default', + 'settings.notifications.copy.save': 'Save copy', + 'settings.notifications.copy.saved': 'Copy saved', + 'settings.notifications.copy.testPushNote': 'The test push button always sends fixed copy.', + 'settings.notifications.copy.permissionRequest': 'Permission request', + 'settings.notifications.copy.ready': 'Session ready', + 'settings.notifications.copy.taskCompleted': 'Task completed', + 'settings.notifications.copy.taskFailed': 'Task failed', + 'settings.notifications.copy.sessionCompletion': 'Session completed', '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 a883e9f7..c5b5e73e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -935,6 +935,20 @@ export default { 'settings.notifications.testPushSent': '测试通知已发送!', 'settings.notifications.testPushError': '发送测试通知失败', 'settings.notifications.saveError': '保存偏好设置失败', + 'settings.notifications.copy.title': '推送文案', + 'settings.notifications.copy.description': '自定义推送通知的标题和正文。{variable} 占位符会在发送时替换。留空则使用默认文案。', + 'settings.notifications.copy.titleLabel': '标题', + 'settings.notifications.copy.bodyLabel': '正文', + 'settings.notifications.copy.preview': '预览', + 'settings.notifications.copy.resetDefault': '恢复默认', + 'settings.notifications.copy.save': '保存文案', + 'settings.notifications.copy.saved': '文案已保存', + 'settings.notifications.copy.testPushNote': '测试推送按钮始终发送固定文案。', + 'settings.notifications.copy.permissionRequest': '权限请求', + 'settings.notifications.copy.ready': '会话就绪', + 'settings.notifications.copy.taskCompleted': '任务完成', + 'settings.notifications.copy.taskFailed': '任务失败', + 'settings.notifications.copy.sessionCompletion': '会话完成', '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 533ce28e..abac062d 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -5,6 +5,7 @@ export const queryKeys = { machines: ['machines'] as const, sqliteStorage: ['sqlite-storage'] as const, notificationPreferences: ['notification-preferences'] as const, + notificationCopy: ['notification-copy'] 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/lib/template.ts b/web/src/lib/template.ts new file mode 100644 index 00000000..6f057717 --- /dev/null +++ b/web/src/lib/template.ts @@ -0,0 +1,10 @@ +/** + * Client-side mirror of hub/src/push/notificationCopy.ts renderTemplate — + * used for live preview of push copy templates. Unknown placeholders are + * left as-is so a typo stays visible instead of vanishing. + */ +export function renderTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + return key in vars ? vars[key] : match + }) +} diff --git a/web/src/routes/settings/notifications.test.tsx b/web/src/routes/settings/notifications.test.tsx index 4c234338..c3767dbf 100644 --- a/web/src/routes/settings/notifications.test.tsx +++ b/web/src/routes/settings/notifications.test.tsx @@ -13,21 +13,44 @@ const defaultPrefs = { updatedAt: Date.now(), } +const defaultCopyResponse = { + copy: {}, + defaults: { + permissionRequest: { title: 'Permission Request', body: '{sessionName}{tool}' }, + ready: { title: 'Ready for input', body: '{agentName} is waiting in {sessionName}' }, + taskCompleted: { title: 'Task completed', body: '{agentName} · {sessionName} · {summary}' }, + taskFailed: { title: 'Task failed', body: '{agentName} · {sessionName} · {summary}' }, + sessionCompletion: { title: 'Session completed', body: '{agentName} · {sessionName}' }, + }, +} + const getNotificationPreferences = vi.fn() const updateNotificationPreferences = vi.fn() const sendTestPush = vi.fn() +const getNotificationCopy = vi.fn() +const updateNotificationCopy = vi.fn() + +function makeToken(ns: string): string { + return `header.${btoa(JSON.stringify({ ns }))}.sig` +} + +let mockToken = makeToken('default') vi.mock('@/lib/app-context', () => ({ useAppContext: () => ({ - api: { getNotificationPreferences, updateNotificationPreferences, sendTestPush }, + api: { getNotificationPreferences, updateNotificationPreferences, sendTestPush, getNotificationCopy, updateNotificationCopy }, + token: mockToken, }), })) describe('SettingsNotificationsPage', () => { beforeEach(() => { + mockToken = makeToken('default') getNotificationPreferences.mockResolvedValue(defaultPrefs) updateNotificationPreferences.mockResolvedValue(defaultPrefs) sendTestPush.mockResolvedValue({ ok: true }) + getNotificationCopy.mockResolvedValue(defaultCopyResponse) + updateNotificationCopy.mockResolvedValue(defaultCopyResponse) }) afterEach(() => { @@ -89,4 +112,44 @@ describe('SettingsNotificationsPage', () => { expect(sendTestPush).toHaveBeenCalled() expect(await screen.findByText('Test notification sent!')).toBeTruthy() }) + + it('hides the copy section for non-admin namespaces', async () => { + mockToken = makeToken('user-1') + renderPage() + expect(screen.queryByText('Push notification copy')).toBeNull() + }) + + it('renders the copy section for the admin namespace', async () => { + renderPage() + expect(await screen.findByText('Push notification copy')).toBeTruthy() + expect(await screen.findByText('Session completed')).toBeTruthy() + }) + + it('saves edited copy through the API', async () => { + renderPage() + const titleInputs = await screen.findAllByLabelText('Title') + // Index 1 = "ready" block (after permissionRequest). + fireEvent.change(titleInputs[1], { target: { value: 'Custom {agentName}' } }) + const saveButton = screen.getByRole('button', { name: 'Save copy' }) + fireEvent.click(saveButton) + await waitFor(() => { + expect(updateNotificationCopy).toHaveBeenCalledWith({ + ready: { title: 'Custom {agentName}', body: '' }, + }) + }) + }) + + it('inserts a variable chip into the focused body field', async () => { + renderPage() + await screen.findByText('Push notification copy') + const chip = screen.getAllByText('{agentName}')[0] + fireEvent.click(chip) + const bodyInputs = screen.getAllByLabelText('Body') as HTMLTextAreaElement[] + expect(bodyInputs[0].value).toBe('{agentName}') + }) + + it('shows a live preview with sample values', async () => { + renderPage() + expect(await screen.findByText(/Claude is waiting in My Project/)).toBeTruthy() + }) }) diff --git a/web/src/routes/settings/notifications.tsx b/web/src/routes/settings/notifications.tsx index 0528f83d..276785d1 100644 --- a/web/src/routes/settings/notifications.tsx +++ b/web/src/routes/settings/notifications.tsx @@ -1,21 +1,64 @@ -import { useState } from 'react' +import { useEffect, useRef, 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 { renderTemplate } from '@/lib/template' import { useTranslation } from '@/lib/use-translation' -import type { NotificationPreferencesUpdate } from '@/types/api' +import type { CopyTemplate, NotificationCopyConfig, NotificationPreferencesUpdate } from '@/types/api' type ToggleKey = keyof NotificationPreferencesUpdate +type CopyKey = keyof NotificationCopyConfig + +const COPY_BLOCKS: Array<{ key: CopyKey; labelKey: string; variables: string[] }> = [ + { key: 'permissionRequest', labelKey: 'settings.notifications.copy.permissionRequest', variables: ['agentName', 'sessionName', 'tool', 'url'] }, + { key: 'ready', labelKey: 'settings.notifications.copy.ready', variables: ['agentName', 'sessionName', 'url'] }, + { key: 'taskCompleted', labelKey: 'settings.notifications.copy.taskCompleted', variables: ['agentName', 'sessionName', 'summary', 'status', 'url'] }, + { key: 'taskFailed', labelKey: 'settings.notifications.copy.taskFailed', variables: ['agentName', 'sessionName', 'summary', 'status', 'url'] }, + { key: 'sessionCompletion', labelKey: 'settings.notifications.copy.sessionCompletion', variables: ['agentName', 'sessionName', 'reason', 'url'] }, +] + +// Sample values used for the live preview only. +const PREVIEW_VARS: Record = { + agentName: 'Claude', + sessionName: 'My Project', + tool: ' (Bash)', + summary: 'Build the feature', + status: 'completed', + reason: 'completed', + url: '/sessions/abc123', +} + +function getNamespace(token: string): string | null { + try { + const payload = token.split('.')[1] + if (!payload) return null + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=') + const decoded = JSON.parse(atob(base64)) as { ns?: unknown } + return typeof decoded.ns === 'string' ? decoded.ns : null + } catch { + return null + } +} + +type FocusTarget = { + block: CopyKey + field: 'title' | 'body' + el: HTMLInputElement | HTMLTextAreaElement +} export default function SettingsNotificationsPage() { - const { api } = useAppContext() + const { api, token } = useAppContext() const { t } = useTranslation() const queryClient = useQueryClient() const [confirmDisablePermission, setConfirmDisablePermission] = useState(false) const [testPushLabel, setTestPushLabel] = useState(null) const [saveError, setSaveError] = useState(null) + const [copySaved, setCopySaved] = useState(false) + const focusedRef = useRef(null) + + const isAdmin = Boolean(token) && getNamespace(token) === 'default' const query = useQuery({ queryKey: queryKeys.notificationPreferences, @@ -42,6 +85,40 @@ export default function SettingsNotificationsPage() { }, }) + const copyQuery = useQuery({ + queryKey: queryKeys.notificationCopy, + queryFn: async () => { + if (!api) throw new Error('API unavailable') + return await api.getNotificationCopy() + }, + enabled: Boolean(api) && isAdmin, + staleTime: 0, + retry: false, + }) + + const [draft, setDraft] = useState({}) + const userEditedRef = useRef(false) + useEffect(() => { + // Initialize the draft from server copy, but never clobber edits made + // while the query was still resolving. + if (copyQuery.data && !userEditedRef.current) { + setDraft(copyQuery.data.copy) + } + }, [copyQuery.data]) + + const copyMutation = useMutation({ + mutationFn: async (copy: NotificationCopyConfig) => { + if (!api) throw new Error('API unavailable') + return await api.updateNotificationCopy(copy) + }, + onSuccess: (data) => { + queryClient.setQueryData(queryKeys.notificationCopy, data) + setDraft(data.copy) + setCopySaved(true) + setTimeout(() => setCopySaved(false), 3000) + }, + }) + const handleToggle = (key: ToggleKey) => (checked: boolean) => { // Turning off permission requests needs explicit confirmation — it // disables phone-side approval, the core remote-control flow. @@ -66,6 +143,49 @@ export default function SettingsNotificationsPage() { setTimeout(() => setTestPushLabel(null), 3000) } + const updateDraftField = (block: CopyKey, field: 'title' | 'body', value: string) => { + userEditedRef.current = true + setDraft((prev) => { + const current = prev[block] ?? { title: '', body: '' } + return { ...prev, [block]: { ...current, [field]: value } } + }) + } + + const insertVariable = (block: CopyKey, varName: string) => { + userEditedRef.current = true + const focused = focusedRef.current + const target = focused && focused.block === block ? focused : null + const field = target?.field ?? 'body' + setDraft((prev) => { + const current = prev[block] ?? { title: '', body: '' } + const value = current[field] + let next = `${value}{${varName}}` + if (target) { + const start = target.el.selectionStart ?? value.length + const end = target.el.selectionEnd ?? value.length + next = value.slice(0, start) + `{${varName}}` + value.slice(end) + } + return { ...prev, [block]: { ...current, [field]: next } } + }) + // Keep focus in the field after the re-render. + requestAnimationFrame(() => { + target?.el.focus() + }) + } + + const resolvePreview = (block: CopyKey): { title: string; body: string } => { + const d = draft[block] + const defaults = copyQuery.data?.defaults + const def = defaults ? defaults[block] as CopyTemplate | undefined : undefined + if (d && d.title.trim() && d.body.trim()) { + return { title: renderTemplate(d.title, PREVIEW_VARS), body: renderTemplate(d.body, PREVIEW_VARS) } + } + return { + title: renderTemplate(def?.title ?? '', PREVIEW_VARS), + body: renderTemplate(def?.body ?? '', PREVIEW_VARS) + } + } + const prefs = query.data return ( @@ -97,6 +217,88 @@ export default function SettingsNotificationsPage() { /> {saveError ? : null} + + {isAdmin ? ( + + {COPY_BLOCKS.map((block) => { + const current = draft[block.key] ?? { title: '', body: '' } + const preview = resolvePreview(block.key) + return ( +
+
{t(block.labelKey)}
+
+ {block.variables.map((variable) => ( + + ))} +
+ +