Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions lib/client.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 38 additions & 3 deletions src/client/SettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* a local draft persisted as one array on save, so an in-progress
* (empty-pattern) rule never reaches the store.
*/
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { PropsLocale, PropsRuntime, InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { NotificationRule, NotificationSettings } from '../contract.ts'
Expand Down Expand Up @@ -132,9 +132,26 @@ function RuleRow(props: {
export function NotificationSettingsSection({ useSettings, set, requestPermission, sendTest, t }: NotificationSectionProps) {
const settings = useSettings(snapshot => snapshot)
const [permission, setPermission] = useState<NotificationPermission>(() => notificationsApi()?.permission ?? 'denied')
const [permissionHint, setPermissionHint] = useState<NotificationKey | null>(null)
const [draft, setDraft] = useState<NotificationRule[] | null>(null)
const [focusedRuleId, setFocusedRuleId] = useState<string | null>(null)

// The browser permission can change outside this section (address-bar site
// settings, a prompt granted elsewhere). The captured snapshot goes stale,
// so re-read it on mount, on window focus, and on visibility changes.
useEffect(() => {
const refresh = (): void => {
setPermission(notificationsApi()?.permission ?? 'denied')
}
refresh()
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refresh)
return () => {
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refresh)
}
}, [])

const durable = settings?.rules ?? []
const rules = draft ?? durable
const dirty = draft !== null
Expand All @@ -156,6 +173,24 @@ export function NotificationSettingsSection({ useSettings, set, requestPermissio
}
const onRequestPermission = async (): Promise<void> => {
setPermission(await requestPermission())
setPermissionHint(null)
}

// The test button never silently no-ops: it re-checks the live permission at
// click time, requests it when missing, and always explains why a test could
// not be sent instead of being a disabled dead button.
const onClickTest = async (): Promise<void> => {
let current = notificationsApi()?.permission ?? 'denied'
if (current !== 'granted') {
current = await requestPermission()
setPermission(current)
}
if (current !== 'granted') {
setPermissionHint(current === 'denied' ? 'settings.permission.deniedHint' : 'settings.permission.defaultHint')
return
}
setPermissionHint(null)
sendTest()
}

const permissionText = t(`settings.permission.${permission}`)
Expand Down Expand Up @@ -192,12 +227,12 @@ export function NotificationSettingsSection({ useSettings, set, requestPermissio
<button
type="button"
className="dsh_notification_button dsh_notification_buttonPrimary"
disabled={permission !== 'granted'}
onClick={sendTest}
onClick={() => { void onClickTest() }}
>
{t('settings.permission.test')}
</button>
</div>
{permissionHint === null ? null : <span className="dsh_notification_error">{t(permissionHint)}</span>}
</div>

<div className="dsh_notification_card">
Expand Down
6 changes: 5 additions & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export function apply(ctx: ClientContext): void {
notification.onclick = () => { window.focus() }
}
const sendTest = (): void => {
show(t('notify.testTitle'), t('notify.testBody'), 'dsh-notification-test', false)
// A unique tag per click: the browser replaces same-tag notifications, and a
// stale same-tag entry lingering in the Windows notification center silently
// swallows every later notification with that tag. A fresh tag per test
// guarantees the toast always shows.
show(t('notify.testTitle'), t('notify.testBody'), `dsh-notification-test-${Date.now()}`, false)
}

// Completion runner: the host projection's turn is monotonic per session,
Expand Down
4 changes: 4 additions & 0 deletions src/client/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const zh = {
'settings.permission.granted': '已授权',
'settings.permission.denied': '已拒绝(请在浏览器地址栏的站点设置中重新开启)',
'settings.permission.default': '未授权',
'settings.permission.defaultHint': '通知权限尚未授予:请先点击「请求通知权限」,并在浏览器弹出的提示中选择允许。',
'settings.permission.deniedHint': '通知权限已被拒绝:请在浏览器地址栏左侧的站点设置中重新开启通知,然后再试。',
'settings.permission.request': '请求通知权限',
'settings.permission.test': '发送测试通知',
'settings.when.title': '通知时机',
Expand Down Expand Up @@ -69,6 +71,8 @@ export const en = {
'settings.permission.granted': 'Granted',
'settings.permission.denied': 'Denied (re-enable in the browser\'s site settings)',
'settings.permission.default': 'Not granted',
'settings.permission.defaultHint': 'Notification permission is not granted yet: click Request permission and allow it in the browser prompt.',
'settings.permission.deniedHint': 'Notification permission was denied: re-enable notifications for this site in the browser\'s site settings, then try again.',
'settings.permission.request': 'Request permission',
'settings.permission.test': 'Send test notification',
'settings.when.title': 'When to notify',
Expand Down
12 changes: 9 additions & 3 deletions src/client/notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ export function shouldShow(
return true
}

/** The grouping tag: one notification slot per session. */
export function notificationTag(sessionId: string): string {
return `dsh-notification-${sessionId}`
/**
* The grouping tag: one notification slot per session per turn. Turn-scoped
* (not session-scoped): the browser replaces same-tag notifications, and a
* stale same-tag entry lingering in the Windows notification center silently
* swallows every later notification with that tag — a per-turn tag guarantees
* each completed turn's toast always shows.
*/
export function notificationTag(sessionId: string, turn: number): string {
return `dsh-notification-${sessionId}-${turn}`
}

/** The surface this code may show notifications on (absent in insecure contexts). */
Expand Down
2 changes: 1 addition & 1 deletion src/client/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ export function notificationFor(
return {
reason,
body: projection?.body ?? title ?? '',
tag: notificationTag(sessionId),
tag: notificationTag(sessionId, projection?.turn ?? 0),
}
}
4 changes: 2 additions & 2 deletions tests/notifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('shouldShow', () => {
})

describe('notificationTag', () => {
it('namespaces the tag per session', () => {
expect(notificationTag('session-1')).toBe('dsh-notification-session-1')
it('namespaces the tag per session and turn', () => {
expect(notificationTag('session-1', 3)).toBe('dsh-notification-session-1-3')
})
})