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 <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 d3544d557b
commit 5ff897a8bf
17 changed files with 1044 additions and 34 deletions
+14
View File
@@ -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',
+14
View File
@@ -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': '我的设备',
+1
View File
@@ -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,
+10
View File
@@ -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, string>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
return key in vars ? vars[key] : match
})
}