mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(hub,web): simplify notification copy configuration
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '预览',
|
||||
|
||||
@@ -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}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user