mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '我的设备',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user