From 137b3fc8ebd8190cdcafd0e857abaca4d5a05328 Mon Sep 17 00:00:00 2001 From: Guiluan <3460332279@qq.com> Date: Sat, 15 Aug 2026 11:22:18 +0800 Subject: [PATCH] fix(client): use per-turn tags and live permission checks The settings section captured Notification.permission once at mount, so a permission granted later in the browser UI left the test button silently disabled. The test and completion notifications also reused fixed tags: a stale same-tag entry lingering in the Windows notification center silently swallows every later notification with that tag. - Re-read permission on mount, focus, and visibility changes. - The test button re-checks the live permission at click time, requests it when missing, and shows an inline hint instead of a disabled dead button. - Test notifications use a unique tag per click; completion notifications use a per-session-per-turn tag, so notifications never replace each other. --- lib/client.js | 47 +++++++++++++++++++++++++++++----- src/client/SettingsSection.tsx | 41 ++++++++++++++++++++++++++--- src/client/index.ts | 6 ++++- src/client/locales.ts | 4 +++ src/client/notifier.ts | 12 ++++++--- src/client/runner.ts | 2 +- tests/notifier.spec.ts | 4 +-- 7 files changed, 99 insertions(+), 17 deletions(-) diff --git a/lib/client.js b/lib/client.js index 35aec5b..c85f467 100644 --- a/lib/client.js +++ b/lib/client.js @@ -53,8 +53,8 @@ function shouldShow(permission, backgroundOnly, documentHidden, completedSession if (backgroundOnly && !documentHidden && completedSessionId === currentSessionId) return false; return true; } -function notificationTag(sessionId) { - return `dsh-notification-${sessionId}`; +function notificationTag(sessionId, turn) { + return `dsh-notification-${sessionId}-${turn}`; } function notificationsApi() { return typeof Notification === "undefined" ? void 0 : Notification; @@ -196,8 +196,21 @@ function RuleRow(props) { function NotificationSettingsSection({ useSettings, set, requestPermission, sendTest, t }) { const settings = useSettings((snapshot) => snapshot); const [permission, setPermission] = (0, import_react.useState)(() => notificationsApi()?.permission ?? "denied"); + const [permissionHint, setPermissionHint] = (0, import_react.useState)(null); const [draft, setDraft] = (0, import_react.useState)(null); const [focusedRuleId, setFocusedRuleId] = (0, import_react.useState)(null); + (0, import_react.useEffect)(() => { + const refresh = () => { + 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; @@ -218,6 +231,20 @@ function NotificationSettingsSection({ useSettings, set, requestPermission, send }; const onRequestPermission = async () => { setPermission(await requestPermission()); + setPermissionHint(null); + }; + const onClickTest = async () => { + 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}`); const badgeClass = permission === "granted" ? "dsh_notification_badgeGranted" : permission === "denied" ? "dsh_notification_badgeDenied" : "dsh_notification_badgeDefault"; @@ -252,12 +279,14 @@ function NotificationSettingsSection({ useSettings, set, requestPermission, send { type: "button", className: "dsh_notification_button dsh_notification_buttonPrimary", - disabled: permission !== "granted", - onClick: sendTest, + onClick: () => { + void onClickTest(); + }, children: t("settings.permission.test") } ) - ] }) + ] }), + permissionHint === null ? null : (0, import_jsx_runtime.jsx)("span", { className: "dsh_notification_error", children: t(permissionHint) }) ] }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh_notification_card", children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [ @@ -353,6 +382,8 @@ var zh = { "settings.permission.granted": "\u5DF2\u6388\u6743", "settings.permission.denied": "\u5DF2\u62D2\u7EDD\uFF08\u8BF7\u5728\u6D4F\u89C8\u5668\u5730\u5740\u680F\u7684\u7AD9\u70B9\u8BBE\u7F6E\u4E2D\u91CD\u65B0\u5F00\u542F\uFF09", "settings.permission.default": "\u672A\u6388\u6743", + "settings.permission.defaultHint": "通知权限尚未授予:请先点击「请求通知权限」,并在浏览器弹出的提示中选择允许。", + "settings.permission.deniedHint": "通知权限已被拒绝:请在浏览器地址栏左侧的站点设置中重新开启通知,然后再试。", "settings.permission.request": "\u8BF7\u6C42\u901A\u77E5\u6743\u9650", "settings.permission.test": "\u53D1\u9001\u6D4B\u8BD5\u901A\u77E5", "settings.when.title": "\u901A\u77E5\u65F6\u673A", @@ -402,6 +433,8 @@ var 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", @@ -803,7 +836,7 @@ function notificationFor(sessionId, origin, title, projection, settings) { return { reason, body: projection?.body ?? title ?? "", - tag: notificationTag(sessionId) + tag: notificationTag(sessionId, projection?.turn ?? 0) }; } @@ -831,7 +864,7 @@ function apply(ctx) { }; }; const sendTest = () => { - show(t("notify.testTitle"), t("notify.testBody"), "dsh-notification-test", false); + show(t("notify.testTitle"), t("notify.testBody"), "dsh-notification-test-" + Date.now(), false); }; ctx.effect(() => { const observedTurn = /* @__PURE__ */ new Map(); diff --git a/src/client/SettingsSection.tsx b/src/client/SettingsSection.tsx index abe682b..dd489f0 100644 --- a/src/client/SettingsSection.tsx +++ b/src/client/SettingsSection.tsx @@ -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' @@ -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(() => notificationsApi()?.permission ?? 'denied') + const [permissionHint, setPermissionHint] = useState(null) const [draft, setDraft] = useState(null) const [focusedRuleId, setFocusedRuleId] = useState(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 @@ -156,6 +173,24 @@ export function NotificationSettingsSection({ useSettings, set, requestPermissio } const onRequestPermission = async (): Promise => { 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 => { + 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}`) @@ -192,12 +227,12 @@ export function NotificationSettingsSection({ useSettings, set, requestPermissio + {permissionHint === null ? null : {t(permissionHint)}}
diff --git a/src/client/index.ts b/src/client/index.ts index 2965b01..2aaf710 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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, diff --git a/src/client/locales.ts b/src/client/locales.ts index cf5a65b..d2ef43d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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': '通知时机', @@ -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', diff --git a/src/client/notifier.ts b/src/client/notifier.ts index aafe854..8e4e4f9 100644 --- a/src/client/notifier.ts +++ b/src/client/notifier.ts @@ -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). */ diff --git a/src/client/runner.ts b/src/client/runner.ts index 4dc22f2..955f4a1 100644 --- a/src/client/runner.ts +++ b/src/client/runner.ts @@ -64,6 +64,6 @@ export function notificationFor( return { reason, body: projection?.body ?? title ?? '', - tag: notificationTag(sessionId), + tag: notificationTag(sessionId, projection?.turn ?? 0), } } diff --git a/tests/notifier.spec.ts b/tests/notifier.spec.ts index 3450a23..1d234d2 100644 --- a/tests/notifier.spec.ts +++ b/tests/notifier.spec.ts @@ -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') }) })