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
+13
View File
@@ -14,6 +14,8 @@ import type {
MachinesResponse,
MessagesResponse,
PermissionMode,
NotificationCopyConfig,
NotificationCopyResponse,
NotificationPreferences,
NotificationPreferencesUpdate,
PushSubscriptionPayload,
@@ -233,6 +235,17 @@ export class ApiClient {
})
}
async getNotificationCopy(): Promise<NotificationCopyResponse> {
return await this.request<NotificationCopyResponse>('/api/notification-copy')
}
async updateNotificationCopy(copy: NotificationCopyConfig): Promise<NotificationCopyResponse> {
return await this.request<NotificationCopyResponse>('/api/notification-copy', {
method: 'PUT',
body: JSON.stringify(copy)
})
}
async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise<CodexDesktopScriptResponse> {
// 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。
return await this.request<CodexDesktopScriptResponse>('/api/codex/sync-session', {
+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
})
}
+64 -1
View File
@@ -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()
})
})
+205 -3
View File
@@ -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<string, string> = {
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<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [copySaved, setCopySaved] = useState(false)
const focusedRef = useRef<FocusTarget | null>(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<NotificationCopyConfig>({})
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 ? <SettingsRow label={saveError} /> : null}
</SettingsSection>
{isAdmin ? (
<SettingsSection
title={t('settings.notifications.copy.title')}
description={t('settings.notifications.copy.description')}
>
{COPY_BLOCKS.map((block) => {
const current = draft[block.key] ?? { title: '', body: '' }
const preview = resolvePreview(block.key)
return (
<div key={block.key} className="px-3 py-3 space-y-2">
<div className="text-sm font-medium text-[var(--app-fg)]">{t(block.labelKey)}</div>
<div className="flex flex-wrap gap-1">
{block.variables.map((variable) => (
<button
key={variable}
type="button"
onClick={() => insertVariable(block.key, variable)}
className="rounded border border-[var(--app-border)] px-1.5 py-0.5 text-xs text-[var(--app-link)] hover:bg-[var(--app-subtle-bg)]"
>
{'{'}
{variable}
{'}'}
</button>
))}
</div>
<label className="block">
<span className="text-xs text-[var(--app-hint)]">{t('settings.notifications.copy.titleLabel')}</span>
<input
type="text"
value={current.title}
maxLength={500}
onChange={(event) => updateDraftField(block.key, 'title', event.target.value)}
onFocus={(event) => { focusedRef.current = { block: block.key, field: 'title', el: event.currentTarget } }}
onBlur={() => { if (focusedRef.current?.block === block.key && focusedRef.current.field === 'title') focusedRef.current = null }}
className="mt-1 w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)]"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--app-hint)]">{t('settings.notifications.copy.bodyLabel')}</span>
<textarea
value={current.body}
maxLength={500}
rows={2}
onChange={(event) => updateDraftField(block.key, 'body', event.target.value)}
onFocus={(event) => { focusedRef.current = { block: block.key, field: 'body', el: event.currentTarget } }}
onBlur={() => { if (focusedRef.current?.block === block.key && focusedRef.current.field === 'body') focusedRef.current = null }}
className="mt-1 w-full resize-y rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)]"
/>
</label>
<div className="text-xs text-[var(--app-hint)]">
{t('settings.notifications.copy.preview')}: {preview.title} {preview.body}
</div>
<button
type="button"
onClick={() => {
updateDraftField(block.key, 'title', '')
updateDraftField(block.key, 'body', '')
}}
className="text-xs text-[var(--app-link)] hover:underline"
>
{t('settings.notifications.copy.resetDefault')}
</button>
</div>
)
})}
<SettingsRow
label={copySaved ? t('settings.notifications.copy.saved') : t('settings.notifications.copy.testPushNote')}
trailing={(
<button
type="button"
onClick={() => copyMutation.mutate(draft)}
disabled={copyMutation.isPending}
className="rounded-lg bg-[var(--app-button)] px-3 py-2 text-sm font-medium text-[var(--app-button-text)] disabled:opacity-50"
>
{t('settings.notifications.copy.save')}
</button>
)}
/>
</SettingsSection>
) : null}
<button
type="button"
onClick={() => void sendTestPush()}
+18
View File
@@ -193,6 +193,24 @@ export type TestPushResponse =
| { ok: true }
| { error: string }
export type CopyTemplate = {
title: string
body: string
}
export type NotificationCopyConfig = Partial<{
permissionRequest: CopyTemplate
ready: CopyTemplate
taskCompleted: CopyTemplate
taskFailed: CopyTemplate
sessionCompletion: CopyTemplate
}>
export type NotificationCopyResponse = {
copy: NotificationCopyConfig
defaults: Record<string, CopyTemplate>
}
export type CodexDesktopScriptResponse = {
success: boolean
message?: string