fix(hub,web): simplify notification copy configuration

This commit is contained in:
2026-08-05 11:16:17 +08:00
parent 8fc545f72f
commit 896e5d3dac
6 changed files with 166 additions and 114 deletions
+1 -1
View File
@@ -237,7 +237,7 @@ function createWebApp(options: {
const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins
const corsMiddleware = cors({
origin: corsOriginOption,
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['authorization', 'content-type']
})
app.use('/api/*', corsMiddleware)
@@ -19,6 +19,14 @@ export function ChevronRightIcon(props: { className?: string }) {
)
}
export function ChevronDownIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={props.className} aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
)
}
export function CheckIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={props.className} aria-hidden="true">
+1 -1
View File
@@ -937,7 +937,7 @@ export default {
'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.description': 'Notification titles and messages.',
'settings.notifications.copy.titleLabel': 'Title',
'settings.notifications.copy.bodyLabel': 'Body',
'settings.notifications.copy.preview': 'Preview',
+1 -1
View File
@@ -936,7 +936,7 @@ export default {
'settings.notifications.testPushError': '发送测试通知失败',
'settings.notifications.saveError': '保存偏好设置失败',
'settings.notifications.copy.title': '推送文案',
'settings.notifications.copy.description': '自定义推送通知标题正文。{variable} 占位符会在发送时替换。留空则使用默认文案。',
'settings.notifications.copy.description': '通知标题正文。',
'settings.notifications.copy.titleLabel': '标题',
'settings.notifications.copy.bodyLabel': '正文',
'settings.notifications.copy.preview': '预览',
+45 -16
View File
@@ -125,39 +125,68 @@ describe('SettingsNotificationsPage', () => {
expect(await screen.findByText('Session completed')).toBeTruthy()
})
it('does not expose the editor before existing copy is loaded', async () => {
let resolveCopy: (value: typeof defaultCopyResponse) => void = () => {}
getNotificationCopy.mockReturnValue(new Promise((resolve) => {
resolveCopy = resolve
}))
renderPage()
await screen.findByLabelText('Permission requests')
expect(screen.queryByText('Push notification copy')).toBeNull()
resolveCopy(defaultCopyResponse)
expect(await screen.findByText('Push notification copy')).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 readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ })
fireEvent.click(readyRow)
fireEvent.change(screen.getByLabelText('Title'), { 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: '' },
ready: {
title: 'Custom {agentName}',
body: '{agentName} is waiting in {sessionName}'
},
})
})
})
it('inserts a variable chip into the focused body field', async () => {
it('keeps copy editors collapsed and prefills defaults when opened', 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}')
const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ })
expect(screen.queryByLabelText('Title')).toBeNull()
fireEvent.click(readyRow)
expect(screen.getByLabelText('Title')).toHaveValue('Ready for input')
expect(screen.getByLabelText('Body')).toHaveValue('{agentName} is waiting in {sessionName}')
})
it('shows a live preview with sample values', async () => {
it('updates the collapsed summary while editing', async () => {
renderPage()
expect(await screen.findByText(/Claude is waiting in My Project/)).toBeTruthy()
const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ })
fireEvent.click(readyRow)
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Custom {agentName}' } })
expect(await screen.findByRole('button', { name: /Custom Claude.*Claude is waiting in My Project/ })).toBeTruthy()
})
it('previews a title-only override with the default body', async () => {
it('restores a customized block to its visible defaults', async () => {
getNotificationCopy.mockResolvedValue({
...defaultCopyResponse,
copy: {
ready: { title: 'Custom title', body: 'Custom body' }
}
})
renderPage()
const titleInputs = await screen.findAllByLabelText('Title')
fireEvent.change(titleInputs[1], { target: { value: 'Custom {agentName}' } })
const readyRow = await screen.findByRole('button', { name: /Session ready.*Custom title.*Custom body/ })
fireEvent.click(readyRow)
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }))
expect(await screen.findByText(/Custom Claude.*Claude is waiting in My Project/)).toBeTruthy()
expect(screen.getByLabelText('Title')).toHaveValue('Ready for input')
expect(screen.getByLabelText('Body')).toHaveValue('{agentName} is waiting in {sessionName}')
})
})
+110 -95
View File
@@ -1,7 +1,7 @@
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 { ChevronDownIcon, 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'
@@ -11,12 +11,12 @@ import type { CopyTemplate, NotificationCopyConfig, NotificationPreferencesUpdat
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'] },
const COPY_BLOCKS: Array<{ key: CopyKey; labelKey: string }> = [
{ key: 'permissionRequest', labelKey: 'settings.notifications.copy.permissionRequest' },
{ key: 'ready', labelKey: 'settings.notifications.copy.ready' },
{ key: 'taskCompleted', labelKey: 'settings.notifications.copy.taskCompleted' },
{ key: 'taskFailed', labelKey: 'settings.notifications.copy.taskFailed' },
{ key: 'sessionCompletion', labelKey: 'settings.notifications.copy.sessionCompletion' },
]
// Sample values used for the live preview only.
@@ -42,10 +42,41 @@ function getNamespace(token: string): string | null {
}
}
type FocusTarget = {
block: CopyKey
field: 'title' | 'body'
el: HTMLInputElement | HTMLTextAreaElement
function resolveCopyTemplate(
copy: NotificationCopyConfig,
defaults: Record<string, CopyTemplate>,
key: CopyKey
): CopyTemplate {
const template = copy[key]
const fallback = defaults[key]
return {
title: template?.title.trim() ? template.title : (fallback?.title ?? ''),
body: template?.body.trim() ? template.body : (fallback?.body ?? '')
}
}
function resolveEffectiveCopy(
copy: NotificationCopyConfig,
defaults: Record<string, CopyTemplate>
): NotificationCopyConfig {
return Object.fromEntries(
COPY_BLOCKS.map(({ key }) => [key, resolveCopyTemplate(copy, defaults, key)])
) as NotificationCopyConfig
}
function getCopyOverrides(
draft: NotificationCopyConfig,
defaults: Record<string, CopyTemplate>
): NotificationCopyConfig {
const overrides: NotificationCopyConfig = {}
for (const { key } of COPY_BLOCKS) {
const template = resolveCopyTemplate(draft, defaults, key)
const fallback = defaults[key]
if (!fallback || template.title !== fallback.title || template.body !== fallback.body) {
overrides[key] = template
}
}
return overrides
}
export default function SettingsNotificationsPage() {
@@ -56,7 +87,7 @@ export default function SettingsNotificationsPage() {
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 [openCopyBlock, setOpenCopyBlock] = useState<CopyKey | null>(null)
const isAdmin = Boolean(token) && getNamespace(token) === 'default'
@@ -102,7 +133,7 @@ export default function SettingsNotificationsPage() {
// 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)
setDraft(resolveEffectiveCopy(copyQuery.data.copy, copyQuery.data.defaults))
}
}, [copyQuery.data])
@@ -113,7 +144,7 @@ export default function SettingsNotificationsPage() {
},
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.notificationCopy, data)
setDraft(data.copy)
setDraft(resolveEffectiveCopy(data.copy, data.defaults))
setCopySaved(true)
setTimeout(() => setCopySaved(false), 3000)
},
@@ -151,25 +182,11 @@ export default function SettingsNotificationsPage() {
})
}
const insertVariable = (block: CopyKey, varName: string) => {
const resetDraftBlock = (block: CopyKey) => {
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 fallback = copyQuery.data?.defaults[block]
return fallback ? { ...prev, [block]: fallback } : prev
})
}
@@ -215,84 +232,82 @@ export default function SettingsNotificationsPage() {
{saveError ? <SettingsRow label={saveError} /> : null}
</SettingsSection>
{isAdmin ? (
{isAdmin && copyQuery.data ? (
<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 current = resolveCopyTemplate(draft, copyQuery.data?.defaults ?? {}, block.key)
const preview = resolvePreview(block.key)
const isOpen = openCopyBlock === block.key
const editorId = `notification-copy-${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>
<div key={block.key}>
<button
type="button"
onClick={() => {
updateDraftField(block.key, 'title', '')
updateDraftField(block.key, 'body', '')
}}
className="text-xs text-[var(--app-link)] hover:underline"
onClick={() => setOpenCopyBlock((currentBlock) => currentBlock === block.key ? null : block.key)}
aria-expanded={isOpen}
aria-controls={editorId}
className="flex min-h-14 w-full items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
>
{t('settings.notifications.copy.resetDefault')}
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[var(--app-fg)]">{t(block.labelKey)}</span>
<span className="mt-0.5 block truncate text-xs text-[var(--app-hint)]">
{preview.title} · {preview.body}
</span>
</span>
<ChevronDownIcon className={`h-4 w-4 shrink-0 text-[var(--app-hint)] transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen ? (
<div id={editorId} className="space-y-3 border-t border-[var(--app-divider)] bg-[var(--app-subtle-bg)]/40 px-3 py-3">
<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)}
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)}
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="flex justify-end">
<button
type="button"
onClick={() => resetDraftBlock(block.key)}
className="text-xs text-[var(--app-link)] hover:underline"
>
{t('settings.notifications.copy.resetDefault')}
</button>
</div>
</div>
) : null}
</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>
)}
/>
<div className="flex min-h-14 items-center justify-between gap-3 px-3 py-3">
<span className="text-xs text-[var(--app-hint)]" aria-live="polite">
{copySaved ? t('settings.notifications.copy.saved') : ''}
</span>
<button
type="button"
onClick={() => copyMutation.mutate(getCopyOverrides(draft, copyQuery.data?.defaults ?? {}))}
disabled={copyMutation.isPending || !copyQuery.data}
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>
</div>
</SettingsSection>
) : null}