From 68ab60273e5ae915816925e996c5974620a5741a Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Sun, 4 Oct 2026 19:01:22 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(client):=20?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=8D=8A=E6=8B=86=E6=88=90=20src=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8Cclient.js=20=E6=94=B9=E7=94=B1?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=BA=A7=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- client.js | 2592 +++++++++++++---------------- package.json | 14 +- pnpm-lock.yaml | 285 ++++ scripts/build-client.mjs | 155 ++ src/client/constants.js | 49 + src/client/core/actions.js | 87 + src/client/core/copy.js | 68 + src/client/core/delivery.js | 134 ++ src/client/core/log.js | 19 + src/client/core/observe.js | 113 ++ src/client/core/replay.js | 85 + src/client/core/session.js | 75 + src/client/core/storage.js | 43 + src/client/core/store.js | 25 + src/client/core/system-channel.js | 112 ++ src/client/core/toasts.js | 80 + src/client/i18n/en.js | 67 + src/client/i18n/index.js | 73 + src/client/i18n/zh.js | 67 + src/client/index.js | 25 + src/client/platform.js | 105 ++ src/client/plugin.js | 216 +++ src/client/ui/ConfigSection.js | 98 ++ src/client/ui/KindRow.js | 25 + src/client/ui/NotifyObserver.js | 42 + src/client/ui/ToastLayer.js | 62 + src/client/ui/hooks.js | 49 + src/client/ui/icons.js | 72 + src/client/ui/styles.js | 59 + 30 files changed, 3506 insertions(+), 1394 deletions(-) create mode 100644 pnpm-lock.yaml create mode 100644 scripts/build-client.mjs create mode 100644 src/client/constants.js create mode 100644 src/client/core/actions.js create mode 100644 src/client/core/copy.js create mode 100644 src/client/core/delivery.js create mode 100644 src/client/core/log.js create mode 100644 src/client/core/observe.js create mode 100644 src/client/core/replay.js create mode 100644 src/client/core/session.js create mode 100644 src/client/core/storage.js create mode 100644 src/client/core/store.js create mode 100644 src/client/core/system-channel.js create mode 100644 src/client/core/toasts.js create mode 100644 src/client/i18n/en.js create mode 100644 src/client/i18n/index.js create mode 100644 src/client/i18n/zh.js create mode 100644 src/client/index.js create mode 100644 src/client/platform.js create mode 100644 src/client/plugin.js create mode 100644 src/client/ui/ConfigSection.js create mode 100644 src/client/ui/KindRow.js create mode 100644 src/client/ui/NotifyObserver.js create mode 100644 src/client/ui/ToastLayer.js create mode 100644 src/client/ui/hooks.js create mode 100644 src/client/ui/icons.js create mode 100644 src/client/ui/styles.js diff --git a/.gitignore b/.gitignore index e008e31..a2ff268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -# 忽略安装与构建产物;这个包没有依赖,也没有构建步骤。 +# 忽略安装产物。注意 client.js 虽然也是构建产物,但它是有意提交的(见 README 的「仓库结构」), +# 因为安装这个插件的人不该被要求装依赖、跑构建。 node_modules/ -pnpm-lock.yaml *.tgz .DS_Store Thumbs.db diff --git a/client.js b/client.js index e1d5391..1ff47e7 100644 --- a/client.js +++ b/client.js @@ -1,193 +1,881 @@ -/** - * Browser half of the session-notify bundle. - * - * One job: tell the user when a conversation needs attention. Three triggers — - * a conversation that stopped running, a pending tool approval, and a pending - * question or plan review — are derived from the client's own session state, and - * each one is delivered exactly once, through exactly one channel: - * - * - the Harness window not in the foreground → a system notification, raised - * through the page's Web `Notification` constructor. That is the only - * system-notification channel a plugin can reach: `@deepseek-ai/dsh-desktop-host` - * runs the Host half in a plain Node process rather than in Electron's main - * process, so the Host side has no `Notification` to call. The same Web API - * backs Windows, macOS, and Linux inside the Desktop shell, so delivery has no - * platform branch at all — only the settings row's *help text* names the - * platform's own notification settings. - * - the window in the foreground, and the finished conversation not the one on - * screen → a light in-app popup, because a desktop notification while you are - * looking at the app is noise. - * - * State comes exclusively from the slot props' standard selector hooks - * (`useSessions`, `useSessionStatus`) — the documented way for a plugin to read - * session data. Nothing here polls, folds session events, or reaches into - * another plugin. Styles are this package's own, following the shipped toast and - * switch surfaces and referencing theme tokens only, because a plugin must not - * import a Harness Client package. - * - * @module @dsh-plugin/session-notify/client - */ - +// Generated by scripts/build-client.mjs — do not edit this file. +// Source of truth: src/client/**. Run `pnpm run build` after changing it. +// Served by dsh-client-modules at /plugins/@dsh-plugin/session-notify/client.js. window.__ModuleLoader__.load({ id: '@dsh-plugin/session-notify', factory(require) { - const React = require('react') - const h = React.createElement + const module = { exports: {} } + // Same object as `module.exports`; kept so the emitted body may assign + // through either binding. + const exports = module.exports - /** Locale namespace owned by this package. */ - const NS = 'session-notify' - /** This package's name: the plugin manager keys a bundle's configuration by it. */ - const PACKAGE_NAME = '@dsh-plugin/session-notify' - /** Trigger ids shared by the store, the copy table, and the settings row. */ - const KINDS = ['completion', 'approval', 'question'] - /** Pending-interaction kinds the Harness itself renders, and this plugin serves. */ - const PENDING_KINDS = ['approval', 'question', 'plan-review'] - /** Shortest gap between two deliveries, so parallel finishes cannot flood. */ - const THROTTLE_MS = 1500 - /** Settle delay before a completion is delivered, so a resumed run stays silent. */ - const SETTLE_MS = 400 - /** In-app popup lifetime, and how many may stack. */ - const TOAST_DURATION_MS = 6000 - const TOAST_LIMIT = 3 - /** Notifications kept referenced, so a collection can never cancel a pending display. */ - const RAISED_LIMIT = 8 - /** Alerts carried by the system channel and still worth replaying in-app on return. */ - const REPLAY_LIMIT = 8 - /** How long a system notification outlives the moment the user comes back. */ - const REPLAY_TTL_MS = 30 * 60 * 1000 - /** - * A confirmed banner that the user came back to within this window is treated as - * seen: the desktop notification is still on screen at that moment, so replaying - * it in-app would be a duplicate rather than a reminder. - */ - const REPLAY_QUIET_MS = 20 * 1000 +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - const zh = { - 'notification.completion': '会话已完成', - 'notification.approval': '需要授权', - 'notification.question': '需要回答', - 'body.completion': '{title} 已完成这一轮回答', - 'body.approval': '{title}:工具 {tool} 正在等待你的授权', - 'body.approvalPlain': '{title}:有工具正在等待你的授权', - 'body.question': '{title}:正在等待你的回答', - 'body.planReview': '{title}:计划正在等待你确认', - 'body.untitled': '未命名会话', - 'toast.view': '查看', - 'toast.dismiss': '关闭', - 'settings.title': '会话通知', - 'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。', - 'settings.permission.granted': '系统通知已开启', - 'settings.permission.default': '尚未授予系统通知权限', - 'settings.permission.denied': '系统通知已被系统设置关闭', - 'settings.permission.unsupported': '当前环境不支持系统通知,仍会显示应用内轻弹窗', - 'settings.permission.hint.plain': '请在系统通知设置中允许 DeepSeek Harness', - 'settings.permission.hint.win': 'Windows 设置 → 系统 → 通知 → DeepSeek Harness', - 'settings.permission.hint.mac': '系统设置 → 通知 → DeepSeek Harness', - 'settings.permission.hint.linux': '在桌面环境的通知设置(GNOME / KDE)中允许 DeepSeek Harness', - 'settings.action.allow': '允许通知', - 'settings.action.testSystem': '测试系统通知', - 'settings.action.testAny': '测试提醒', - 'settings.action.unsupported': '不可用', - 'settings.permission.promptFailed': '无法请求通知权限:{message}', - 'settings.health.watching': '正在监听会话状态', - 'settings.health.noHooks': '未收到会话状态(通知不会触发)', - 'settings.health.error': '监听会话状态出错:{message}', - 'settings.health.starting': '正在等待会话列表', - 'settings.kinds.label': '提醒内容', - 'settings.kind.completion': '完成', - 'settings.kind.approval': '授权', - 'settings.kind.question': '提问', - 'config.section.kinds': '提醒内容', - 'config.section.permission': '系统通知权限', - 'config.section.status': '运行状态', - 'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。', - 'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。', - 'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。', - 'config.testAny': '测试提醒', - 'config.testSystem': '测试系统通知', - 'config.diag.window': '窗口状态', - 'config.diag.inFront': '在前台(会走轻弹窗)', - 'config.diag.away': '不在前台(会走系统通知)', - 'config.diag.last': '最近一次投递', - 'config.diag.none': '还没有投递过', - 'config.diag.systemShown': '系统通知(系统已确认弹出)', - 'config.diag.systemUnconfirmed': '系统通知(系统没有回报,回到窗口时补发轻弹窗)', - 'config.diag.systemFailed': '系统通知没弹出来,回到窗口时补发轻弹窗', - 'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)', - 'config.diag.popup': '应用内轻弹窗', - 'config.diag.refused': '系统通知被拒绝,改用了轻弹窗', - 'config.diag.replay': '等你回到窗口时补发:{count} 条', - 'config.diag.replayNone': '没有待补发的提醒', +// src/client/index.js +var index_exports = {}; +__export(index_exports, { + apply: () => apply, + inject: () => inject +}); +module.exports = __toCommonJS(index_exports); + +// src/client/constants.js +var NS = "session-notify"; +var PACKAGE_NAME = "@dsh-plugin/session-notify"; +var KINDS = ["completion", "approval", "question"]; +var PENDING_KINDS = ["approval", "question", "plan-review"]; +var THROTTLE_MS = 1500; +var SETTLE_MS = 400; +var TOAST_DURATION_MS = 6e3; +var TOAST_LIMIT = 3; +var RAISED_LIMIT = 8; +var REPLAY_LIMIT = 8; +var REPLAY_TTL_MS = 30 * 60 * 1e3; +var REPLAY_QUIET_MS = 20 * 1e3; + +// src/client/i18n/zh.js +var zh = { + "notification.completion": "会话已完成", + "notification.approval": "需要授权", + "notification.question": "需要回答", + "body.completion": "{title} 已完成这一轮回答", + "body.approval": "{title}:工具 {tool} 正在等待你的授权", + "body.approvalPlain": "{title}:有工具正在等待你的授权", + "body.question": "{title}:正在等待你的回答", + "body.planReview": "{title}:计划正在等待你确认", + "body.untitled": "未命名会话", + "toast.view": "查看", + "toast.dismiss": "关闭", + "settings.title": "会话通知", + "settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。", + "settings.permission.granted": "系统通知已开启", + "settings.permission.default": "尚未授予系统通知权限", + "settings.permission.denied": "系统通知已被系统设置关闭", + "settings.permission.unsupported": "当前环境不支持系统通知,仍会显示应用内轻弹窗", + "settings.permission.hint.plain": "请在系统通知设置中允许 DeepSeek Harness", + "settings.permission.hint.win": "Windows 设置 → 系统 → 通知 → DeepSeek Harness", + "settings.permission.hint.mac": "系统设置 → 通知 → DeepSeek Harness", + "settings.permission.hint.linux": "在桌面环境的通知设置(GNOME / KDE)中允许 DeepSeek Harness", + "settings.action.allow": "允许通知", + "settings.action.testSystem": "测试系统通知", + "settings.action.testAny": "测试提醒", + "settings.action.unsupported": "不可用", + "settings.permission.promptFailed": "无法请求通知权限:{message}", + "settings.health.watching": "正在监听会话状态", + "settings.health.noHooks": "未收到会话状态(通知不会触发)", + "settings.health.error": "监听会话状态出错:{message}", + "settings.health.starting": "正在等待会话列表", + "settings.kinds.label": "提醒内容", + "settings.kind.completion": "完成", + "settings.kind.approval": "授权", + "settings.kind.question": "提问", + "config.section.kinds": "提醒内容", + "config.section.permission": "系统通知权限", + "config.section.status": "运行状态", + "config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。", + "config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。", + "config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。", + "config.testAny": "测试提醒", + "config.testSystem": "测试系统通知", + "config.diag.window": "窗口状态", + "config.diag.inFront": "在前台(会走轻弹窗)", + "config.diag.away": "不在前台(会走系统通知)", + "config.diag.last": "最近一次投递", + "config.diag.none": "还没有投递过", + "config.diag.systemShown": "系统通知(系统已确认弹出)", + "config.diag.systemUnconfirmed": "系统通知(系统没有回报,回到窗口时补发轻弹窗)", + "config.diag.systemFailed": "系统通知没弹出来,回到窗口时补发轻弹窗", + "config.diag.replayDelivery": "应用内轻弹窗(回到窗口时补发)", + "config.diag.popup": "应用内轻弹窗", + "config.diag.refused": "系统通知被拒绝,改用了轻弹窗", + "config.diag.replay": "等你回到窗口时补发:{count} 条", + "config.diag.replayNone": "没有待补发的提醒" +}; + +// src/client/i18n/en.js +var en = { + "notification.completion": "Conversation finished", + "notification.approval": "Approval required", + "notification.question": "Answer needed", + "body.completion": "{title} finished this round", + "body.approval": "{title}: {tool} is waiting for your approval", + "body.approvalPlain": "{title}: a tool is waiting for your approval", + "body.question": "{title}: waiting for your answer", + "body.planReview": "{title}: a plan is waiting for your review", + "body.untitled": "Untitled conversation", + "toast.view": "View", + "toast.dismiss": "Dismiss", + "settings.title": "Session notifications", + "settings.description": "A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.", + "settings.permission.granted": "System notifications are on", + "settings.permission.default": "System notification permission has not been granted", + "settings.permission.denied": "System notifications are switched off in system settings", + "settings.permission.unsupported": "This environment cannot show system notifications; the in-app popup still works", + "settings.permission.hint.plain": "Allow DeepSeek Harness in your system notification settings", + "settings.permission.hint.win": "Windows Settings → System → Notifications → DeepSeek Harness", + "settings.permission.hint.mac": "System Settings → Notifications → DeepSeek Harness", + "settings.permission.hint.linux": "Allow DeepSeek Harness in your desktop notification settings (GNOME / KDE)", + "settings.action.allow": "Allow notifications", + "settings.action.testSystem": "Test system notification", + "settings.action.testAny": "Test alert", + "settings.action.unsupported": "Unavailable", + "settings.permission.promptFailed": "Could not request notification permission: {message}", + "settings.health.watching": "Watching session status", + "settings.health.noHooks": "No session status received (notifications will not fire)", + "settings.health.error": "Watching session status failed: {message}", + "settings.health.starting": "Waiting for the session list", + "settings.kinds.label": "Notify about", + "settings.kind.completion": "Finished", + "settings.kind.approval": "Approval", + "settings.kind.question": "Questions", + "config.section.kinds": "What to notify about", + "config.section.permission": "System notification permission", + "config.section.status": "Status", + "config.intro": "Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.", + "config.saveNote": "The switches are stored in this browser and survive reinstalling the plugin.", + "config.testHint": "The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).", + "config.testAny": "Test alert", + "config.testSystem": "Test system notification", + "config.diag.window": "Window", + "config.diag.inFront": "in front (popup is used)", + "config.diag.away": "not in front (system notification is used)", + "config.diag.last": "Last delivery", + "config.diag.none": "nothing delivered yet", + "config.diag.systemShown": "system notification (confirmed on screen)", + "config.diag.systemUnconfirmed": "system notification (no confirmation, replayed in-app on return)", + "config.diag.systemFailed": "system notification never appeared, replayed in-app on return", + "config.diag.replayDelivery": "in-app popup (replayed when you came back)", + "config.diag.popup": "in-app popup", + "config.diag.refused": "system channel refused, popup was used instead", + "config.diag.replay": "waiting for you to come back: {count}", + "config.diag.replayNone": "nothing waiting to be replayed" +}; + +// src/client/i18n/index.js +var dictionaries = { zh, en }; +function registerDictionaries(locale) { + if (locale === void 0 || typeof locale.register !== "function") return () => { + }; + try { + return locale.register(NS, dictionaries); + } catch { + const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(NS, id, dict)); + return () => { + for (const dispose of disposers) dispose?.(); + }; + } +} +function createTranslator(locale) { + return (key, params) => { + if (locale !== void 0 && typeof locale.bind === "function") { + try { + const bound = locale.bind(NS); + if (typeof bound === "function") { + const translated = bound(key, params); + if (typeof translated === "string" && translated !== key) return translated; + } + } catch { + } } - const en = { - 'notification.completion': 'Conversation finished', - 'notification.approval': 'Approval required', - 'notification.question': 'Answer needed', - 'body.completion': '{title} finished this round', - 'body.approval': '{title}: {tool} is waiting for your approval', - 'body.approvalPlain': '{title}: a tool is waiting for your approval', - 'body.question': '{title}: waiting for your answer', - 'body.planReview': '{title}: a plan is waiting for your review', - 'body.untitled': 'Untitled conversation', - 'toast.view': 'View', - 'toast.dismiss': 'Dismiss', - 'settings.title': 'Session notifications', - 'settings.description': 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.', - 'settings.permission.granted': 'System notifications are on', - 'settings.permission.default': 'System notification permission has not been granted', - 'settings.permission.denied': 'System notifications are switched off in system settings', - 'settings.permission.unsupported': 'This environment cannot show system notifications; the in-app popup still works', - 'settings.permission.hint.plain': 'Allow DeepSeek Harness in your system notification settings', - 'settings.permission.hint.win': 'Windows Settings → System → Notifications → DeepSeek Harness', - 'settings.permission.hint.mac': 'System Settings → Notifications → DeepSeek Harness', - 'settings.permission.hint.linux': 'Allow DeepSeek Harness in your desktop notification settings (GNOME / KDE)', - 'settings.action.allow': 'Allow notifications', - 'settings.action.testSystem': 'Test system notification', - 'settings.action.testAny': 'Test alert', - 'settings.action.unsupported': 'Unavailable', - 'settings.permission.promptFailed': 'Could not request notification permission: {message}', - 'settings.health.watching': 'Watching session status', - 'settings.health.noHooks': 'No session status received (notifications will not fire)', - 'settings.health.error': 'Watching session status failed: {message}', - 'settings.health.starting': 'Waiting for the session list', - 'settings.kinds.label': 'Notify about', - 'settings.kind.completion': 'Finished', - 'settings.kind.approval': 'Approval', - 'settings.kind.question': 'Questions', - 'config.section.kinds': 'What to notify about', - 'config.section.permission': 'System notification permission', - 'config.section.status': 'Status', - 'config.intro': 'Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.', - 'config.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.', - 'config.testHint': 'The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).', - 'config.testAny': 'Test alert', - 'config.testSystem': 'Test system notification', - 'config.diag.window': 'Window', - 'config.diag.inFront': 'in front (popup is used)', - 'config.diag.away': 'not in front (system notification is used)', - 'config.diag.last': 'Last delivery', - 'config.diag.none': 'nothing delivered yet', - 'config.diag.systemShown': 'system notification (confirmed on screen)', - 'config.diag.systemUnconfirmed': 'system notification (no confirmation, replayed in-app on return)', - 'config.diag.systemFailed': 'system notification never appeared, replayed in-app on return', - 'config.diag.replayDelivery': 'in-app popup (replayed when you came back)', - 'config.diag.popup': 'in-app popup', - 'config.diag.refused': 'system channel refused, popup was used instead', - 'config.diag.replay': 'waiting for you to come back: {count}', - 'config.diag.replayNone': 'nothing waiting to be replayed', + let language = ""; + try { + const snapshot = locale?.getSnapshot?.(); + language = String(snapshot?.active ?? ""); + } catch { + language = ""; } + const dictionary = /^zh/i.test(language) ? zh : en; + const template = dictionary[key] ?? zh[key] ?? en[key] ?? key; + if (params === void 0) return template; + return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match); + }; +} - /** - * Every declaration below belongs to this package. The popup copies the - * shipped toast surface (a fixed entry in the frame-wide overlay, the - * `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair, `--dsw-radius-lg`, - * `--dsw-shadow-lv3`) and adds a title line above the text; the switch copies - * `Switch.module.css` behavior and proportion; the configuration sections - * follow the Plugin Manager page's own section rhythm (a column, 32px apart). - * Class names are renamed under `dsn-`; only the popup glyphs carry their own - * artwork colors. - */ - const CSS = ` +// src/client/platform.js +function notificationApi() { + const Ctor = globalThis.Notification; + return typeof Ctor === "function" ? Ctor : void 0; +} +function notificationPermission() { + const Ctor = notificationApi(); + if (Ctor === void 0) return "unsupported"; + const permission = Ctor.permission; + if (permission === "granted" || permission === "denied" || permission === "default") return permission; + return "default"; +} +function windowIsAway() { + try { + if (document.visibilityState === "hidden") return true; + } catch { + } + try { + if (typeof document.hasFocus === "function") return !document.hasFocus(); + } catch { + } + return false; +} +function focusWindow() { + try { + globalThis.focus?.(); + } catch { + } +} +var systemIcon = (() => { + try { + const url = new URL("./icon.svg", document.baseURI).href; + if (url !== "") return url; + } catch { + } + try { + const svg = ''; + return `data:image/svg+xml,${svg}`; + } catch { + return void 0; + } +})(); +function platformKey() { + try { + const agent = navigator.userAgentData; + const platform = String((agent?.platform ?? navigator.platform) || navigator.userAgent || ""); + if (/win/i.test(platform)) return "win"; + if (/mac|iphone|ipad/i.test(platform)) return "mac"; + if (/linux|x11/i.test(platform)) return "linux"; + } catch { + } + return "plain"; +} + +// src/client/core/log.js +function text(error) { + return error instanceof Error ? error.message : String(error); +} +function report(message) { + try { + console.error(`session-notify: ${message}`); + } catch { + } +} + +// src/client/core/store.js +function createStore(initial) { + let value = initial; + const listeners = /* @__PURE__ */ new Set(); + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set: (next) => { + if (next === value) return; + value = next; + for (const listener of [...listeners]) listener(); + } + }; +} + +// src/client/core/storage.js +var STORAGE_KEY = "dsh-plugin/session-notify"; +function readStoredKinds() { + const kinds = { completion: true, approval: true, question: true }; + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (typeof raw !== "string" || raw === "") return kinds; + const stored = JSON.parse(raw); + for (const kind of KINDS) { + if (typeof stored?.[kind] === "boolean") kinds[kind] = stored[kind]; + } + } catch { + } + return kinds; +} +function writeStoredKinds(kinds) { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds)); + } catch { + } +} + +// src/client/core/copy.js +function createCopy(t) { + const copyFor = (candidate) => { + const title = candidate.title === "" ? t("body.untitled") : candidate.title; + if (candidate.kind === "completion") { + return { kind: "completion", title: t("notification.completion"), body: t("body.completion", { title }) }; + } + if (candidate.kind === "question") { + const question = candidate.detail; + return { + kind: "question", + title: t("notification.question"), + body: question === "" ? t("body.question", { title }) : `${title} · ${question}` + }; + } + if (candidate.pendingKind === "plan-review") { + return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) }; + } + const tool = candidate.detail; + return { + kind: "approval", + title: t("notification.approval"), + body: tool === "" ? t("body.approvalPlain", { title }) : t("body.approval", { title, tool }) + }; + }; + const describeDelivery = (last, tr) => { + if (last === null || last === void 0) return tr("config.diag.none"); + const outcome = last.outcome.startsWith("system-refused") ? tr("config.diag.refused") : last.outcome === "system" ? last.shown === true ? tr("config.diag.systemShown") : last.shown === false ? tr("config.diag.systemFailed") : tr("config.diag.systemUnconfirmed") : last.outcome === "replay" ? tr("config.diag.replayDelivery") : tr("config.diag.popup"); + return `${tr("config.diag.last")}: ${last.at} · ${outcome} · ${last.title}`; + }; + const describeReplay = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replay", { count }); + return { copyFor, describeDelivery, describeReplay }; +} + +// src/client/core/toasts.js +function createToastStack({ store, copy }) { + const timers = /* @__PURE__ */ new Map(); + let seq = 0; + const closeToast = (id) => { + const timer = timers.get(id); + if (timer !== void 0) { + clearTimeout(timer); + timers.delete(id); + } + const snapshot = store.getSnapshot(); + if (!snapshot.toasts.some((toast) => toast.id === id)) return; + store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }); + }; + const armToast = (id) => { + const timer = timers.get(id); + if (timer !== void 0) clearTimeout(timer); + timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS)); + }; + const holdToast = (id) => { + const timer = timers.get(id); + if (timer === void 0) return; + clearTimeout(timer); + timers.delete(id); + }; + const showToast = (candidate) => { + const notice = copy.copyFor(candidate); + seq += 1; + const toast = { + id: `dsn-${String(seq)}`, + kind: notice.kind, + title: notice.title, + body: notice.body, + sessionId: candidate.sessionId + }; + const snapshot = store.getSnapshot(); + const next = [toast, ...snapshot.toasts]; + for (const dropped of next.slice(TOAST_LIMIT)) { + const timer = timers.get(dropped.id); + if (timer !== void 0) { + clearTimeout(timer); + timers.delete(dropped.id); + } + } + store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }); + armToast(toast.id); + }; + const dispose = () => { + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + return { showToast, closeToast, armToast, holdToast, dispose }; +} + +// src/client/core/replay.js +function createReplay({ store, publish, showToast, recordDelivery }) { + let entries = []; + const dropReplay = (candidate) => { + const next = entries.filter((entry) => entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind); + if (next.length === entries.length) return; + entries = next; + publish(); + }; + const holdForReplay = (candidate, monitor) => { + const now = Date.now(); + entries = [ + ...entries.filter((entry) => entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind), + { candidate, at: now, monitor } + ].slice(-REPLAY_LIMIT); + publish(); + }; + const flushReplay = () => { + if (entries.length === 0) return; + const now = Date.now(); + const waiting = entries; + entries = []; + const due = waiting.filter((entry) => { + if (now - entry.at > REPLAY_TTL_MS) return false; + return !(entry.monitor?.shown === true && now - entry.at <= REPLAY_QUIET_MS); + }); + const shown = due.slice(-TOAST_LIMIT); + for (const entry of shown) showToast(entry.candidate); + if (shown.length > 0) recordDelivery("replay", shown[shown.length - 1].candidate); + else publish(); + }; + const size = () => entries.length; + const dispose = () => { + entries = []; + }; + return { holdForReplay, dropReplay, flushReplay, size, dispose }; +} + +// src/client/core/system-channel.js +function createSystemChannel({ store, copy, notify, openSession, dropReplay }) { + const raised = /* @__PURE__ */ new Set(); + const confirmDelivery = (monitor, shown) => { + if (monitor === null || monitor === void 0 || typeof monitor !== "object") return; + monitor.shown = shown; + const snapshot = store.getSnapshot(); + const last = snapshot.lastDelivery; + if (last === null || last === void 0 || last.monitor !== monitor || last.shown === shown) return; + store.set({ ...snapshot, lastDelivery: { ...last, shown } }); + }; + const keepRaised = (notification) => { + raised.add(notification); + while (raised.size > RAISED_LIMIT) { + const oldest = raised.values().next().value; + if (oldest === void 0) break; + raised.delete(oldest); + } + }; + const notifySystem = (candidate, force) => { + const Ctor = globalThis.Notification; + const permission = notificationPermission(); + if (permission === "unsupported") return { outcome: "unsupported" }; + if (!force && permission !== "granted") return { outcome: "permission" }; + try { + const notice = copy.copyFor(candidate); + const options = { + body: notice.body, + tag: `dsh-session-${candidate.sessionId}`, + requireInteraction: false + }; + if (systemIcon !== void 0) options.icon = systemIcon; + const notification = new Ctor(notice.title, options); + const record = { outcome: "raised", notice, shown: void 0 }; + keepRaised(notification); + notification.onshow = () => { + confirmDelivery(record, true); + }; + notification.onerror = (event) => { + confirmDelivery(record, false); + notify(`system notification was refused: ${String(event?.message ?? "unknown")}`); + }; + notification.onclose = () => { + raised.delete(notification); + }; + notification.onclick = () => { + dropReplay(candidate); + focusWindow(); + openSession(candidate.sessionId); + try { + notification.close(); + } catch { + } + }; + return record; + } catch (error) { + notify(`system notification failed: ${text(error)}`); + return { outcome: "threw" }; + } + }; + const dispose = () => { + raised.clear(); + }; + return { notifySystem, confirmDelivery, dispose }; +} + +// src/client/core/session.js +function servedPendingKind(value) { + if (value === void 0 || value === null || typeof value !== "object") return void 0; + const kind = value.kind; + return typeof kind === "string" && PENDING_KINDS.includes(kind) ? kind : void 0; +} +function preview(value) { + if (typeof value !== "string") return ""; + const flat = value.replace(/\s+/g, " ").trim(); + return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat; +} +function questionText(interaction) { + if (interaction === null || typeof interaction !== "object") return ""; + const first = Array.isArray(interaction.questions) ? interaction.questions[0] : void 0; + if (typeof first?.question === "string") return preview(first.question); + if (typeof first?.text === "string") return preview(first.text); + if (typeof interaction.question === "string") return preview(interaction.question); + if (typeof interaction.prompt === "string") return preview(interaction.prompt); + return preview(interaction.displayReason?.text ?? interaction.reason?.text ?? ""); +} +function titleOf(summary, sessionId) { + const title = typeof summary?.title === "string" ? summary.title.trim() : ""; + return title === "" ? sessionId.slice(0, 8) : title; +} +function isOnScreen(sessionId, list) { + if (list === void 0) return false; + for (const id of Object.keys(list.byId ?? {})) { + const summary = list.byId[id]; + if (summary !== void 0 && (summary.retainedBy?.mainView ?? 0) > 0) return id === sessionId; + } + return false; +} +function stillWorth(candidate, live) { + const list = live.list; + if (candidate.test === true || list === void 0) return true; + const summary = list.byId?.[candidate.sessionId]; + if (summary === void 0) return true; + const status = live.status; + const running = status?.get?.(candidate.sessionId)?.running ?? summary.running; + return running !== true; +} + +// src/client/core/delivery.js +function createDelivery({ store, copy, live, toasts, system, replay }) { + const pending = []; + let settleTimer = 0; + let lastDelivery = 0; + let deliverySeq = 0; + const recordDelivery = (outcome, candidate, monitor) => { + deliverySeq += 1; + const now = /* @__PURE__ */ new Date(); + const pad = (value) => String(value).padStart(2, "0"); + const record = { + seq: deliverySeq, + outcome, + at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + title: copy.copyFor(candidate).title, + shown: monitor?.shown, + monitor + }; + store.set({ ...store.getSnapshot(), lastDelivery: record }); + return record; + }; + const deliver = (candidate, ignoreThrottle) => { + if (ignoreThrottle !== true) { + const now = Date.now(); + if (now - lastDelivery < THROTTLE_MS) return; + lastDelivery = now; + } + if (!windowIsAway()) { + if (isOnScreen(candidate.sessionId, live.list)) return; + toasts.showToast(candidate); + recordDelivery("popup", candidate); + return; + } + const result = system.notifySystem(candidate, candidate.test === true); + if (result.outcome === "raised") { + recordDelivery("system", candidate, result); + if (candidate.test !== true) replay.holdForReplay(candidate, result); + return; + } + toasts.showToast(candidate); + recordDelivery(`system-refused-${result.outcome}`, candidate); + }; + const flushSettle = () => { + settleTimer = 0; + const candidate = pending.shift(); + if (candidate !== void 0 && stillWorth(candidate, live)) deliver(candidate, false); + if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS); + }; + const queue = (candidate) => { + pending.push(candidate); + if (settleTimer !== 0) return; + settleTimer = setTimeout(() => flushSettle(), SETTLE_MS); + }; + const resetThrottle = () => { + lastDelivery = 0; + }; + const dispose = () => { + if (settleTimer !== 0) { + clearTimeout(settleTimer); + settleTimer = 0; + } + }; + return { deliver, recordDelivery, queue, flushSettle, resetThrottle, dispose }; +} + +// src/client/core/observe.js +function createObservation({ store, live, queue }) { + const runs = /* @__PURE__ */ new Map(); + const completionNotice = /* @__PURE__ */ new Set(); + const pendingNotice = /* @__PURE__ */ new Map(); + const reportHealth = (next) => { + const snapshot = store.getSnapshot(); + const current = snapshot.health; + if (current.state === next.state && current.message === next.message) return; + if (current.state !== "watching") report(`observer health: ${next.state} ${next.message}`); + store.set({ ...snapshot, health: next }); + }; + const observe = (list, status) => { + if (list === void 0 || status === void 0) return; + live.list = list; + live.status = status; + const kinds = store.getSnapshot().kinds; + for (const sessionId of Object.keys(list.byId ?? {})) { + const summary = list.byId[sessionId]; + if (summary === void 0 || summary.origin === "subagent") continue; + const sessionStatus = status.get(sessionId); + const running = sessionStatus?.running ?? summary.running; + if (running === true) { + runs.set(sessionId, true); + completionNotice.delete(sessionId); + } else { + if (runs.get(sessionId) === true) { + runs.set(sessionId, false); + if (summary.blank !== true && !completionNotice.has(sessionId) && kinds.completion) { + completionNotice.add(sessionId); + queue({ + kind: "completion", + sessionId, + title: titleOf(summary, sessionId), + detail: "", + pendingKind: "" + }); + } + } else if (!runs.has(sessionId)) { + runs.set(sessionId, false); + } + } + const interaction = sessionStatus?.pendingInteraction; + const pendingKind = servedPendingKind(interaction); + if (pendingKind === void 0) { + pendingNotice.delete(sessionId); + continue; + } + if (pendingNotice.get(sessionId) === pendingKind) continue; + pendingNotice.set(sessionId, pendingKind); + const trigger = pendingKind === "approval" ? "approval" : "question"; + if (!kinds[trigger]) continue; + queue({ + kind: trigger, + sessionId, + title: titleOf(summary, sessionId), + detail: pendingKind === "approval" ? String(interaction?.toolName ?? "") : questionText(interaction), + pendingKind + }); + } + for (const sessionId of [...runs.keys()]) { + if (list.byId?.[sessionId] !== void 0) continue; + runs.delete(sessionId); + completionNotice.delete(sessionId); + pendingNotice.delete(sessionId); + } + }; + return { observe, reportHealth }; +} + +// src/client/core/actions.js +function createActions({ store, t, delivery, system, toasts, publish }) { + const testCandidate = (title) => ({ + kind: "completion", + sessionId: "test", + title, + detail: "", + pendingKind: "", + test: true + }); + const sendTest = () => { + delivery.deliver(testCandidate(t("config.testAny")), true); + }; + const sendTestSystem = () => { + const candidate = testCandidate(t("config.testSystem")); + const result = system.notifySystem(candidate, true); + if (result.outcome === "raised") { + delivery.recordDelivery("system", candidate, result); + return; + } + toasts.showToast(candidate); + delivery.recordDelivery(`system-refused-${result.outcome}`, candidate); + }; + const requestPermission = async () => { + const Ctor = notificationApi(); + if (Ctor === void 0) { + publish(); + return; + } + try { + let result = Ctor.requestPermission(); + if (result === void 0) { + result = new Promise((resolve) => { + Ctor.requestPermission((value) => resolve(value)); + }); + } + await result; + } catch (error) { + const snapshot2 = store.getSnapshot(); + store.set({ ...snapshot2, promptError: t("settings.permission.promptFailed", { message: text(error) }) }); + return; + } + const snapshot = store.getSnapshot(); + store.set({ ...snapshot, permission: notificationPermission(), promptError: "" }); + if (notificationPermission() === "granted") sendTest(); + }; + const setKindEnabled = (kind, enabled) => { + const snapshot = store.getSnapshot(); + const kinds = { ...snapshot.kinds, [kind]: enabled }; + writeStoredKinds(kinds); + store.set({ ...snapshot, kinds }); + }; + return { sendTest, sendTestSystem, requestPermission, setKindEnabled }; +} + +// src/client/ui/ToastLayer.js +var React3 = __toESM(require("react"), 1); + +// src/client/ui/icons.js +var React = __toESM(require("react"), 1); +var h = React.createElement; +function BellIcon({ size = 16 }) { + return h( + "svg", + { + width: size, + height: size, + viewBox: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true + }, + h("path", { + d: "M8 2.35C5.7 2.35 3.83 4.22 3.83 6.52V9.02L3.06 11.1C2.99 11.29 3.13 11.48 3.33 11.48H12.67C12.87 11.48 13.01 11.29 12.94 11.1L12.17 9.02V6.52C12.17 4.22 10.3 2.35 8 2.35Z", + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinejoin: "round" + }), + h("path", { + d: "M6.3 12.75C6.52 13.66 7.2 14.28 8 14.28C8.8 14.28 9.48 13.66 9.7 12.75", + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinecap: "round" + }) + ); +} +function ShieldIcon({ size = 16 }) { + return h( + "svg", + { + width: size, + height: size, + viewBox: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true + }, + h("path", { + d: "M8 1.9 13.1 3.7V7.9C13.1 11 11 13.3 8 14.2C5 13.3 2.9 11 2.9 7.9V3.7L8 1.9Z", + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinejoin: "round" + }), + h("path", { + d: "M5.9 8.05L7.35 9.5L10.15 6.6", + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinecap: "round", + strokeLinejoin: "round" + }) + ); +} +function QuestionIcon({ size = 16 }) { + return h( + "svg", + { + width: size, + height: size, + viewBox: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true + }, + h("circle", { cx: 8, cy: 8, r: 6.15, stroke: "currentColor", strokeWidth: 1.2 }), + h("path", { + d: "M6.35 6.35C6.35 5.44 7.09 4.7 8 4.7C8.91 4.7 9.65 5.44 9.65 6.35C9.65 7.75 8 7.7 8 9.15", + stroke: "currentColor", + strokeWidth: 1.2, + strokeLinecap: "round" + }), + h("circle", { cx: 8, cy: 11.3, r: 0.85, fill: "currentColor" }) + ); +} +function CloseIcon({ size = 12 }) { + return h( + "svg", + { + width: size, + height: size, + viewBox: "0 0 16 16", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true + }, + h("path", { d: "M2.5 2.5L13.5 13.5", stroke: "currentColor", strokeWidth: 1.4 }), + h("path", { d: "M13.5 2.5L2.5 13.5", stroke: "currentColor", strokeWidth: 1.4 }) + ); +} +function KindIcon({ kind, size }) { + if (kind === "approval") return h(ShieldIcon, { size }); + if (kind === "question") return h(QuestionIcon, { size }); + return h(BellIcon, { size }); +} + +// src/client/ui/hooks.js +var React2 = __toESM(require("react"), 1); +function useOwnStore(source) { + return React2.useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot); +} +function useWindowState() { + const subscribe = React2.useCallback((listener) => { + try { + window.addEventListener("focus", listener); + window.addEventListener("blur", listener); + document.addEventListener("visibilitychange", listener); + } catch { + return () => { + }; + } + return () => { + window.removeEventListener("focus", listener); + window.removeEventListener("blur", listener); + document.removeEventListener("visibilitychange", listener); + }; + }, []); + return React2.useSyncExternalStore(subscribe, () => !windowIsAway(), () => true); +} +function seatTranslator(props, fallback) { + return typeof props.t === "function" ? props.t : fallback; +} + +// src/client/ui/styles.js +var CSS = ` .dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none} @media (max-width:520px){.dsn-stack{right:12px}} .dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out} @@ -228,1225 +916,347 @@ window.__ModuleLoader__.load({ .dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)} .dsn-button:disabled{cursor:not-allowed;opacity:.4} .dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} -` +`; - /** Bell glyph: this bundle's own artwork, drawn like the shipped outline set. */ - function BellIcon({ size = 16 }) { - return h('svg', { - width: size, height: size, viewBox: '0 0 16 16', fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, - }, - h('path', { - d: 'M8 2.35C5.7 2.35 3.83 4.22 3.83 6.52V9.02L3.06 11.1C2.99 11.29 3.13 11.48 3.33 11.48H12.67C12.87 11.48 13.01 11.29 12.94 11.1L12.17 9.02V6.52C12.17 4.22 10.3 2.35 8 2.35Z', - stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round', - }), - h('path', { - d: 'M6.3 12.75C6.52 13.66 7.2 14.28 8 14.28C8.8 14.28 9.48 13.66 9.7 12.75', - stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', - })) - } - - /** Shield glyph for an approval request. */ - function ShieldIcon({ size = 16 }) { - return h('svg', { - width: size, height: size, viewBox: '0 0 16 16', fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, - }, - h('path', { - d: 'M8 1.9 13.1 3.7V7.9C13.1 11 11 13.3 8 14.2C5 13.3 2.9 11 2.9 7.9V3.7L8 1.9Z', - stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round', - }), - h('path', { - d: 'M5.9 8.05L7.35 9.5L10.15 6.6', - stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', - })) - } - - /** Question glyph for a pending question or plan review. */ - function QuestionIcon({ size = 16 }) { - return h('svg', { - width: size, height: size, viewBox: '0 0 16 16', fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, - }, - h('circle', { cx: 8, cy: 8, r: 6.15, stroke: 'currentColor', strokeWidth: 1.2 }), - h('path', { - d: 'M6.35 6.35C6.35 5.44 7.09 4.7 8 4.7C8.91 4.7 9.65 5.44 9.65 6.35C9.65 7.75 8 7.7 8 9.15', - stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', - }), - h('circle', { cx: 8, cy: 11.3, r: 0.85, fill: 'currentColor' })) - } - - /** Close glyph, matching the primitives' own stroke weight and geometry. */ - function CloseIcon({ size = 12 }) { - return h('svg', { - width: size, height: size, viewBox: '0 0 16 16', fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, - }, - h('path', { d: 'M2.5 2.5L13.5 13.5', stroke: 'currentColor', strokeWidth: 1.4 }), - h('path', { d: 'M13.5 2.5L2.5 13.5', stroke: 'currentColor', strokeWidth: 1.4 })) - } - - /** Select the per-trigger glyph. */ - function KindIcon({ kind, size }) { - if (kind === 'approval') return h(ShieldIcon, { size }) - if (kind === 'question') return h(QuestionIcon, { size }) - return h(BellIcon, { size }) - } - - /** Minimal observable store: the shape React reads with useSyncExternalStore. */ - function createStore(initial) { - let value = initial - const listeners = new Set() - return { - getSnapshot: () => value, - subscribe: (listener) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } +// src/client/ui/ToastLayer.js +var h2 = React3.createElement; +function ToastLayer(props) { + const snapshot = useOwnStore(props.store); + const toasts = snapshot.toasts; + const tr = seatTranslator(props, props.fallbackT); + React3.useEffect(() => { + if (toasts.length === 0) return void 0; + const onKeyDown = (event) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + const top = toasts[0]; + if (top === void 0) return; + event.preventDefault(); + props.dismiss(top.id); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [toasts, props]); + if (toasts.length === 0) return null; + return h2( + React3.Fragment, + null, + h2("style", null, CSS), + h2( + "div", + { className: "dsn-stack", role: "region", "aria-live": "polite" }, + toasts.map((toast) => h2( + "div", + { + key: toast.id, + className: "dsn-toast", + role: toast.kind === "completion" ? "status" : "alert", + onMouseEnter: () => props.hold(toast.id), + onMouseLeave: () => props.arm(toast.id) }, - set: (next) => { - if (next === value) return - value = next - for (const listener of [...listeners]) listener() - }, - } - } - - /** Render one unknown failure as text. */ - function text(error) { - return error instanceof Error ? error.message : String(error) - } - - /** Log one contained diagnostic through the package-tagged console. */ - function report(message) { - try { - console.error(`session-notify: ${message}`) - } catch { - /* a diagnostic never fails the plugin */ - } - } - - /** - * Publish the resident observer's own health into the plugin store. - * - * A slot entry that silently receives nothing is the one failure this plugin - * cannot detect from the outside, so the observer records whether it is - * actually seeing session state and the settings row shows it. Repeated - * identical states publish nothing, and a state this is not `watching` also - * reaches the tagged console. - * @param store - the plugin's own store. - * @param next - the health state observed on this render. - */ - function applyHealth(store, next) { - const snapshot = store.getSnapshot() - const current = snapshot.health - if (current.state === next.state && current.message === next.message) return - if (current.state !== 'watching') report(`observer health: ${next.state} ${next.message}`) - store.set({ ...snapshot, health: next }) - } - - /** Permission lookup that never throws in a browser without the API. */ - function notificationPermission() { - const Ctor = globalThis.Notification - if (typeof Ctor !== 'function') return 'unsupported' - const permission = Ctor.permission - if (permission === 'granted' || permission === 'denied' || permission === 'default') return permission - return 'default' - } - - /** - * Whether the Harness window is somewhere the user cannot see the app. - * - * Two independent browser facts are consulted, and ANY of them counts as "not - * in the foreground": the page's visibility and the document's focus. They are - * read at delivery time rather than remembered from an event, because a missed - * blur in the desktop shell made this plugin believe the window was in front - * and swallow the alert. - * @returns whether the alert must go to the system notification channel. - */ - function windowIsAway() { - try { - if (document.visibilityState === 'hidden') return true - } catch { - /* an unreadable visibility state leaves the focus fact */ - } - try { - if (typeof document.hasFocus === 'function') return !document.hasFocus() - } catch { - /* an unreadable focus state leaves the default below */ - } - return false - } - - /** Read the window's live focus state rather than a cached value. */ - function currentFocus() { - return !windowIsAway() - } - - /** Focus the window without letting a refusal stop the navigation. */ - function focusWindow() { - try { - globalThis.focus?.() - } catch { - /* focus is best effort on every platform */ - } - } - - /** - * The system-notification icon, built once: this deployment's own static URL - * when the page serves this package's assets, else the SVG inline. Icons are - * cosmetic on every platform, so each step is contained. - * @returns an icon URL, or undefined when neither form can be built. - */ - const systemIcon = (() => { - try { - const url = new URL('./icon.svg', document.baseURI).href - if (url !== '') return url - } catch { - /* fall through to the inline form */ - } - try { - const svg = '' - return `data:image/svg+xml,${svg}` - } catch { - return undefined - } - })() - - /** One best-effort platform hint, used for settings help text only. */ - function platformKey() { - try { - const agent = navigator.userAgentData - const platform = String((agent?.platform ?? navigator.platform) || navigator.userAgent || '') - if (/win/i.test(platform)) return 'win' - if (/mac|iphone|ipad/i.test(platform)) return 'mac' - if (/linux|x11/i.test(platform)) return 'linux' - } catch { - /* an unreadable navigator keeps the neutral hint */ - } - return 'plain' - } - - /** - * Discriminate a pending interaction the client published. Only the three - * domains the Harness itself renders are served; anything else is ignored - * rather than guessed at. - * @param value - `status.pendingInteraction`. - * @returns a served kind, or undefined. - */ - function servedPendingKind(value) { - if (value === undefined || value === null || typeof value !== 'object') return undefined - const kind = value.kind - return typeof kind === 'string' && PENDING_KINDS.includes(kind) ? kind : undefined - } - - /** Trim free text down to one popup line. */ - function preview(value) { - if (typeof value !== 'string') return '' - const flat = value.replace(/\s+/g, ' ').trim() - return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat - } - - /** The first question text a pending interaction exposes, across its shapes. */ - function questionText(interaction) { - if (interaction === null || typeof interaction !== 'object') return '' - const first = Array.isArray(interaction.questions) ? interaction.questions[0] : undefined - if (typeof first?.question === 'string') return preview(first.question) - if (typeof first?.text === 'string') return preview(first.text) - if (typeof interaction.question === 'string') return preview(interaction.question) - if (typeof interaction.prompt === 'string') return preview(interaction.prompt) - return preview(interaction.displayReason?.text ?? interaction.reason?.text ?? '') - } - - /** - * Register this package's dictionaries with the locale service. - * - * Without this the service has no entry for the namespace and every - * `locale.bind(NS)` lookup answers with the key itself, so the settings row - * shows `settings.title` instead of its copy. The typed form takes every - * shipped locale in one call, and the older single-locale form stays - * supported as a fallback. Registration bumps the locale revision, so - * already-rendered entries pick the copy up without a reload. - * @param locale - the locale service, or undefined when this profile has none. - * @returns a disposer for whichever registrations were installed. - */ - function registerDictionaries(locale) { - if (locale === undefined || typeof locale.register !== 'function') return () => {} - const dictionaries = { zh, en } - try { - return locale.register(NS, dictionaries) - } catch { - const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(NS, id, dict)) - return () => { - for (const dispose of disposers) dispose?.() - } - } - } - - /** - * Build a translate function for one locale service: the service's own bound - * namespace first, then this package's dictionaries, then the key itself, so - * a missing seat degrades copy instead of rendering. - * @param locale - the locale service, or undefined when this profile has none. - * @returns a translate function with `{ name }` interpolation. - */ - function createTranslator(locale) { - return (key, params) => { - if (locale !== undefined && typeof locale.bind === 'function') { - try { - const bound = locale.bind(NS) - if (typeof bound === 'function') { - const translated = bound(key, params) - if (typeof translated === 'string' && translated !== key) return translated + h2("span", { className: `dsn-toast-icon is-${toast.kind}` }, h2(KindIcon, { kind: toast.kind, size: 16 })), + h2( + "div", + { className: "dsn-toast-text" }, + h2("div", { className: "dsn-toast-title" }, toast.title), + h2("div", { className: "dsn-toast-desc" }, toast.body) + ), + h2( + "div", + { className: "dsn-toast-actions" }, + h2("button", { + type: "button", + className: "dsn-toast-action", + onClick: () => { + props.dismiss(toast.id); + props.open(toast.sessionId); } - } catch { - /* a locale service without this namespace still renders the fallback */ - } - } - let language = '' - try { - const snapshot = locale?.getSnapshot?.() - language = String(snapshot?.active ?? '') - } catch { - language = '' - } - const dictionary = /^zh/i.test(language) ? zh : en - const template = dictionary[key] ?? zh[key] ?? en[key] ?? key - if (params === undefined) return template - return template.replace(/\{(\w+)\}/g, (match, name) => ( - Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match - )) + }, tr("toast.view")), + h2("button", { + type: "button", + className: "dsn-toast-close", + "aria-label": tr("toast.dismiss"), + onClick: () => props.dismiss(toast.id) + }, h2(CloseIcon, { size: 14 })) + ) + )) + ) + ); +} + +// src/client/ui/ConfigSection.js +var React5 = __toESM(require("react"), 1); + +// src/client/ui/KindRow.js +var React4 = __toESM(require("react"), 1); +var h3 = React4.createElement; +function KindRow(props) { + return h3( + "div", + { className: "dsn-row" }, + h3( + "div", + { className: "dsn-row-text" }, + h3("div", { className: "dsn-row-title" }, props.label) + ), + h3("button", { + type: "button", + role: "switch", + className: "dsn-switch", + "aria-checked": props.checked, + "aria-label": props.label, + disabled: props.disabled, + onClick: props.onToggle + }, h3("span", { className: "dsn-thumb" })) + ); +} + +// src/client/ui/ConfigSection.js +var h4 = React5.createElement; +function ConfigSection(props) { + const snapshot = useOwnStore(props.store); + const tr = seatTranslator(props, props.fallbackT); + if (props.view === "summary") return h4(React5.Fragment, null, tr("settings.description")); + const permission = snapshot.permission; + const supported = permission !== "unsupported"; + const failed = permission === "denied" || snapshot.promptError !== ""; + const statusKey = permission === "granted" ? "settings.permission.granted" : permission === "denied" ? "settings.permission.denied" : supported ? "settings.permission.default" : "settings.permission.unsupported"; + const permissionText = snapshot.promptError === "" ? `${tr(statusKey)}${permission === "denied" || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ""}` : snapshot.promptError; + const health = snapshot.health; + const healthText = health.state === "watching" ? tr("settings.health.watching") : health.state === "noHooks" ? tr("settings.health.noHooks") : health.state === "error" ? tr("settings.health.error", { message: health.message }) : tr("settings.health.starting"); + const healthFailed = health.state !== "watching" && health.state !== "starting"; + return h4( + React5.Fragment, + null, + h4("style", null, CSS), + h4( + "div", + { className: "dsn-sections" }, + // The page header already shows this bundle's title and description, so + // this section opens with what the header cannot say: how alerts are + // carried, and what this form changes. + h4( + "section", + { className: "dsn-section" }, + h4("p", { className: "dsn-section-desc" }, tr("config.intro")) + ), + h4( + "section", + { className: "dsn-section" }, + h4("h3", { className: "dsn-section-title" }, tr("config.section.kinds")), + KINDS.map((kind) => h4(KindRow, { + key: kind, + label: tr(`settings.kind.${kind}`), + checked: snapshot.kinds[kind] === true, + disabled: false, + onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true) + })), + h4("p", { className: "dsn-section-desc" }, tr("config.saveNote")) + ), + h4( + "section", + { className: "dsn-section" }, + h4("h3", { className: "dsn-section-title" }, tr("config.section.permission")), + h4( + "div", + { className: "dsn-row" }, + h4( + "div", + { className: "dsn-row-text" }, + h4("p", { + className: `dsn-status${failed ? " dsn-status-error" : ""}`, + role: failed ? "alert" : "status" + }, permissionText) + ), + h4("button", { + type: "button", + className: "dsn-button", + disabled: !supported, + onClick: permission === "granted" ? props.testSystem : props.ask + }, tr(!supported ? "settings.action.unsupported" : permission === "granted" ? "settings.action.testSystem" : "settings.action.allow")) + ), + h4("p", { className: "dsn-section-desc" }, tr("config.testHint")) + ), + h4( + "section", + { className: "dsn-section" }, + h4("h3", { className: "dsn-section-title" }, tr("config.section.status")), + h4( + "div", + { className: "dsn-row" }, + h4( + "div", + { className: "dsn-row-text" }, + h4("p", { className: "dsn-status" }, `${tr("config.diag.window")}: ${tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")}`), + h4("p", { + className: `dsn-status${healthFailed ? " dsn-status-error" : ""}`, + role: healthFailed ? "alert" : "status" + }, healthText), + h4("p", { className: "dsn-status" }, props.copy.describeDelivery(snapshot.lastDelivery, tr)), + h4("p", { className: "dsn-status" }, props.copy.describeReplay(props.replayCount(), tr)) + ), + h4("button", { + type: "button", + className: "dsn-button", + onClick: props.testAny + }, tr("settings.action.testAny")) + ) + ) + ) + ); +} + +// src/client/ui/NotifyObserver.js +var React6 = __toESM(require("react"), 1); +function NotifyObserver(props) { + useWindowState(); + const useSessions = props.useSessions; + const useSessionStatus = props.useSessionStatus; + const select = React6.useCallback((snapshot) => snapshot, []); + const list = typeof useSessions === "function" ? useSessions(select) : void 0; + const status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0; + React6.useEffect(() => { + try { + if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") { + props.observation.reportHealth({ state: "noHooks", message: "" }); + return; } + if (list === void 0 || status === void 0) { + props.observation.reportHealth({ state: "starting", message: "" }); + return; + } + props.observation.observe(list, status); + props.observation.reportHealth({ state: "watching", message: "" }); + } catch (error) { + props.observation.reportHealth({ state: "error", message: text(error) }); } + }); + return null; +} - /** Storage key for this package's own preferences. */ - const STORAGE_KEY = 'dsh-plugin/session-notify' - - /** - * Read this package's stored trigger switches. - * - * These are browser preferences rather than cordis configuration, so they live - * in this origin's own storage instead of the profile's patch file: the plugin - * writes nothing into DSH's configuration, and every read is defensive because - * storage can be unavailable or hold something an older version wrote. - * @returns the stored switches, defaulting to everything on. - */ - function readStoredKinds() { - const kinds = { completion: true, approval: true, question: true } +// src/client/plugin.js +var inject = ["slots", "locale"]; +function apply(ctx) { + const locale = ctx.get("locale"); + const t = createTranslator(locale); + const store = createStore({ + permission: notificationPermission(), + kinds: readStoredKinds(), + toasts: [], + promptError: "", + health: { state: "starting", message: "" }, + lastDelivery: null + }); + const live = { list: void 0, status: void 0 }; + const runtime = {}; + let disposed = false; + const publish = () => { + if (!disposed) store.set({ ...store.getSnapshot() }); + }; + const openSession = (sessionId) => { + try { + ctx.get("uiWorkspace")?.openSession?.(sessionId); + } catch (error) { + report(`could not open "${sessionId}": ${text(error)}`); + } + }; + const copy = createCopy(t); + const toasts = createToastStack({ store, copy }); + const replay = createReplay({ + store, + publish, + showToast: toasts.showToast, + recordDelivery: (outcome, candidate, monitor) => runtime.delivery?.recordDelivery(outcome, candidate, monitor) + }); + const system = createSystemChannel({ + store, + copy, + notify: report, + openSession, + dropReplay: replay.dropReplay + }); + const delivery = createDelivery({ store, copy, live, toasts, system, replay }); + runtime.delivery = delivery; + const observation = createObservation({ store, live, queue: delivery.queue }); + const actions = createActions({ store, t, delivery, system, toasts, publish }); + const installBridges = () => { + const onBlur = () => { + delivery.resetThrottle(); + }; + const onReturn = () => { + delivery.resetThrottle(); + replay.flushReplay(); + }; + const onVisibilityChange = () => { + if (!windowIsAway()) onReturn(); + }; + try { + window.addEventListener("focus", onReturn); + window.addEventListener("blur", onBlur); + document.addEventListener("visibilitychange", onVisibilityChange); + } catch { + } + return () => { try { - const raw = globalThis.localStorage?.getItem(STORAGE_KEY) - if (typeof raw !== 'string' || raw === '') return kinds - const stored = JSON.parse(raw) - for (const kind of KINDS) { - if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind] - } + window.removeEventListener("focus", onReturn); + window.removeEventListener("blur", onBlur); + document.removeEventListener("visibilitychange", onVisibilityChange); } catch { - /* unreadable or foreign storage keeps the defaults */ } - return kinds - } - - /** Store one trigger switch, ignoring storage that refuses to write. */ - function writeStoredKinds(kinds) { - try { - globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds)) - } catch { - /* a refused write leaves the switches live for this page only */ + delivery.dispose(); + toasts.dispose(); + system.dispose(); + replay.dispose(); + }; + }; + ctx.effect(() => registerDictionaries(locale), "session-notify: dictionaries"); + ctx.effect(() => installBridges(), "session-notify: window focus"); + ctx.effect(() => () => { + disposed = true; + }, "session-notify: disposal"); + ctx.slots.inject("shell.overlay", () => ctx.slots.register({ + name: "shell.overlay", + id: `${NS}-toasts`, + order: 100, + locale: NS, + inject: () => ({ + store, + fallbackT: t, + dismiss: toasts.closeToast, + arm: toasts.armToast, + hold: toasts.holdToast, + open: (sessionId) => { + focusWindow(); + openSession(sessionId); } - } + }) + }, ToastLayer)); + ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({ + name: "plugins.bundle.config", + key: PACKAGE_NAME, + locale: NS, + inject: () => ({ + store, + fallbackT: t, + copy, + platform: platformKey(), + replayCount: replay.size, + ask: actions.requestPermission, + testAny: actions.sendTest, + testSystem: actions.sendTestSystem, + setKind: actions.setKindEnabled + }) + }, ConfigSection)); + ctx.slots.inject("shell.overlay", () => ctx.slots.register({ + name: "shell.overlay", + id: `${NS}-watch`, + order: 101, + inject: () => ({ observation }) + }, NotifyObserver)); +} - /** - * Install the plugin. - * @param ctx - Client root context providing `slots` and `locale`. - */ - function apply(ctx) { - const locale = ctx.get('locale') - const t = createTranslator(locale) - - const store = createStore({ - permission: notificationPermission(), - kinds: readStoredKinds(), - toasts: [], - promptError: '', - health: { state: 'starting', message: '' }, - lastDelivery: null, - }) - const notify = (message) => { - report(message) - } - /** Every component translates through this: the slot's own seat when the owner supplies one, else the locale service. */ - const seatFor = (props) => (typeof props.t === 'function' ? props.t : t) - const state = { - runs: new Map(), - completionNotice: new Set(), - pendingNotice: new Map(), - pending: [], - settleTimer: 0, - lastDelivery: 0, - toastSeq: 0, - deliverySeq: 0, - /** System notifications this page still holds open, so nothing collects them mid-display. */ - raised: new Set(), - /** Alerts the user has not been able to see yet, replayed in-app when the window returns. */ - replay: [], - live: { - list: undefined, - status: undefined, - }, - } - const timers = new Map() - let disposed = false - - const publish = () => { - if (!disposed) store.set({ ...store.getSnapshot() }) - } - - // ------------------------------------------------------------------ copy - - /** Build the delivered copy for one candidate. */ - const copyFor = (candidate) => { - const title = candidate.title === '' ? t('body.untitled') : candidate.title - if (candidate.kind === 'completion') { - return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) } - } - if (candidate.kind === 'question') { - const question = candidate.detail - return { - kind: 'question', - title: t('notification.question'), - body: question === '' ? t('body.question', { title }) : `${title} · ${question}`, - } - } - if (candidate.pendingKind === 'plan-review') { - return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) } - } - const tool = candidate.detail - return { - kind: 'approval', - title: t('notification.approval'), - body: tool === '' ? t('body.approvalPlain', { title }) : t('body.approval', { title, tool }), - } - } - - /** Copy one session title, falling back to its identity. */ - const titleOf = (summary, sessionId) => { - const title = typeof summary?.title === 'string' ? summary.title.trim() : '' - return title === '' ? sessionId.slice(0, 8) : title - } - - // ---------------------------------------------------------------- popups - - /** Drop one popup and cancel its lifetime timer. */ - const closeToast = (id) => { - const timer = timers.get(id) - if (timer !== undefined) { - clearTimeout(timer) - timers.delete(id) - } - const snapshot = store.getSnapshot() - if (!snapshot.toasts.some((toast) => toast.id === id)) return - store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }) - } - - /** Arm one popup's lifetime; hovering calls `hold` first, then this again. */ - const armToast = (id) => { - const timer = timers.get(id) - if (timer !== undefined) clearTimeout(timer) - timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS)) - } - - /** Pause one popup's lifetime while the pointer rests on it. */ - const holdToast = (id) => { - const timer = timers.get(id) - if (timer === undefined) return - clearTimeout(timer) - timers.delete(id) - } - - /** - * Show one light in-app popup: the trigger as the title, the conversation - * and its detail as the description, so the heading says what happened and - * the line under it says where. - */ - const showToast = (candidate) => { - const notice = copyFor(candidate) - state.toastSeq += 1 - const toast = { - id: `dsn-${String(state.toastSeq)}`, - kind: notice.kind, - title: notice.title, - body: notice.body, - sessionId: candidate.sessionId, - } - const snapshot = store.getSnapshot() - const next = [toast, ...snapshot.toasts] - for (const dropped of next.slice(TOAST_LIMIT)) { - const timer = timers.get(dropped.id) - if (timer !== undefined) { - clearTimeout(timer) - timers.delete(dropped.id) - } - } - store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }) - armToast(toast.id) - } - - // ---------------------------------------------------- system notification - - /** Bring one conversation to the front, when the client exposes that operation. */ - const openSession = (sessionId) => { - try { - ctx.get('uiWorkspace')?.openSession?.(sessionId) - } catch (error) { - notify(`could not open "${sessionId}": ${text(error)}`) - } - } - - /** - * The system-notification channel's own answer about one notification. - * - * A browser reports the platform's verdict asynchronously: `show` means the - * notification reached the desktop, `error` means the platform refused it, and - * silence means neither answer arrived. That third case is the one that used to - * lose alerts silently, so it is recorded rather than assumed to be a success. - * @param monitor - the record returned by {@link systemNotify} for this notification. - * @param shown - `true` when the platform displayed it, `false` when it refused. - */ - const confirmDelivery = (monitor, shown) => { - if (monitor === null || monitor === undefined || typeof monitor !== 'object') return - monitor.shown = shown - const snapshot = store.getSnapshot() - const last = snapshot.lastDelivery - if (last === null || last === undefined || last.monitor !== monitor || last.shown === shown) return - store.set({ ...snapshot, lastDelivery: { ...last, shown } }) - } - - /** Hold one raised notification open, bounded, so it cannot be collected while pending. */ - const keepRaised = (notification) => { - state.raised.add(notification) - while (state.raised.size > RAISED_LIMIT) { - const oldest = state.raised.values().next().value - if (oldest === undefined) break - state.raised.delete(oldest) - } - } - - /** - * Raise one system notification, tolerating every refusal a browser may give, - * and report what the platform said about it. - * @returns a record whose `outcome` is the channel's verdict (`raised`, - * `permission`, `unsupported`, or `threw`) and whose `shown` is filled in - * later by {@link confirmDelivery} — `true` displayed, `false` refused, - * `undefined` while the platform has said nothing. - */ - const systemNotify = (candidate, force) => { - const Ctor = globalThis.Notification - const permission = notificationPermission() - if (permission === 'unsupported') return { outcome: 'unsupported' } - if (!force && permission !== 'granted') return { outcome: 'permission' } - try { - const notice = copyFor(candidate) - const options = { - body: notice.body, - tag: `dsh-session-${candidate.sessionId}`, - requireInteraction: false, - } - if (systemIcon !== undefined) options.icon = systemIcon - const notification = new Ctor(notice.title, options) - const record = { outcome: 'raised', notice, shown: undefined } - keepRaised(notification) - notification.onshow = () => { - confirmDelivery(record, true) - } - notification.onerror = (event) => { - confirmDelivery(record, false) - notify(`system notification was refused: ${String(event?.message ?? 'unknown')}`) - } - notification.onclose = () => { - state.raised.delete(notification) - } - notification.onclick = () => { - dropReplay(candidate) - focusWindow() - openSession(candidate.sessionId) - try { - notification.close() - } catch { - /* close is best effort on every platform */ - } - } - return record - } catch (error) { - notify(`system notification failed: ${text(error)}`) - return { outcome: 'threw' } - } - } - - // ------------------------------------------------------------ replay - - /** Forget one queued replay: the user has just answered that alert in the system channel. */ - const dropReplay = (candidate) => { - const next = state.replay.filter((entry) => ( - entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind - )) - if (next.length === state.replay.length) return - state.replay = next - publish() - } - - /** - * Keep one away-channel alert for an in-app replay. - * - * A desktop notification raised while nobody is looking at the machine is - * easy to miss: its banner lives a few seconds, its history lives in the - * system's own notification centre, which the user may never open, and a - * refused display leaves no trace at all on the page. The alert is therefore - * replayed as a light in-app popup as soon as the window is back in the - * foreground, except when the platform confirmed the banner and the user was - * back within {@link REPLAY_QUIET_MS} — then the banner is still on screen and - * a second surface would only be noise. Entries older than - * {@link REPLAY_TTL_MS} are dropped instead of waiting for a user who has - * moved on. - * @param candidate - the alert to keep for a replay. - * @param monitor - the system channel's own record, so its confirmation can be read later. - */ - const holdForReplay = (candidate, monitor) => { - const now = Date.now() - state.replay = [ - ...state.replay.filter((entry) => ( - entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind - )), - { candidate, at: now, monitor }, - ].slice(-REPLAY_LIMIT) - publish() - } - - /** Show every away-channel alert the user has not had a chance to see yet. */ - const flushReplay = () => { - if (state.replay.length === 0) return - const now = Date.now() - const waiting = state.replay - state.replay = [] - const due = waiting.filter((entry) => { - if (now - entry.at > REPLAY_TTL_MS) return false - // A banner that the platform confirmed and that is still inside its own - // visible window has already told the user; do not say it twice. - return !(entry.monitor?.shown === true && now - entry.at <= REPLAY_QUIET_MS) - }) - const shown = due.slice(-TOAST_LIMIT) - for (const entry of shown) showToast(entry.candidate) - if (shown.length > 0) recordDelivery('replay', shown[shown.length - 1].candidate) - else publish() - } - - // -------------------------------------------------------------- delivery - - /** Whether the user is looking at this exact conversation right now. */ - const isOnScreen = (sessionId, list) => { - if (list === undefined) return false - for (const id of Object.keys(list.byId ?? {})) { - const summary = list.byId[id] - if (summary !== undefined && (summary.retainedBy?.mainView ?? 0) > 0) return id === sessionId - } - return false - } - - /** - * Record what actually happened to the newest alert. - * - * Everything on the delivery path used to fail invisibly: the alert simply - * never appeared, with no way to tell a wrong channel decision from a - * refused constructor. The config page shows this record, so the next - * question ("did it even try?") has an answer. - * - * The time is the user's own clock, not UTC: a log line the reader has to - * translate by eight hours is worse than no log line. - * @param outcome - which channel carried the alert, or why it fell back. - * @param candidate - the alert being recorded. - * @param monitor - the system channel's own record, when that is the channel. - * @returns the stored record, so a later platform answer can update it. - */ - const recordDelivery = (outcome, candidate, monitor) => { - state.deliverySeq += 1 - const now = new Date() - const pad = (value) => String(value).padStart(2, '0') - const record = { - seq: state.deliverySeq, - outcome, - at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, - title: copyFor(candidate).title, - shown: monitor?.shown, - monitor, - } - store.set({ ...store.getSnapshot(), lastDelivery: record }) - return record - } - - /** - * Whether a queued candidate is still worth delivering. Read from the LIVE - * snapshot rather than the one captured when the transition was seen: a - * conversation that resumed in the settle window owes no alert, and the - * snapshot the transition was derived from still carries the pre-transition - * `running` flag of the session record. - */ - const stillWorth = (candidate) => { - const list = state.live.list - if (candidate.test === true || list === undefined) return true - const summary = list.byId?.[candidate.sessionId] - if (summary === undefined) return true - const status = state.live.status - const running = status?.get?.(candidate.sessionId)?.running ?? summary.running - return running !== true - } - - /** - * Deliver one candidate through exactly one channel: the system - * notification while the window is away, the light popup while it is in - * front, and nothing at all for the conversation on screen. - * - * The channel decision reads the window state right here, at delivery time, - * instead of trusting the state the observer last remembered — that is what - * an unreported blur used to defeat. An alert that leaves through the system - * channel is additionally held for an in-app replay, because a desktop banner - * raised while nobody is looking at the machine is a notification the user - * never actually receives. - */ - const deliver = (candidate, ignoreThrottle) => { - if (ignoreThrottle !== true) { - const now = Date.now() - if (now - state.lastDelivery < THROTTLE_MS) return - state.lastDelivery = now - } - if (!windowIsAway()) { - if (isOnScreen(candidate.sessionId, state.live.list)) return - showToast(candidate) - recordDelivery('popup', candidate) - return - } - const result = systemNotify(candidate, candidate.test === true) - if (result.outcome === 'raised') { - recordDelivery('system', candidate, result) - if (candidate.test !== true) holdForReplay(candidate, result) - return - } - // A test alert still has to reach the user, and so does a real one when - // the system channel refuses: the popup is the channel that remains. - showToast(candidate) - recordDelivery(`system-refused-${result.outcome}`, candidate) - } - - /** - * Take the delivery decision on a later tick, from the freshest state. - * - * One candidate per tick keeps the throttle meaningful, and anything still - * queued arms its own follow-up tick — a burst used to leave every candidate - * after the first stranded in the queue with no timer to flush it. - */ - const flushSettle = () => { - state.settleTimer = 0 - if (disposed) return - const candidate = state.pending.shift() - if (candidate !== undefined && stillWorth(candidate)) deliver(candidate, false) - if (state.pending.length > 0) state.settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) - } - - /** Hold one candidate for the settle tick, collapsing a burst onto one timer. */ - const queue = (candidate) => { - state.pending.push(candidate) - if (state.settleTimer !== 0) return - state.settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) - } - - // ----------------------------------------------------------- observation - - /** - * One derivation pass over the client's own session state: remember the - * freshest snapshot for the settle tick, compare it against what this page - * already observed, emit at most one candidate per transition, and let the - * settle tick decide delivery. The window state is deliberately NOT captured - * here — the delivery reads it fresh, because it can change inside the - * settle window. - */ - const observe = (list, status) => { - if (list === undefined || status === undefined) return - state.live = { list, status } - const kinds = store.getSnapshot().kinds - for (const sessionId of Object.keys(list.byId ?? {})) { - const summary = list.byId[sessionId] - if (summary === undefined || summary.origin === 'subagent') continue - const sessionStatus = status.get(sessionId) - const running = sessionStatus?.running ?? summary.running - - if (running === true) { - state.runs.set(sessionId, true) - state.completionNotice.delete(sessionId) - } else { - if (state.runs.get(sessionId) === true) { - state.runs.set(sessionId, false) - if (summary.blank !== true && !state.completionNotice.has(sessionId) && kinds.completion) { - state.completionNotice.add(sessionId) - queue({ - kind: 'completion', - sessionId, - title: titleOf(summary, sessionId), - detail: '', - pendingKind: '', - }) - } - } else if (!state.runs.has(sessionId)) { - state.runs.set(sessionId, false) - } - } - - const interaction = sessionStatus?.pendingInteraction - const pendingKind = servedPendingKind(interaction) - if (pendingKind === undefined) { - state.pendingNotice.delete(sessionId) - continue - } - if (state.pendingNotice.get(sessionId) === pendingKind) continue - state.pendingNotice.set(sessionId, pendingKind) - const trigger = pendingKind === 'approval' ? 'approval' : 'question' - if (!kinds[trigger]) continue - queue({ - kind: trigger, - sessionId, - title: titleOf(summary, sessionId), - detail: pendingKind === 'approval' - ? String(interaction?.toolName ?? '') - : questionText(interaction), - pendingKind, - }) - } - - for (const sessionId of [...state.runs.keys()]) { - if (list.byId?.[sessionId] !== undefined) continue - state.runs.delete(sessionId) - state.completionNotice.delete(sessionId) - state.pendingNotice.delete(sessionId) - } - } - - // --------------------------------------------------------------- actions - - /** Build one test alert that skips the throttle and the "on screen" rule. */ - const testCandidate = (title) => ({ - kind: 'completion', - sessionId: 'test', - title, - detail: '', - pendingKind: '', - test: true, - }) - - /** - * Raise one test alert through whichever channel the window state selects. - * Verified from the config page, where the user is looking at the app, so it - * normally lands in the popup — the way to prove the system channel is to - * press the button in a channel-specific test instead. - */ - const sendTest = () => { - deliver(testCandidate(t('config.testAny')), true) - } - - /** Raise the test alert on the system channel specifically, whatever the focus is. */ - const sendTestSystem = () => { - const candidate = testCandidate(t('config.testSystem')) - const result = systemNotify(candidate, true) - if (result.outcome === 'raised') { - recordDelivery('system', candidate, result) - return - } - showToast(candidate) - recordDelivery(`system-refused-${result.outcome}`, candidate) - } - - /** Request notification permission inside a user gesture, then report the outcome. */ - const requestPermission = async () => { - const Ctor = globalThis.Notification - if (typeof Ctor !== 'function') { - publish() - return - } - try { - let result = Ctor.requestPermission() - if (result === undefined) { - result = new Promise((resolve) => { - Ctor.requestPermission((value) => resolve(value)) - }) - } - await result - } catch (error) { - const snapshot = store.getSnapshot() - store.set({ ...snapshot, promptError: t('settings.permission.promptFailed', { message: text(error) }) }) - return - } - const snapshot = store.getSnapshot() - store.set({ ...snapshot, permission: notificationPermission(), promptError: '' }) - if (notificationPermission() === 'granted') sendTest() - } - - /** Toggle one trigger and remember the choice. */ - const setKindEnabled = (kind, enabled) => { - const snapshot = store.getSnapshot() - const kinds = { ...snapshot.kinds, [kind]: enabled } - writeStoredKinds(kinds) - store.set({ ...snapshot, kinds }) - } - - // ----------------------------------------------------------- components - - /** Subscribe to this plugin's own store from inside a component. */ - function useOwnStore(source) { - return React.useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) - } - - /** - * Subscribe to every browser signal that says whether the app is visible: - * the window's focus events and the document's visibility change. The value - * itself is a boolean, so the observer re-renders once per transition. - */ - function useWindowState() { - const subscribe = React.useCallback((listener) => { - try { - window.addEventListener('focus', listener) - window.addEventListener('blur', listener) - document.addEventListener('visibilitychange', listener) - } catch { - return () => {} - } - return () => { - window.removeEventListener('focus', listener) - window.removeEventListener('blur', listener) - document.removeEventListener('visibilitychange', listener) - } - }, []) - return React.useSyncExternalStore(subscribe, () => !windowIsAway(), () => true) - } - - /** - * The resident observer: one invisible entry in a root-scope slot, which is - * how a plugin reaches the session hooks without occupying visible UI. It - * renders `null`, and any failure in its own derivation is contained here so - * a slot entry can never crash. - * - * It subscribes to the window state only so a foreground/background change - * re-derives promptly; the delivery itself re-reads that state, so a missed - * event can no longer pick the wrong channel. - */ - function NotifyObserver(props) { - useWindowState() - const useSessions = props.useSessions - const useSessionStatus = props.useSessionStatus - const select = React.useCallback((snapshot) => snapshot, []) - const list = typeof useSessions === 'function' ? useSessions(select) : undefined - const status = typeof useSessionStatus === 'function' ? useSessionStatus(select) : undefined - React.useEffect(() => { - try { - if (typeof props.useSessions !== 'function' || typeof props.useSessionStatus !== 'function') { - applyHealth(store, { state: 'noHooks', message: '' }) - return - } - if (list === undefined || status === undefined) { - applyHealth(store, { state: 'starting', message: '' }) - return - } - observe(list, status) - applyHealth(store, { state: 'watching', message: '' }) - } catch (error) { - applyHealth(store, { state: 'error', message: text(error) }) - } - }) - return null - } - - /** The light popup stack: the frame-wide overlay entry this package owns. */ - function ToastLayer(props) { - const snapshot = useOwnStore(props.store) - const toasts = snapshot.toasts - const tr = seatFor(props) - React.useEffect(() => { - if (toasts.length === 0) return undefined - const onKeyDown = (event) => { - if (event.key !== 'Escape' || event.defaultPrevented) return - const top = toasts[0] - if (top === undefined) return - event.preventDefault() - props.dismiss(top.id) - } - document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('keydown', onKeyDown) - } - }, [toasts, props]) - if (toasts.length === 0) return null - return h(React.Fragment, null, - h('style', null, CSS), - h('div', { className: 'dsn-stack', role: 'region', 'aria-live': 'polite' }, - toasts.map((toast) => h('div', { - key: toast.id, - className: 'dsn-toast', - role: toast.kind === 'completion' ? 'status' : 'alert', - onMouseEnter: () => props.hold(toast.id), - onMouseLeave: () => props.arm(toast.id), - }, - h('span', { className: `dsn-toast-icon is-${toast.kind}` }, h(KindIcon, { kind: toast.kind, size: 16 })), - h('div', { className: 'dsn-toast-text' }, - h('div', { className: 'dsn-toast-title' }, toast.title), - h('div', { className: 'dsn-toast-desc' }, toast.body)), - h('div', { className: 'dsn-toast-actions' }, - h('button', { - type: 'button', - className: 'dsn-toast-action', - onClick: () => { - props.dismiss(toast.id) - props.open(toast.sessionId) - }, - }, tr('toast.view')), - h('button', { - type: 'button', - className: 'dsn-toast-close', - 'aria-label': tr('toast.dismiss'), - onClick: () => props.dismiss(toast.id), - }, h(CloseIcon, { size: 14 }))))))) - } - - /** - * One trigger switch row: the label says which notification it governs and - * the switch is the control, so the row never needs a second description. - */ - function KindRow(props) { - return h('div', { className: 'dsn-row' }, - h('div', { className: 'dsn-row-text' }, - h('div', { className: 'dsn-row-title' }, props.label)), - h('button', { - type: 'button', - role: 'switch', - className: 'dsn-switch', - 'aria-checked': props.checked, - 'aria-label': props.label, - disabled: props.disabled, - onClick: props.onToggle, - }, h('span', { className: 'dsn-thumb' }))) - } - - /** - * This plugin's configuration, rendered on its own page in the plugin - * manager (`plugins.bundle.config`, keyed by this package's name). - * - * The owner asks for two views: `summary` (a one-liner that stands in for - * the row's description) and `page` (the whole form). Everything visible here - * belongs to this package, including the platform-specific permission hint. - */ - function ConfigSection(props) { - const snapshot = useOwnStore(props.store) - const tr = seatFor(props) - if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description')) - const permission = snapshot.permission - const supported = permission !== 'unsupported' - const failed = permission === 'denied' || snapshot.promptError !== '' - const statusKey = permission === 'granted' - ? 'settings.permission.granted' - : permission === 'denied' - ? 'settings.permission.denied' - : supported - ? 'settings.permission.default' - : 'settings.permission.unsupported' - const permissionText = snapshot.promptError === '' - ? `${tr(statusKey)}${permission === 'denied' || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ''}` - : snapshot.promptError - const health = snapshot.health - const healthText = health.state === 'watching' - ? tr('settings.health.watching') - : health.state === 'noHooks' - ? tr('settings.health.noHooks') - : health.state === 'error' - ? tr('settings.health.error', { message: health.message }) - : tr('settings.health.starting') - const healthFailed = health.state !== 'watching' && health.state !== 'starting' - return h(React.Fragment, null, - h('style', null, CSS), - h('div', { className: 'dsn-sections' }, - // The page header already shows this bundle's title and description, so - // this section opens with what the header cannot say: how alerts are - // carried, and what this form changes. - h('section', { className: 'dsn-section' }, - h('p', { className: 'dsn-section-desc' }, tr('config.intro'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')), - KINDS.map((kind) => h(KindRow, { - key: kind, - label: tr(`settings.kind.${kind}`), - checked: snapshot.kinds[kind] === true, - disabled: false, - onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true), - })), - h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')), - h('div', { className: 'dsn-row' }, - h('div', { className: 'dsn-row-text' }, - h('p', { - className: `dsn-status${failed ? ' dsn-status-error' : ''}`, - role: failed ? 'alert' : 'status', - }, permissionText)), - h('button', { - type: 'button', - className: 'dsn-button', - disabled: !supported, - onClick: permission === 'granted' ? props.testSystem : props.ask, - }, tr(!supported - ? 'settings.action.unsupported' - : permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))), - h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))), - h('section', { className: 'dsn-section' }, - h('h3', { className: 'dsn-section-title' }, tr('config.section.status')), - h('div', { className: 'dsn-row' }, - h('div', { className: 'dsn-row-text' }, - h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`), - h('p', { - className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`, - role: healthFailed ? 'alert' : 'status', - }, healthText), - h('p', { className: 'dsn-status' }, deliveryText(tr)), - h('p', { className: 'dsn-status' }, replayText(tr))), - h('button', { - type: 'button', - className: 'dsn-button', - onClick: props.testAny, - }, tr('settings.action.testAny')))))) - } - - /** Describe the newest alert's fate in one line. */ - function deliveryText(tr) { - const last = store.getSnapshot().lastDelivery - if (last === null || last === undefined) return tr('config.diag.none') - const outcome = last.outcome.startsWith('system-refused') - ? tr('config.diag.refused') - : last.outcome === 'system' - ? last.shown === true - ? tr('config.diag.systemShown') - : last.shown === false - ? tr('config.diag.systemFailed') - : tr('config.diag.systemUnconfirmed') - : last.outcome === 'replay' - ? tr('config.diag.replayDelivery') - : tr('config.diag.popup') - return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}` - } - - /** Say how many away-channel alerts are still waiting to be replayed in-app. */ - function replayText(tr) { - const pending = state.replay.length - return pending === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count: pending }) - } - - // ---------------------------------------------------------- registration - - /** Keep the page's own focus events owned by this plugin's effect. */ - const installBridges = () => { - const onBlur = () => { - state.lastDelivery = 0 - } - // Coming back is the moment the user can finally be told about whatever the - // system channel carried while the window was away, and it also clears the - // throttle so the first alert after returning is never swallowed. - const onReturn = () => { - state.lastDelivery = 0 - flushReplay() - } - const onVisibilityChange = () => { - if (!windowIsAway()) onReturn() - } - try { - window.addEventListener('focus', onReturn) - window.addEventListener('blur', onBlur) - document.addEventListener('visibilitychange', onVisibilityChange) - } catch { - /* an unreadable window still delivers through live focus reads */ - } - return () => { - try { - window.removeEventListener('focus', onReturn) - window.removeEventListener('blur', onBlur) - document.removeEventListener('visibilitychange', onVisibilityChange) - } catch { - /* nothing to detach */ - } - if (state.settleTimer !== 0) { - clearTimeout(state.settleTimer) - state.settleTimer = 0 - } - for (const timer of timers.values()) clearTimeout(timer) - timers.clear() - state.raised.clear() - state.replay = [] - } - } - - ctx.effect(() => registerDictionaries(locale), 'session-notify: dictionaries') - ctx.effect(() => installBridges(), 'session-notify: window focus') - ctx.effect(() => () => { - disposed = true - }, 'session-notify: disposal') - - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', - id: `${NS}-toasts`, - order: 100, - locale: NS, - inject: () => ({ - store, - dismiss: closeToast, - arm: armToast, - hold: holdToast, - open: (sessionId) => { - focusWindow() - openSession(sessionId) - }, - }), - }, ToastLayer)) - - // This plugin's configuration lives on its own page in the plugin manager, - // keyed by this package's name — the seat the shipped experimental bundle - // uses for its own settings, and the one the plugin page renders between the - // bundle's description and its rows. It is deliberately NOT a General - // settings row: a plugin's options belong to the plugin. - ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ - name: 'plugins.bundle.config', - key: PACKAGE_NAME, - locale: NS, - inject: () => ({ - store, - platform: platformKey(), - ask: requestPermission, - testAny: sendTest, - testSystem: sendTestSystem, - setKind: setKindEnabled, - }), - }, ConfigSection)) - - // The observer lives in the frame-wide overlay, NOT in `sidebar.panellist`. - // - // That owner does not merely render its entries: it also reads WHICH entries - // exist and turns each one into a sidebar panel button (id → panel id, label - // → button text, the entry itself → the glyph). A renderless entry there is - // therefore an extra, empty panel row in the left column — which is exactly - // how this plugin broke the sidebar once. The overlay is the non-generic - // seat: it is a floating layer whose entries routinely render nothing, so a - // `null`-rendering observer is an ordinary occupant, and it carries the same - // root-scope standard props (`useSessions`, `useSessionStatus`). - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', - id: `${NS}-watch`, - order: 101, - }, NotifyObserver)) - } - - return { - inject: ['slots', 'locale'], - apply, - } + return module.exports }, }) + diff --git a/package.json b/package.json index 862ff48..e18dc7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-plugin/session-notify", - "version": "1.0.6", + "version": "1.0.7", "private": true, "type": "module", "description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。", @@ -34,6 +34,18 @@ "engines": { "node": ">=22" }, + "scripts": { + "build": "node scripts/build-client.mjs", + "test": "node --test" + }, + "devDependencies": { + "esbuild": "0.28.2" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "dsh": { "manifestVersion": 1, "bundle": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..72c6084 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,285 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + esbuild: + specifier: 0.28.2 + version: 0.28.2 + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 diff --git a/scripts/build-client.mjs b/scripts/build-client.mjs new file mode 100644 index 0000000..b028419 --- /dev/null +++ b/scripts/build-client.mjs @@ -0,0 +1,155 @@ +// Bundles the browser half's modules into the single self-contained `client.js` +// this package is served through. +// +// `dsh-client-modules` hands a plugin bundle one lazy CJS factory whose +// `require` resolves against the module table (platform seeds such as `react`, +// and other plugins' rows) — never against relative files. A client entry must +// therefore be one self-contained script that registers exactly one factory, +// and every module of this package has to be inlined into it. Source of truth: +// `src/client/**`; the generated `client.js` at the repository root is the +// artifact DSH serves, and it is committed so installing this plugin still needs +// no build step. +// +// Usage: +// node scripts/build-client.mjs # rebuild client.js +// node --test tests # also asserts the artifact is fresh +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const entryPoint = resolve(repoRoot, 'src/client/index.js') +const targetPath = resolve(repoRoot, 'client.js') + +/** The module-table name this bundle's factory is registered under. */ +const packageName = JSON.parse(await readFile(resolve(repoRoot, 'package.json'), 'utf8')).name + +// The wrapper. Everything the bundler emits goes INSIDE the factory, so the +// module table's own `require` is in scope for it: `require('react')` runs when +// the bundle materializes, not when the script is executed, which is the +// laziness contract the module system documents. +const banner = [ + '// Generated by scripts/build-client.mjs — do not edit this file.', + '// Source of truth: src/client/**. Run `pnpm run build` after changing it.', + `// Served by dsh-client-modules at /plugins/${packageName}/client.js.`, + 'window.__ModuleLoader__.load({', + ` id: '${packageName}',`, + ' factory(require) {', + ' const module = { exports: {} }', + ' // Same object as `module.exports`; kept so the emitted body may assign', + ' // through either binding.', + ' const exports = module.exports', + '', +].join('\n') + +const footer = [ + '', + ' return module.exports', + ' },', + '})', + '', +].join('\n') + +/** Referenced modules that are NOT platform seeds, and must reach `dsh.client.external`. */ +const external = ['react'] + +/** How the wrapper is recognized in a built artifact; the test suite reuses it. */ +export const WRAPPER = { + load: 'window.__ModuleLoader__.load({', + id: `id: '${packageName}',`, + factory: 'factory(require) {', + ret: 'return module.exports', + footer: '})', +} + +/** + * Load esbuild through a dynamic import, so a checkout without devDependencies + * gets one actionable sentence instead of a module-resolution stack. + * @returns the esbuild module. + */ +async function loadEsbuild() { + try { + return await import('esbuild') + } catch { + throw new Error('esbuild is missing — run `pnpm install` in the repository root first') + } +} + +/** + * Refuse to ship an artifact that does not honor the registration contract. + * + * These checks are the machine-readable half of the reason this repository has a + * build step at all: a bundle that escapes the factory, that carries a top-level + * `import`, or that asks the module table for something undeclared would fail in + * the page rather than here. + * @param source - the bundled artifact text. + */ +function assertBundleShape(source) { + if (!source.startsWith('// Generated by scripts/build-client.mjs')) { + throw new Error('bundle does not start with the generated-file header') + } + for (const [what, needle] of Object.entries(WRAPPER)) { + if (!source.includes(needle)) throw new Error(`bundle is missing its ${what}: ${needle}`) + } + if (!source.trimEnd().endsWith(WRAPPER.footer)) { + throw new Error('bundle does not close the registration wrapper') + } + + const factoryAt = source.indexOf(WRAPPER.factory) + const reactAt = source.indexOf('require("react")') + if (reactAt === -1) throw new Error('bundle never requires react') + if (reactAt < factoryAt) throw new Error('bundle requires react outside the factory, so it would load eagerly') + + if (/^\s*(?:import|export)\s/m.test(source)) { + throw new Error('bundle has a top-level import/export statement; it is not a self-contained script') + } + const requested = new Set([...source.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)].map((match) => match[1])) + for (const specifier of requested) { + if (!external.includes(specifier)) { + throw new Error(`bundle asks the module table for "${specifier}" — declare it in dsh.client.external and build-client.mjs`) + } + } +} + +/** + * Bundle the browser half. + * @param options - `write: false` returns the artifact without touching the repository. + * @returns the bundled `client.js` text. + */ +export async function buildClient({ write = true } = {}) { + const { build } = await loadEsbuild() + const result = await build({ + entryPoints: [entryPoint], + outfile: 'client.js', + bundle: true, + format: 'cjs', + platform: 'browser', + target: 'es2022', + charset: 'utf8', + minify: false, + legalComments: 'none', + external, + banner: { js: banner }, + footer: { js: footer }, + write: false, + logLevel: 'warning', + }) + + const produced = result.outputFiles?.[0]?.text + if (typeof produced !== 'string' || produced === '') throw new Error('esbuild produced no output') + // The repository is LF-only (see .gitattributes) and the artifact is reviewed + // as a diff, so nothing here may follow the build machine's conventions: + // esbuild emits LF, and this keeps that true if a future option changes it. + const built = produced.replace(/\r\n/g, '\n') + assertBundleShape(built) + if (write) await writeFile(targetPath, built, 'utf8') + return built +} + +const invokedDirectly = process.argv[1] !== undefined + && pathToFileURL(process.argv[1]).href === import.meta.url + +if (invokedDirectly) { + const built = await buildClient() + console.log(`session-notify: client.js rebuilt from src/client (${built.length} bytes)`) +} diff --git a/src/client/constants.js b/src/client/constants.js new file mode 100644 index 0000000..653f807 --- /dev/null +++ b/src/client/constants.js @@ -0,0 +1,49 @@ +/** + * Fixed vocabulary shared by every module of the browser half. + * + * These values are this package's own contract rather than configuration: the + * trigger ids are read by the store, the copy table and the settings row at the + * same time, the timing constants are quoted in README.md's rule list, and the + * storage key is the browser-local preference this package owns. Keeping them + * in one module is what lets the others stay about behavior. + * + * @module @dsh-plugin/session-notify/client/constants + */ + +/** Locale namespace owned by this package. */ +export const NS = 'session-notify' + +/** This package's name: the plugin manager keys a bundle's configuration by it. */ +export const PACKAGE_NAME = '@dsh-plugin/session-notify' + +/** Trigger ids shared by the store, the copy table, and the settings row. */ +export const KINDS = ['completion', 'approval', 'question'] + +/** Pending-interaction kinds the Harness itself renders, and this plugin serves. */ +export const PENDING_KINDS = ['approval', 'question', 'plan-review'] + +/** Shortest gap between two deliveries, so parallel finishes cannot flood. */ +export const THROTTLE_MS = 1500 + +/** Settle delay before a completion is delivered, so a resumed run stays silent. */ +export const SETTLE_MS = 400 + +/** In-app popup lifetime, and how many may stack. */ +export const TOAST_DURATION_MS = 6000 +export const TOAST_LIMIT = 3 + +/** Notifications kept referenced, so a collection can never cancel a pending display. */ +export const RAISED_LIMIT = 8 + +/** Alerts carried by the system channel and still worth replaying in-app on return. */ +export const REPLAY_LIMIT = 8 + +/** How long a system notification outlives the moment the user comes back. */ +export const REPLAY_TTL_MS = 30 * 60 * 1000 + +/** + * A confirmed banner that the user came back to within this window is treated as + * seen: the desktop notification is still on screen at that moment, so replaying + * it in-app would be a duplicate rather than a reminder. + */ +export const REPLAY_QUIET_MS = 20 * 1000 diff --git a/src/client/core/actions.js b/src/client/core/actions.js new file mode 100644 index 0000000..4c1985a --- /dev/null +++ b/src/client/core/actions.js @@ -0,0 +1,87 @@ +/** + * What the settings page can do: the two test buttons, the permission request, + * and the trigger switches. + * + * @module @dsh-plugin/session-notify/client/core/actions + */ + +import { notificationApi, notificationPermission } from '../platform.js' +import { writeStoredKinds } from './storage.js' +import { text } from './log.js' + +/** + * Build the actions over one store. + * @param deps - the plugin store; the translate function; the delivery path (the + * test alert goes through the ordinary channel decision); the system channel + * (the channel-specific test); the popup stack (used when the system channel + * refuses a test); and `publish` for the one branch that only re-renders. + * @returns the actions the settings page and the permission row call. + */ +export function createActions({ store, t, delivery, system, toasts, publish }) { + /** Build one test alert that skips the throttle and the "on screen" rule. */ + const testCandidate = (title) => ({ + kind: 'completion', + sessionId: 'test', + title, + detail: '', + pendingKind: '', + test: true, + }) + + /** + * Raise one test alert through whichever channel the window state selects. + * Verified from the config page, where the user is looking at the app, so it + * normally lands in the popup — the way to prove the system channel is to + * press the button in a channel-specific test instead. + */ + const sendTest = () => { + delivery.deliver(testCandidate(t('config.testAny')), true) + } + + /** Raise the test alert on the system channel specifically, whatever the focus is. */ + const sendTestSystem = () => { + const candidate = testCandidate(t('config.testSystem')) + const result = system.notifySystem(candidate, true) + if (result.outcome === 'raised') { + delivery.recordDelivery('system', candidate, result) + return + } + toasts.showToast(candidate) + delivery.recordDelivery(`system-refused-${result.outcome}`, candidate) + } + + /** Request notification permission inside a user gesture, then report the outcome. */ + const requestPermission = async () => { + const Ctor = notificationApi() + if (Ctor === undefined) { + publish() + return + } + try { + let result = Ctor.requestPermission() + if (result === undefined) { + result = new Promise((resolve) => { + Ctor.requestPermission((value) => resolve(value)) + }) + } + await result + } catch (error) { + const snapshot = store.getSnapshot() + store.set({ ...snapshot, promptError: t('settings.permission.promptFailed', { message: text(error) }) }) + return + } + const snapshot = store.getSnapshot() + store.set({ ...snapshot, permission: notificationPermission(), promptError: '' }) + if (notificationPermission() === 'granted') sendTest() + } + + /** Toggle one trigger and remember the choice. */ + const setKindEnabled = (kind, enabled) => { + const snapshot = store.getSnapshot() + const kinds = { ...snapshot.kinds, [kind]: enabled } + writeStoredKinds(kinds) + store.set({ ...snapshot, kinds }) + } + + return { sendTest, sendTestSystem, requestPermission, setKindEnabled } +} diff --git a/src/client/core/copy.js b/src/client/core/copy.js new file mode 100644 index 0000000..1d7c87b --- /dev/null +++ b/src/client/core/copy.js @@ -0,0 +1,68 @@ +/** + * The delivered copy: what a candidate becomes once it is on screen, plus the + * two diagnoses the settings page prints about past deliveries. + * + * @module @dsh-plugin/session-notify/client/core/copy + */ + +/** + * Build the copy table for one translate function. + * @param t - the translate function every string here goes through. + * @returns `copyFor` (candidate → notice) and the two diagnosis renderers. + */ +export function createCopy(t) { + /** Build the delivered copy for one candidate. */ + const copyFor = (candidate) => { + const title = candidate.title === '' ? t('body.untitled') : candidate.title + if (candidate.kind === 'completion') { + return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) } + } + if (candidate.kind === 'question') { + const question = candidate.detail + return { + kind: 'question', + title: t('notification.question'), + body: question === '' ? t('body.question', { title }) : `${title} · ${question}`, + } + } + // Kept in v1.0.6's order, which makes this branch unreachable today: the + // observer queues a plan review as `kind: 'question'`, so the branch above + // already answered with the plan's own text. Restoring the documented copy + // (`body.planReview`, "计划正在等待你确认") means testing `pendingKind` + // before `kind` — a user-visible behavior change, so it is deliberately NOT + // part of this refactor. `tests/unit/copy.test.js` pins today's output. + if (candidate.pendingKind === 'plan-review') { + return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) } + } + const tool = candidate.detail + return { + kind: 'approval', + title: t('notification.approval'), + body: tool === '' ? t('body.approvalPlain', { title }) : t('body.approval', { title, tool }), + } + } + + /** Describe the newest alert's fate in one line. */ + const describeDelivery = (last, tr) => { + if (last === null || last === undefined) return tr('config.diag.none') + const outcome = last.outcome.startsWith('system-refused') + ? tr('config.diag.refused') + : last.outcome === 'system' + ? last.shown === true + ? tr('config.diag.systemShown') + : last.shown === false + ? tr('config.diag.systemFailed') + : tr('config.diag.systemUnconfirmed') + : last.outcome === 'replay' + ? tr('config.diag.replayDelivery') + : tr('config.diag.popup') + return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}` + } + + /** Say how many away-channel alerts are still waiting to be replayed in-app. */ + const describeReplay = (count, tr) => ( + count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count }) + ) + + return { copyFor, describeDelivery, describeReplay } +} diff --git a/src/client/core/delivery.js b/src/client/core/delivery.js new file mode 100644 index 0000000..3cf0fcc --- /dev/null +++ b/src/client/core/delivery.js @@ -0,0 +1,134 @@ +/** + * Delivery: choose exactly one channel for one alert, at the moment the alert + * is owed, and record what happened. + * + * @module @dsh-plugin/session-notify/client/core/delivery + */ + +import { SETTLE_MS, THROTTLE_MS } from '../constants.js' +import { windowIsAway } from '../platform.js' +import { isOnScreen, stillWorth } from './session.js' + +/** + * Build the delivery path. + * @param deps - the plugin store; the shared `live` cell the observer writes and + * this path reads at delivery time; the copy table; the popup stack; the + * system channel; and the replay queue. + * @returns the delivery operations, the settle runner, and the throttle reset a + * window transition needs. + */ +export function createDelivery({ store, copy, live, toasts, system, replay }) { + const pending = [] + let settleTimer = 0 + let lastDelivery = 0 + let deliverySeq = 0 + + /** + * Record what actually happened to the newest alert. + * + * Everything on the delivery path used to fail invisibly: the alert simply + * never appeared, with no way to tell a wrong channel decision from a + * refused constructor. The config page shows this record, so the next + * question ("did it even try?") has an answer. + * + * The time is the user's own clock, not UTC: a log line the reader has to + * translate by eight hours is worse than no log line. + * @param outcome - which channel carried the alert, or why it fell back. + * @param candidate - the alert being recorded. + * @param monitor - the system channel's own record, when that is the channel. + * @returns the stored record, so a later platform answer can update it. + */ + const recordDelivery = (outcome, candidate, monitor) => { + deliverySeq += 1 + const now = new Date() + const pad = (value) => String(value).padStart(2, '0') + const record = { + seq: deliverySeq, + outcome, + at: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + title: copy.copyFor(candidate).title, + shown: monitor?.shown, + monitor, + } + store.set({ ...store.getSnapshot(), lastDelivery: record }) + return record + } + + /** + * Deliver one candidate through exactly one channel: the system + * notification while the window is away, the light popup while it is in + * front, and nothing at all for the conversation on screen. + * + * The channel decision reads the window state right here, at delivery time, + * instead of trusting the state the observer last remembered — that is what + * an unreported blur used to defeat. An alert that leaves through the system + * channel is additionally held for an in-app replay, because a desktop banner + * raised while nobody is looking at the machine is a notification the user + * never actually receives. + * @param candidate - the alert to deliver. + * @param ignoreThrottle - deliver immediately, bypassing the flood guard. + */ + const deliver = (candidate, ignoreThrottle) => { + if (ignoreThrottle !== true) { + const now = Date.now() + if (now - lastDelivery < THROTTLE_MS) return + lastDelivery = now + } + if (!windowIsAway()) { + if (isOnScreen(candidate.sessionId, live.list)) return + toasts.showToast(candidate) + recordDelivery('popup', candidate) + return + } + const result = system.notifySystem(candidate, candidate.test === true) + if (result.outcome === 'raised') { + recordDelivery('system', candidate, result) + if (candidate.test !== true) replay.holdForReplay(candidate, result) + return + } + // A test alert still has to reach the user, and so does a real one when + // the system channel refuses: the popup is the channel that remains. + toasts.showToast(candidate) + recordDelivery(`system-refused-${result.outcome}`, candidate) + } + + /** + * Take the delivery decision on a later tick, from the freshest state. + * + * One candidate per tick keeps the throttle meaningful, and anything still + * queued arms its own follow-up tick — a burst used to leave every candidate + * after the first stranded in the queue with no timer to flush it. + */ + const flushSettle = () => { + settleTimer = 0 + const candidate = pending.shift() + if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false) + if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) + } + + /** Hold one candidate for the settle tick, collapsing a burst onto one timer. */ + const queue = (candidate) => { + pending.push(candidate) + if (settleTimer !== 0) return + settleTimer = setTimeout(() => flushSettle(), SETTLE_MS) + } + + /** + * Clear the flood guard. A window that just lost or regained the foreground is + * exactly when the next alert matters most, so it must never be swallowed by a + * delivery from the other side of that transition. + */ + const resetThrottle = () => { + lastDelivery = 0 + } + + /** Stop the pending settle tick: the plugin is being disposed. */ + const dispose = () => { + if (settleTimer !== 0) { + clearTimeout(settleTimer) + settleTimer = 0 + } + } + + return { deliver, recordDelivery, queue, flushSettle, resetThrottle, dispose } +} diff --git a/src/client/core/log.js b/src/client/core/log.js new file mode 100644 index 0000000..b18ec8c --- /dev/null +++ b/src/client/core/log.js @@ -0,0 +1,19 @@ +/** + * Diagnostics: one way to render an unknown failure, one way to log it. + * + * @module @dsh-plugin/session-notify/client/core/log + */ + +/** Render one unknown failure as text. */ +export function text(error) { + return error instanceof Error ? error.message : String(error) +} + +/** Log one contained diagnostic through the package-tagged console. */ +export function report(message) { + try { + console.error(`session-notify: ${message}`) + } catch { + /* a diagnostic never fails the plugin */ + } +} diff --git a/src/client/core/observe.js b/src/client/core/observe.js new file mode 100644 index 0000000..73117fc --- /dev/null +++ b/src/client/core/observe.js @@ -0,0 +1,113 @@ +/** + * Observation: one derivation pass over the client's own session state. + * + * @module @dsh-plugin/session-notify/client/core/observe + */ + +import { questionText, servedPendingKind, titleOf } from './session.js' +import { report } from './log.js' + +/** + * Build the observer. + * @param deps - the plugin store; the shared `live` cell the delivery path reads + * back at delivery time; and `queue`, which holds a derived alert for the + * settle tick. + * @returns the derivation pass, and the health publisher the slot entry reports + * its own state through. + */ +export function createObservation({ store, live, queue }) { + const runs = new Map() + const completionNotice = new Set() + const pendingNotice = new Map() + + /** + * Publish the resident observer's own health into the plugin store. + * + * A slot entry that silently receives nothing is the one failure this plugin + * cannot detect from the outside, so the observer records whether it is + * actually seeing session state and the settings row shows it. Repeated + * identical states publish nothing, and a state this is not `watching` also + * reaches the tagged console. + * @param next - the health state observed on this render. + */ + const reportHealth = (next) => { + const snapshot = store.getSnapshot() + const current = snapshot.health + if (current.state === next.state && current.message === next.message) return + if (current.state !== 'watching') report(`observer health: ${next.state} ${next.message}`) + store.set({ ...snapshot, health: next }) + } + + /** + * One derivation pass over the client's own session state: remember the + * freshest snapshot for the settle tick, compare it against what this page + * already observed, emit at most one candidate per transition, and let the + * settle tick decide delivery. The window state is deliberately NOT captured + * here — the delivery reads it fresh, because it can change inside the + * settle window. + * @param list - the client's session-list snapshot. + * @param status - the client's per-session status selector. + */ + const observe = (list, status) => { + if (list === undefined || status === undefined) return + live.list = list + live.status = status + const kinds = store.getSnapshot().kinds + for (const sessionId of Object.keys(list.byId ?? {})) { + const summary = list.byId[sessionId] + if (summary === undefined || summary.origin === 'subagent') continue + const sessionStatus = status.get(sessionId) + const running = sessionStatus?.running ?? summary.running + + if (running === true) { + runs.set(sessionId, true) + completionNotice.delete(sessionId) + } else { + if (runs.get(sessionId) === true) { + runs.set(sessionId, false) + if (summary.blank !== true && !completionNotice.has(sessionId) && kinds.completion) { + completionNotice.add(sessionId) + queue({ + kind: 'completion', + sessionId, + title: titleOf(summary, sessionId), + detail: '', + pendingKind: '', + }) + } + } else if (!runs.has(sessionId)) { + runs.set(sessionId, false) + } + } + + const interaction = sessionStatus?.pendingInteraction + const pendingKind = servedPendingKind(interaction) + if (pendingKind === undefined) { + pendingNotice.delete(sessionId) + continue + } + if (pendingNotice.get(sessionId) === pendingKind) continue + pendingNotice.set(sessionId, pendingKind) + const trigger = pendingKind === 'approval' ? 'approval' : 'question' + if (!kinds[trigger]) continue + queue({ + kind: trigger, + sessionId, + title: titleOf(summary, sessionId), + detail: pendingKind === 'approval' + ? String(interaction?.toolName ?? '') + : questionText(interaction), + pendingKind, + }) + } + + for (const sessionId of [...runs.keys()]) { + if (list.byId?.[sessionId] !== undefined) continue + runs.delete(sessionId) + completionNotice.delete(sessionId) + pendingNotice.delete(sessionId) + } + } + + return { observe, reportHealth } +} diff --git a/src/client/core/replay.js b/src/client/core/replay.js new file mode 100644 index 0000000..08aef21 --- /dev/null +++ b/src/client/core/replay.js @@ -0,0 +1,85 @@ +/** + * Replaying, in-app, the alerts the system channel carried while nobody was + * looking at the machine. + * + * @module @dsh-plugin/session-notify/client/core/replay + */ + +import { REPLAY_LIMIT, REPLAY_QUIET_MS, REPLAY_TTL_MS, TOAST_LIMIT } from '../constants.js' + +/** + * Build the replay queue. + * @param deps - the plugin store; `publish` so the settings row can count what + * is waiting; `showToast` for the in-app surface; and `recordDelivery`, which + * belongs to the delivery module and is reached through the composition root's + * late-bound runtime (this queue is built before that module exists). + * @returns the queue's operations plus the current depth. + */ +export function createReplay({ store, publish, showToast, recordDelivery }) { + let entries = [] + + /** Forget one queued replay: the user has just answered that alert in the system channel. */ + const dropReplay = (candidate) => { + const next = entries.filter((entry) => ( + entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind + )) + if (next.length === entries.length) return + entries = next + publish() + } + + /** + * Keep one away-channel alert for an in-app replay. + * + * A desktop notification raised while nobody is looking at the machine is + * easy to miss: its banner lives a few seconds, its history lives in the + * system's own notification centre, which the user may never open, and a + * refused display leaves no trace at all on the page. The alert is therefore + * replayed as a light in-app popup as soon as the window is back in the + * foreground, except when the platform confirmed the banner and the user was + * back within {@link REPLAY_QUIET_MS} — then the banner is still on screen and + * a second surface would only be noise. Entries older than + * {@link REPLAY_TTL_MS} are dropped instead of waiting for a user who has + * moved on. + * @param candidate - the alert to keep for a replay. + * @param monitor - the system channel's own record, so its confirmation can be read later. + */ + const holdForReplay = (candidate, monitor) => { + const now = Date.now() + entries = [ + ...entries.filter((entry) => ( + entry.candidate.sessionId !== candidate.sessionId || entry.candidate.kind !== candidate.kind + )), + { candidate, at: now, monitor }, + ].slice(-REPLAY_LIMIT) + publish() + } + + /** Show every away-channel alert the user has not had a chance to see yet. */ + const flushReplay = () => { + if (entries.length === 0) return + const now = Date.now() + const waiting = entries + entries = [] + const due = waiting.filter((entry) => { + if (now - entry.at > REPLAY_TTL_MS) return false + // A banner that the platform confirmed and that is still inside its own + // visible window has already told the user; do not say it twice. + return !(entry.monitor?.shown === true && now - entry.at <= REPLAY_QUIET_MS) + }) + const shown = due.slice(-TOAST_LIMIT) + for (const entry of shown) showToast(entry.candidate) + if (shown.length > 0) recordDelivery('replay', shown[shown.length - 1].candidate) + else publish() + } + + /** How many alerts are still waiting for the user to come back. */ + const size = () => entries.length + + /** Forget everything: the plugin is being disposed. */ + const dispose = () => { + entries = [] + } + + return { holdForReplay, dropReplay, flushReplay, size, dispose } +} diff --git a/src/client/core/session.js b/src/client/core/session.js new file mode 100644 index 0000000..15533a7 --- /dev/null +++ b/src/client/core/session.js @@ -0,0 +1,75 @@ +/** + * Reading the client's own session state: the pure derivations the observer + * and the delivery path both ask questions of. + * + * @module @dsh-plugin/session-notify/client/core/session + */ + +import { PENDING_KINDS } from '../constants.js' + +/** + * Discriminate a pending interaction the client published. Only the three + * domains the Harness itself renders are served; anything else is ignored + * rather than guessed at. + * @param value - `status.pendingInteraction`. + * @returns a served kind, or undefined. + */ +export function servedPendingKind(value) { + if (value === undefined || value === null || typeof value !== 'object') return undefined + const kind = value.kind + return typeof kind === 'string' && PENDING_KINDS.includes(kind) ? kind : undefined +} + +/** Trim free text down to one popup line. */ +export function preview(value) { + if (typeof value !== 'string') return '' + const flat = value.replace(/\s+/g, ' ').trim() + return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat +} + +/** The first question text a pending interaction exposes, across its shapes. */ +export function questionText(interaction) { + if (interaction === null || typeof interaction !== 'object') return '' + const first = Array.isArray(interaction.questions) ? interaction.questions[0] : undefined + if (typeof first?.question === 'string') return preview(first.question) + if (typeof first?.text === 'string') return preview(first.text) + if (typeof interaction.question === 'string') return preview(interaction.question) + if (typeof interaction.prompt === 'string') return preview(interaction.prompt) + return preview(interaction.displayReason?.text ?? interaction.reason?.text ?? '') +} + +/** Copy one session title, falling back to its identity. */ +export function titleOf(summary, sessionId) { + const title = typeof summary?.title === 'string' ? summary.title.trim() : '' + return title === '' ? sessionId.slice(0, 8) : title +} + +/** Whether the user is looking at this exact conversation right now. */ +export function isOnScreen(sessionId, list) { + if (list === undefined) return false + for (const id of Object.keys(list.byId ?? {})) { + const summary = list.byId[id] + if (summary !== undefined && (summary.retainedBy?.mainView ?? 0) > 0) return id === sessionId + } + return false +} + +/** + * Whether a queued candidate is still worth delivering. Read from the LIVE + * snapshot rather than the one captured when the transition was seen: a + * conversation that resumed in the settle window owes no alert, and the + * snapshot the transition was derived from still carries the pre-transition + * `running` flag of the session record. + * @param candidate - the alert waiting for its settle tick. + * @param live - the freshest `{ list, status }` the observer has seen. + * @returns whether the alert is still owed. + */ +export function stillWorth(candidate, live) { + const list = live.list + if (candidate.test === true || list === undefined) return true + const summary = list.byId?.[candidate.sessionId] + if (summary === undefined) return true + const status = live.status + const running = status?.get?.(candidate.sessionId)?.running ?? summary.running + return running !== true +} diff --git a/src/client/core/storage.js b/src/client/core/storage.js new file mode 100644 index 0000000..2f4e6f2 --- /dev/null +++ b/src/client/core/storage.js @@ -0,0 +1,43 @@ +/** + * This package's browser-local preferences. + * + * @module @dsh-plugin/session-notify/client/core/storage + */ + +import { KINDS } from '../constants.js' + +/** Storage key for this package's own preferences. */ +export const STORAGE_KEY = 'dsh-plugin/session-notify' + +/** + * Read this package's stored trigger switches. + * + * These are browser preferences rather than cordis configuration, so they live + * in this origin's own storage instead of the profile's patch file: the plugin + * writes nothing into DSH's configuration, and every read is defensive because + * storage can be unavailable or hold something an older version wrote. + * @returns the stored switches, defaulting to everything on. + */ +export function readStoredKinds() { + const kinds = { completion: true, approval: true, question: true } + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY) + if (typeof raw !== 'string' || raw === '') return kinds + const stored = JSON.parse(raw) + for (const kind of KINDS) { + if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind] + } + } catch { + /* unreadable or foreign storage keeps the defaults */ + } + return kinds +} + +/** Store one trigger switch, ignoring storage that refuses to write. */ +export function writeStoredKinds(kinds) { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds)) + } catch { + /* a refused write leaves the switches live for this page only */ + } +} diff --git a/src/client/core/store.js b/src/client/core/store.js new file mode 100644 index 0000000..d989780 --- /dev/null +++ b/src/client/core/store.js @@ -0,0 +1,25 @@ +/** + * The plugin's own observable state. + * + * @module @dsh-plugin/session-notify/client/core/store + */ + +/** Minimal observable store: the shape React reads with useSyncExternalStore. */ +export function createStore(initial) { + let value = initial + const listeners = new Set() + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + set: (next) => { + if (next === value) return + value = next + for (const listener of [...listeners]) listener() + }, + } +} diff --git a/src/client/core/system-channel.js b/src/client/core/system-channel.js new file mode 100644 index 0000000..6564f42 --- /dev/null +++ b/src/client/core/system-channel.js @@ -0,0 +1,112 @@ +/** + * The system-notification channel: raising one, and reporting what the platform + * actually did with it. + * + * @module @dsh-plugin/session-notify/client/core/system-channel + */ + +import { RAISED_LIMIT } from '../constants.js' +import { focusWindow, notificationPermission, systemIcon } from '../platform.js' +import { text } from './log.js' + +/** + * Build the system channel over one store. + * @param deps - the plugin store; the copy table; `notify` for contained + * diagnostics; `openSession` for a click that must land on the conversation; + * and `dropReplay`, so clicking the banner cancels its in-app replay. + * @returns the channel's operations. + */ +export function createSystemChannel({ store, copy, notify, openSession, dropReplay }) { + /** System notifications this page still holds open, so nothing collects them mid-display. */ + const raised = new Set() + + /** + * The system-notification channel's own answer about one notification. + * + * A browser reports the platform's verdict asynchronously: `show` means the + * notification reached the desktop, `error` means the platform refused it, and + * silence means neither answer arrived. That third case is the one that used to + * lose alerts silently, so it is recorded rather than assumed to be a success. + * @param monitor - the record returned by {@link notifySystem} for this notification. + * @param shown - `true` when the platform displayed it, `false` when it refused. + */ + const confirmDelivery = (monitor, shown) => { + if (monitor === null || monitor === undefined || typeof monitor !== 'object') return + monitor.shown = shown + const snapshot = store.getSnapshot() + const last = snapshot.lastDelivery + if (last === null || last === undefined || last.monitor !== monitor || last.shown === shown) return + store.set({ ...snapshot, lastDelivery: { ...last, shown } }) + } + + /** Hold one raised notification open, bounded, so it cannot be collected while pending. */ + const keepRaised = (notification) => { + raised.add(notification) + while (raised.size > RAISED_LIMIT) { + const oldest = raised.values().next().value + if (oldest === undefined) break + raised.delete(oldest) + } + } + + /** + * Raise one system notification, tolerating every refusal a browser may give, + * and report what the platform said about it. + * @param candidate - the alert to raise. + * @param force - raise it even when the permission is not granted (the settings + * page's own test button, which exists to prove the channel on its own). + * @returns a record whose `outcome` is the channel's verdict (`raised`, + * `permission`, `unsupported`, or `threw`) and whose `shown` is filled in + * later by {@link confirmDelivery} — `true` displayed, `false` refused, + * `undefined` while the platform has said nothing. + */ + const notifySystem = (candidate, force) => { + const Ctor = globalThis.Notification + const permission = notificationPermission() + if (permission === 'unsupported') return { outcome: 'unsupported' } + if (!force && permission !== 'granted') return { outcome: 'permission' } + try { + const notice = copy.copyFor(candidate) + const options = { + body: notice.body, + tag: `dsh-session-${candidate.sessionId}`, + requireInteraction: false, + } + if (systemIcon !== undefined) options.icon = systemIcon + const notification = new Ctor(notice.title, options) + const record = { outcome: 'raised', notice, shown: undefined } + keepRaised(notification) + notification.onshow = () => { + confirmDelivery(record, true) + } + notification.onerror = (event) => { + confirmDelivery(record, false) + notify(`system notification was refused: ${String(event?.message ?? 'unknown')}`) + } + notification.onclose = () => { + raised.delete(notification) + } + notification.onclick = () => { + dropReplay(candidate) + focusWindow() + openSession(candidate.sessionId) + try { + notification.close() + } catch { + /* close is best effort on every platform */ + } + } + return record + } catch (error) { + notify(`system notification failed: ${text(error)}`) + return { outcome: 'threw' } + } + } + + /** Drop the held references: the plugin is being disposed. */ + const dispose = () => { + raised.clear() + } + + return { notifySystem, confirmDelivery, dispose } +} diff --git a/src/client/core/toasts.js b/src/client/core/toasts.js new file mode 100644 index 0000000..97e3230 --- /dev/null +++ b/src/client/core/toasts.js @@ -0,0 +1,80 @@ +/** + * The light in-app popup stack: its lifetime, its ordering, and its bound. + * + * @module @dsh-plugin/session-notify/client/core/toasts + */ + +import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js' + +/** + * Build the popup stack over one store. + * @param deps - the plugin store, and the copy table a candidate is rendered with. + * @returns the stack's operations, including the three the popup element calls back into. + */ +export function createToastStack({ store, copy }) { + const timers = new Map() + let seq = 0 + + /** Drop one popup and cancel its lifetime timer. */ + const closeToast = (id) => { + const timer = timers.get(id) + if (timer !== undefined) { + clearTimeout(timer) + timers.delete(id) + } + const snapshot = store.getSnapshot() + if (!snapshot.toasts.some((toast) => toast.id === id)) return + store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }) + } + + /** Arm one popup's lifetime; hovering calls `hold` first, then this again. */ + const armToast = (id) => { + const timer = timers.get(id) + if (timer !== undefined) clearTimeout(timer) + timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS)) + } + + /** Pause one popup's lifetime while the pointer rests on it. */ + const holdToast = (id) => { + const timer = timers.get(id) + if (timer === undefined) return + clearTimeout(timer) + timers.delete(id) + } + + /** + * Show one light in-app popup: the trigger as the title, the conversation + * and its detail as the description, so the heading says what happened and + * the line under it says where. + */ + const showToast = (candidate) => { + const notice = copy.copyFor(candidate) + seq += 1 + const toast = { + id: `dsn-${String(seq)}`, + kind: notice.kind, + title: notice.title, + body: notice.body, + sessionId: candidate.sessionId, + } + const snapshot = store.getSnapshot() + const next = [toast, ...snapshot.toasts] + for (const dropped of next.slice(TOAST_LIMIT)) { + const timer = timers.get(dropped.id) + if (timer !== undefined) { + clearTimeout(timer) + timers.delete(dropped.id) + } + } + store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }) + armToast(toast.id) + } + + /** Cancel every lifetime timer: the plugin is being disposed, so nothing may fire later. */ + const dispose = () => { + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + } + + return { showToast, closeToast, armToast, holdToast, dispose } +} diff --git a/src/client/i18n/en.js b/src/client/i18n/en.js new file mode 100644 index 0000000..b8b58c3 --- /dev/null +++ b/src/client/i18n/en.js @@ -0,0 +1,67 @@ +/** + * English dictionary of this package's own copies. + * + * The key set must stay identical to `zh.js`: `createTranslator` falls back to + * whichever dictionary has the key, so an asymmetric key would silently change + * language rather than fail loudly. + * + * @module @dsh-plugin/session-notify/client/i18n/en + */ + +export const en = { + 'notification.completion': 'Conversation finished', + 'notification.approval': 'Approval required', + 'notification.question': 'Answer needed', + 'body.completion': '{title} finished this round', + 'body.approval': '{title}: {tool} is waiting for your approval', + 'body.approvalPlain': '{title}: a tool is waiting for your approval', + 'body.question': '{title}: waiting for your answer', + 'body.planReview': '{title}: a plan is waiting for your review', + 'body.untitled': 'Untitled conversation', + 'toast.view': 'View', + 'toast.dismiss': 'Dismiss', + 'settings.title': 'Session notifications', + 'settings.description': 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.', + 'settings.permission.granted': 'System notifications are on', + 'settings.permission.default': 'System notification permission has not been granted', + 'settings.permission.denied': 'System notifications are switched off in system settings', + 'settings.permission.unsupported': 'This environment cannot show system notifications; the in-app popup still works', + 'settings.permission.hint.plain': 'Allow DeepSeek Harness in your system notification settings', + 'settings.permission.hint.win': 'Windows Settings → System → Notifications → DeepSeek Harness', + 'settings.permission.hint.mac': 'System Settings → Notifications → DeepSeek Harness', + 'settings.permission.hint.linux': 'Allow DeepSeek Harness in your desktop notification settings (GNOME / KDE)', + 'settings.action.allow': 'Allow notifications', + 'settings.action.testSystem': 'Test system notification', + 'settings.action.testAny': 'Test alert', + 'settings.action.unsupported': 'Unavailable', + 'settings.permission.promptFailed': 'Could not request notification permission: {message}', + 'settings.health.watching': 'Watching session status', + 'settings.health.noHooks': 'No session status received (notifications will not fire)', + 'settings.health.error': 'Watching session status failed: {message}', + 'settings.health.starting': 'Waiting for the session list', + 'settings.kinds.label': 'Notify about', + 'settings.kind.completion': 'Finished', + 'settings.kind.approval': 'Approval', + 'settings.kind.question': 'Questions', + 'config.section.kinds': 'What to notify about', + 'config.section.permission': 'System notification permission', + 'config.section.status': 'Status', + 'config.intro': 'Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.', + 'config.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.', + 'config.testHint': 'The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).', + 'config.testAny': 'Test alert', + 'config.testSystem': 'Test system notification', + 'config.diag.window': 'Window', + 'config.diag.inFront': 'in front (popup is used)', + 'config.diag.away': 'not in front (system notification is used)', + 'config.diag.last': 'Last delivery', + 'config.diag.none': 'nothing delivered yet', + 'config.diag.systemShown': 'system notification (confirmed on screen)', + 'config.diag.systemUnconfirmed': 'system notification (no confirmation, replayed in-app on return)', + 'config.diag.systemFailed': 'system notification never appeared, replayed in-app on return', + 'config.diag.replayDelivery': 'in-app popup (replayed when you came back)', + 'config.diag.popup': 'in-app popup', + 'config.diag.refused': 'system channel refused, popup was used instead', + 'config.diag.replay': 'waiting for you to come back: {count}', + 'config.diag.replayNone': 'nothing waiting to be replayed', +} diff --git a/src/client/i18n/index.js b/src/client/i18n/index.js new file mode 100644 index 0000000..537e791 --- /dev/null +++ b/src/client/i18n/index.js @@ -0,0 +1,73 @@ +/** + * This package's translation seat: the dictionaries it registers with the + * framework's locale service, and the translator every copy path goes through. + * + * @module @dsh-plugin/session-notify/client/i18n + */ + +import { NS } from '../constants.js' +import { zh } from './zh.js' +import { en } from './en.js' + +/** Every dictionary this package ships, keyed by the locale service's own id. */ +export const dictionaries = { zh, en } + +/** + * Register this package's dictionaries with the locale service. + * + * Without this the service has no entry for the namespace and every + * `locale.bind(NS)` lookup answers with the key itself, so the settings row + * shows `settings.title` instead of its copy. The typed form takes every + * shipped locale in one call, and the older single-locale form stays + * supported as a fallback. Registration bumps the locale revision, so + * already-rendered entries pick the copy up without a reload. + * @param locale - the locale service, or undefined when this profile has none. + * @returns a disposer for whichever registrations were installed. + */ +export function registerDictionaries(locale) { + if (locale === undefined || typeof locale.register !== 'function') return () => {} + try { + return locale.register(NS, dictionaries) + } catch { + const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(NS, id, dict)) + return () => { + for (const dispose of disposers) dispose?.() + } + } +} + +/** + * Build a translate function for one locale service: the service's own bound + * namespace first, then this package's dictionaries, then the key itself, so + * a missing seat degrades copy instead of rendering. + * @param locale - the locale service, or undefined when this profile has none. + * @returns a translate function with `{ name }` interpolation. + */ +export function createTranslator(locale) { + return (key, params) => { + if (locale !== undefined && typeof locale.bind === 'function') { + try { + const bound = locale.bind(NS) + if (typeof bound === 'function') { + const translated = bound(key, params) + if (typeof translated === 'string' && translated !== key) return translated + } + } catch { + /* a locale service without this namespace still renders the fallback */ + } + } + let language = '' + try { + const snapshot = locale?.getSnapshot?.() + language = String(snapshot?.active ?? '') + } catch { + language = '' + } + const dictionary = /^zh/i.test(language) ? zh : en + const template = dictionary[key] ?? zh[key] ?? en[key] ?? key + if (params === undefined) return template + return template.replace(/\{(\w+)\}/g, (match, name) => ( + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match + )) + } +} diff --git a/src/client/i18n/zh.js b/src/client/i18n/zh.js new file mode 100644 index 0000000..f28690a --- /dev/null +++ b/src/client/i18n/zh.js @@ -0,0 +1,67 @@ +/** + * Chinese dictionary of this package's own copies. + * + * Every key here is read through `createTranslator`, and the key set must match + * `en.js` exactly — a dead or half-translated key is a defect, not a fallback + * (v1.0.4 removed two of them). The locale service gets the same two objects. + * + * @module @dsh-plugin/session-notify/client/i18n/zh + */ + +export const zh = { + 'notification.completion': '会话已完成', + 'notification.approval': '需要授权', + 'notification.question': '需要回答', + 'body.completion': '{title} 已完成这一轮回答', + 'body.approval': '{title}:工具 {tool} 正在等待你的授权', + 'body.approvalPlain': '{title}:有工具正在等待你的授权', + 'body.question': '{title}:正在等待你的回答', + 'body.planReview': '{title}:计划正在等待你确认', + 'body.untitled': '未命名会话', + 'toast.view': '查看', + 'toast.dismiss': '关闭', + 'settings.title': '会话通知', + 'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。', + 'settings.permission.granted': '系统通知已开启', + 'settings.permission.default': '尚未授予系统通知权限', + 'settings.permission.denied': '系统通知已被系统设置关闭', + 'settings.permission.unsupported': '当前环境不支持系统通知,仍会显示应用内轻弹窗', + 'settings.permission.hint.plain': '请在系统通知设置中允许 DeepSeek Harness', + 'settings.permission.hint.win': 'Windows 设置 → 系统 → 通知 → DeepSeek Harness', + 'settings.permission.hint.mac': '系统设置 → 通知 → DeepSeek Harness', + 'settings.permission.hint.linux': '在桌面环境的通知设置(GNOME / KDE)中允许 DeepSeek Harness', + 'settings.action.allow': '允许通知', + 'settings.action.testSystem': '测试系统通知', + 'settings.action.testAny': '测试提醒', + 'settings.action.unsupported': '不可用', + 'settings.permission.promptFailed': '无法请求通知权限:{message}', + 'settings.health.watching': '正在监听会话状态', + 'settings.health.noHooks': '未收到会话状态(通知不会触发)', + 'settings.health.error': '监听会话状态出错:{message}', + 'settings.health.starting': '正在等待会话列表', + 'settings.kinds.label': '提醒内容', + 'settings.kind.completion': '完成', + 'settings.kind.approval': '授权', + 'settings.kind.question': '提问', + 'config.section.kinds': '提醒内容', + 'config.section.permission': '系统通知权限', + 'config.section.status': '运行状态', + 'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。', + 'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。', + 'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。', + 'config.testAny': '测试提醒', + 'config.testSystem': '测试系统通知', + 'config.diag.window': '窗口状态', + 'config.diag.inFront': '在前台(会走轻弹窗)', + 'config.diag.away': '不在前台(会走系统通知)', + 'config.diag.last': '最近一次投递', + 'config.diag.none': '还没有投递过', + 'config.diag.systemShown': '系统通知(系统已确认弹出)', + 'config.diag.systemUnconfirmed': '系统通知(系统没有回报,回到窗口时补发轻弹窗)', + 'config.diag.systemFailed': '系统通知没弹出来,回到窗口时补发轻弹窗', + 'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)', + 'config.diag.popup': '应用内轻弹窗', + 'config.diag.refused': '系统通知被拒绝,改用了轻弹窗', + 'config.diag.replay': '等你回到窗口时补发:{count} 条', + 'config.diag.replayNone': '没有待补发的提醒', +} diff --git a/src/client/index.js b/src/client/index.js new file mode 100644 index 0000000..b94fd90 --- /dev/null +++ b/src/client/index.js @@ -0,0 +1,25 @@ +/** + * Build entry of the browser half: the one file the bundle registers with. + * + * `scripts/build-client.mjs` wraps the built body of this module in the + * registration this package is served through: + * + * ```js + * window.__ModuleLoader__.load({ + * id: '@dsh-plugin/session-notify', + * factory(require) { + * // …the whole bundle body, so `require('react')` runs at materialization… + * return module.exports + * }, + * }) + * ``` + * + * The client module system resolves `require` against its own module table — + * platform seeds such as `react`, and other plugins' rows — and never against + * relative files, which is why this package's source is bundled into a single + * self-contained `client.js` instead of being served module by module. + * + * @module @dsh-plugin/session-notify/client + */ + +export { inject, apply } from './plugin.js' diff --git a/src/client/platform.js b/src/client/platform.js new file mode 100644 index 0000000..1d591cf --- /dev/null +++ b/src/client/platform.js @@ -0,0 +1,105 @@ +/** + * Every browser and platform fact this plugin reads, in one seam. + * + * Nothing here owns state or decides anything: the window's visibility, the + * notification permission, the notification icon, and the one platform hint the + * settings copy uses. Each read is contained, because a page a plugin does not + * control may hide any of them — and because a contained read is what lets the + * delivery path keep going when the answer is unreadable. + * + * @module @dsh-plugin/session-notify/client/platform + */ + +/** + * The window's Notification constructor, or undefined when this environment has + * none (a plain Node test, or a browser without the API). + * @returns the constructor, or undefined. + */ +export function notificationApi() { + const Ctor = globalThis.Notification + return typeof Ctor === 'function' ? Ctor : undefined +} + +/** Permission lookup that never throws in a browser without the API. */ +export function notificationPermission() { + const Ctor = notificationApi() + if (Ctor === undefined) return 'unsupported' + const permission = Ctor.permission + if (permission === 'granted' || permission === 'denied' || permission === 'default') return permission + return 'default' +} + +/** + * Whether the Harness window is somewhere the user cannot see the app. + * + * Two independent browser facts are consulted, and ANY of them counts as "not + * in the foreground": the page's visibility and the document's focus. They are + * read at delivery time rather than remembered from an event, because a missed + * blur in the desktop shell made this plugin believe the window was in front + * and swallow the alert. + * @returns whether the alert must go to the system notification channel. + */ +export function windowIsAway() { + try { + if (document.visibilityState === 'hidden') return true + } catch { + /* an unreadable visibility state leaves the focus fact */ + } + try { + if (typeof document.hasFocus === 'function') return !document.hasFocus() + } catch { + /* an unreadable focus state leaves the default below */ + } + return false +} + +/** Read the window's live focus state rather than a cached value. */ +export function currentFocus() { + return !windowIsAway() +} + +/** Focus the window without letting a refusal stop the navigation. */ +export function focusWindow() { + try { + globalThis.focus?.() + } catch { + /* focus is best effort on every platform */ + } +} + +/** + * The system-notification icon, built once when this bundle materializes: this + * deployment's own static URL when the page serves this package's assets, else + * the SVG inline. Icons are cosmetic on every platform, so each step is + * contained — and materialization happens on first import, not when the bundle + * script runs, so a page that never activates the plugin never gets here. + * @type {string | undefined} an icon URL, or undefined when neither form works. + */ +export const systemIcon = (() => { + try { + const url = new URL('./icon.svg', document.baseURI).href + if (url !== '') return url + } catch { + /* fall through to the inline form */ + } + try { + const svg = '' + return `data:image/svg+xml,${svg}` + } catch { + return undefined + } +})() + +/** One best-effort platform hint, used for settings help text only. */ +export function platformKey() { + try { + const agent = navigator.userAgentData + const platform = String((agent?.platform ?? navigator.platform) || navigator.userAgent || '') + if (/win/i.test(platform)) return 'win' + if (/mac|iphone|ipad/i.test(platform)) return 'mac' + if (/linux|x11/i.test(platform)) return 'linux' + } catch { + /* an unreadable navigator keeps the neutral hint */ + } + return 'plain' +} diff --git a/src/client/plugin.js b/src/client/plugin.js new file mode 100644 index 0000000..f7b4d6d --- /dev/null +++ b/src/client/plugin.js @@ -0,0 +1,216 @@ +/** + * Client half of the session-notify bundle: the composition root. + * + * One job: tell the user when a conversation needs attention. Three triggers — + * a conversation that stopped running, a pending tool approval, and a pending + * question or plan review — are derived from the client's own session state, and + * each one is delivered exactly once, through exactly one channel: + * + * - the Harness window not in the foreground → a system notification, raised + * through the page's Web `Notification` constructor. That is the only + * system-notification channel a plugin can reach: `@deepseek-ai/dsh-desktop-host` + * runs the Host half in a plain Node process rather than in Electron's main + * process, so the Host side has no `Notification` to call. The same Web API + * backs Windows, macOS, and Linux inside the Desktop shell, so delivery has no + * platform branch at all — only the settings row's *help text* names the + * platform's own notification settings. + * - the window in the foreground, and the finished conversation not the one on + * screen → a light in-app popup, because a desktop notification while you are + * looking at the app is noise. + * + * This module owns no behavior of its own: it creates the plugin's own store and + * the shared state every part reads, assembles the modules under `core/`, and + * registers the three slot entries. The rules live with the code that applies + * them — `core/observe.js` decides when an alert is owed, `core/delivery.js` + * picks its channel, `core/replay.js` and `core/system-channel.js` carry it. + * + * State comes exclusively from the slot props' standard selector hooks + * (`useSessions`, `useSessionStatus`) — the documented way for a plugin to read + * session data. Nothing here polls, folds session events, or reaches into + * another plugin. + * + * @module @dsh-plugin/session-notify/client/plugin + */ + +import { NS, PACKAGE_NAME } from './constants.js' +import { createTranslator, registerDictionaries } from './i18n/index.js' +import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js' +import { report, text } from './core/log.js' +import { createStore } from './core/store.js' +import { readStoredKinds } from './core/storage.js' +import { createCopy } from './core/copy.js' +import { createToastStack } from './core/toasts.js' +import { createReplay } from './core/replay.js' +import { createSystemChannel } from './core/system-channel.js' +import { createDelivery } from './core/delivery.js' +import { createObservation } from './core/observe.js' +import { createActions } from './core/actions.js' +import { ToastLayer } from './ui/ToastLayer.js' +import { ConfigSection } from './ui/ConfigSection.js' +import { NotifyObserver } from './ui/NotifyObserver.js' + +/** The services this plugin needs; without them Cordis defers activation. */ +export const inject = ['slots', 'locale'] + +/** + * Install the plugin. + * @param ctx - Client root context providing `slots` and `locale`. + */ +export function apply(ctx) { + const locale = ctx.get('locale') + const t = createTranslator(locale) + + const store = createStore({ + permission: notificationPermission(), + kinds: readStoredKinds(), + toasts: [], + promptError: '', + health: { state: 'starting', message: '' }, + lastDelivery: null, + }) + + /** Freshest session state the observer has seen; the delivery path reads it at delivery time. */ + const live = { list: undefined, status: undefined } + /** + * Late-bound seams. Two module pairs genuinely call back into each other — the + * replay queue records its own delivery through the delivery path, which is + * built afterwards — so those edges are resolved here instead of by importing + * one module from the other, which would make the import graph cyclic. + */ + const runtime = {} + let disposed = false + + /** Republish the store, so the settings page follows replay and delivery book changes. */ + const publish = () => { + if (!disposed) store.set({ ...store.getSnapshot() }) + } + + /** Bring one conversation to the front, when the client exposes that operation. */ + const openSession = (sessionId) => { + try { + ctx.get('uiWorkspace')?.openSession?.(sessionId) + } catch (error) { + report(`could not open "${sessionId}": ${text(error)}`) + } + } + + const copy = createCopy(t) + const toasts = createToastStack({ store, copy }) + const replay = createReplay({ + store, + publish, + showToast: toasts.showToast, + recordDelivery: (outcome, candidate, monitor) => runtime.delivery?.recordDelivery(outcome, candidate, monitor), + }) + const system = createSystemChannel({ + store, + copy, + notify: report, + openSession, + dropReplay: replay.dropReplay, + }) + const delivery = createDelivery({ store, copy, live, toasts, system, replay }) + runtime.delivery = delivery + const observation = createObservation({ store, live, queue: delivery.queue }) + const actions = createActions({ store, t, delivery, system, toasts, publish }) + + /** Keep the page's own focus events owned by this plugin's effect. */ + const installBridges = () => { + const onBlur = () => { + delivery.resetThrottle() + } + // Coming back is the moment the user can finally be told about whatever the + // system channel carried while the window was away, and it also clears the + // throttle so the first alert after returning is never swallowed. + const onReturn = () => { + delivery.resetThrottle() + replay.flushReplay() + } + const onVisibilityChange = () => { + if (!windowIsAway()) onReturn() + } + try { + window.addEventListener('focus', onReturn) + window.addEventListener('blur', onBlur) + document.addEventListener('visibilitychange', onVisibilityChange) + } catch { + /* an unreadable window still delivers through live focus reads */ + } + return () => { + try { + window.removeEventListener('focus', onReturn) + window.removeEventListener('blur', onBlur) + document.removeEventListener('visibilitychange', onVisibilityChange) + } catch { + /* nothing to detach */ + } + delivery.dispose() + toasts.dispose() + system.dispose() + replay.dispose() + } + } + + ctx.effect(() => registerDictionaries(locale), 'session-notify: dictionaries') + ctx.effect(() => installBridges(), 'session-notify: window focus') + ctx.effect(() => () => { + disposed = true + }, 'session-notify: disposal') + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: `${NS}-toasts`, + order: 100, + locale: NS, + inject: () => ({ + store, + fallbackT: t, + dismiss: toasts.closeToast, + arm: toasts.armToast, + hold: toasts.holdToast, + open: (sessionId) => { + focusWindow() + openSession(sessionId) + }, + }), + }, ToastLayer)) + + // This plugin's configuration lives on its own page in the plugin manager, + // keyed by this package's name — the seat the shipped experimental bundle + // uses for its own settings, and the one the plugin page renders between the + // bundle's description and its rows. It is deliberately NOT a General + // settings row: a plugin's options belong to the plugin. + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: PACKAGE_NAME, + locale: NS, + inject: () => ({ + store, + fallbackT: t, + copy, + platform: platformKey(), + replayCount: replay.size, + ask: actions.requestPermission, + testAny: actions.sendTest, + testSystem: actions.sendTestSystem, + setKind: actions.setKindEnabled, + }), + }, ConfigSection)) + + // The observer lives in the frame-wide overlay, NOT in `sidebar.panellist`. + // + // That owner does not merely render its entries: it also reads WHICH entries + // exist and turns each one into a sidebar panel button (id → panel id, label + // → button text, the entry itself → the glyph). A renderless entry there is + // therefore an extra, empty panel row in the left column — which is exactly + // how this plugin broke the sidebar once. The overlay is the non-generic + // seat: it is a floating layer whose entries routinely render nothing, so a + // `null`-rendering observer is an ordinary occupant, and it carries the same + // root-scope standard props (`useSessions`, `useSessionStatus`). + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: `${NS}-watch`, + order: 101, + inject: () => ({ observation }), + }, NotifyObserver)) +} diff --git a/src/client/ui/ConfigSection.js b/src/client/ui/ConfigSection.js new file mode 100644 index 0000000..6966ba3 --- /dev/null +++ b/src/client/ui/ConfigSection.js @@ -0,0 +1,98 @@ +/** + * This plugin's configuration, rendered on its own page in the plugin + * manager (`plugins.bundle.config`, keyed by this package's name). + * + * The owner asks for two views: `summary` (a one-liner that stands in for the + * row's description) and `page` (the whole form). Everything visible here + * belongs to this package, including the platform-specific permission hint. + * + * @module @dsh-plugin/session-notify/client/ui/ConfigSection + */ + +import * as React from 'react' +import { KINDS } from '../constants.js' +import { windowIsAway } from '../platform.js' +import { KindRow } from './KindRow.js' +import { seatTranslator, useOwnStore } from './hooks.js' +import { CSS } from './styles.js' + +const h = React.createElement + +export function ConfigSection(props) { + const snapshot = useOwnStore(props.store) + const tr = seatTranslator(props, props.fallbackT) + if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description')) + const permission = snapshot.permission + const supported = permission !== 'unsupported' + const failed = permission === 'denied' || snapshot.promptError !== '' + const statusKey = permission === 'granted' + ? 'settings.permission.granted' + : permission === 'denied' + ? 'settings.permission.denied' + : supported + ? 'settings.permission.default' + : 'settings.permission.unsupported' + const permissionText = snapshot.promptError === '' + ? `${tr(statusKey)}${permission === 'denied' || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ''}` + : snapshot.promptError + const health = snapshot.health + const healthText = health.state === 'watching' + ? tr('settings.health.watching') + : health.state === 'noHooks' + ? tr('settings.health.noHooks') + : health.state === 'error' + ? tr('settings.health.error', { message: health.message }) + : tr('settings.health.starting') + const healthFailed = health.state !== 'watching' && health.state !== 'starting' + return h(React.Fragment, null, + h('style', null, CSS), + h('div', { className: 'dsn-sections' }, + // The page header already shows this bundle's title and description, so + // this section opens with what the header cannot say: how alerts are + // carried, and what this form changes. + h('section', { className: 'dsn-section' }, + h('p', { className: 'dsn-section-desc' }, tr('config.intro'))), + h('section', { className: 'dsn-section' }, + h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')), + KINDS.map((kind) => h(KindRow, { + key: kind, + label: tr(`settings.kind.${kind}`), + checked: snapshot.kinds[kind] === true, + disabled: false, + onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true), + })), + h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))), + h('section', { className: 'dsn-section' }, + h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')), + h('div', { className: 'dsn-row' }, + h('div', { className: 'dsn-row-text' }, + h('p', { + className: `dsn-status${failed ? ' dsn-status-error' : ''}`, + role: failed ? 'alert' : 'status', + }, permissionText)), + h('button', { + type: 'button', + className: 'dsn-button', + disabled: !supported, + onClick: permission === 'granted' ? props.testSystem : props.ask, + }, tr(!supported + ? 'settings.action.unsupported' + : permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))), + h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))), + h('section', { className: 'dsn-section' }, + h('h3', { className: 'dsn-section-title' }, tr('config.section.status')), + h('div', { className: 'dsn-row' }, + h('div', { className: 'dsn-row-text' }, + h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`), + h('p', { + className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`, + role: healthFailed ? 'alert' : 'status', + }, healthText), + h('p', { className: 'dsn-status' }, props.copy.describeDelivery(snapshot.lastDelivery, tr)), + h('p', { className: 'dsn-status' }, props.copy.describeReplay(props.replayCount(), tr))), + h('button', { + type: 'button', + className: 'dsn-button', + onClick: props.testAny, + }, tr('settings.action.testAny')))))) +} diff --git a/src/client/ui/KindRow.js b/src/client/ui/KindRow.js new file mode 100644 index 0000000..1e09eb3 --- /dev/null +++ b/src/client/ui/KindRow.js @@ -0,0 +1,25 @@ +/** + * One trigger switch row: the label says which notification it governs and + * the switch is the control, so the row never needs a second description. + * + * @module @dsh-plugin/session-notify/client/ui/KindRow + */ + +import * as React from 'react' + +const h = React.createElement + +export function KindRow(props) { + return h('div', { className: 'dsn-row' }, + h('div', { className: 'dsn-row-text' }, + h('div', { className: 'dsn-row-title' }, props.label)), + h('button', { + type: 'button', + role: 'switch', + className: 'dsn-switch', + 'aria-checked': props.checked, + 'aria-label': props.label, + disabled: props.disabled, + onClick: props.onToggle, + }, h('span', { className: 'dsn-thumb' }))) +} diff --git a/src/client/ui/NotifyObserver.js b/src/client/ui/NotifyObserver.js new file mode 100644 index 0000000..2f58d8c --- /dev/null +++ b/src/client/ui/NotifyObserver.js @@ -0,0 +1,42 @@ +/** + * The resident observer: one invisible entry in a root-scope slot, which is + * how a plugin reaches the session hooks without occupying visible UI. It + * renders `null`, and any failure in its own derivation is contained here so + * a slot entry can never crash. + * + * It subscribes to the window state only so a foreground/background change + * re-derives promptly; the delivery itself re-reads that state, so a missed + * event can no longer pick the wrong channel. + * + * @module @dsh-plugin/session-notify/client/ui/NotifyObserver + */ + +import * as React from 'react' +import { text } from '../core/log.js' +import { useWindowState } from './hooks.js' + +export function NotifyObserver(props) { + useWindowState() + const useSessions = props.useSessions + const useSessionStatus = props.useSessionStatus + const select = React.useCallback((snapshot) => snapshot, []) + const list = typeof useSessions === 'function' ? useSessions(select) : undefined + const status = typeof useSessionStatus === 'function' ? useSessionStatus(select) : undefined + React.useEffect(() => { + try { + if (typeof props.useSessions !== 'function' || typeof props.useSessionStatus !== 'function') { + props.observation.reportHealth({ state: 'noHooks', message: '' }) + return + } + if (list === undefined || status === undefined) { + props.observation.reportHealth({ state: 'starting', message: '' }) + return + } + props.observation.observe(list, status) + props.observation.reportHealth({ state: 'watching', message: '' }) + } catch (error) { + props.observation.reportHealth({ state: 'error', message: text(error) }) + } + }) + return null +} diff --git a/src/client/ui/ToastLayer.js b/src/client/ui/ToastLayer.js new file mode 100644 index 0000000..67a774d --- /dev/null +++ b/src/client/ui/ToastLayer.js @@ -0,0 +1,62 @@ +/** + * The light popup stack: the frame-wide overlay entry this package owns. + * + * @module @dsh-plugin/session-notify/client/ui/ToastLayer + */ + +import * as React from 'react' +import { KindIcon, CloseIcon } from './icons.js' +import { seatTranslator, useOwnStore } from './hooks.js' +import { CSS } from './styles.js' + +const h = React.createElement + +export function ToastLayer(props) { + const snapshot = useOwnStore(props.store) + const toasts = snapshot.toasts + const tr = seatTranslator(props, props.fallbackT) + React.useEffect(() => { + if (toasts.length === 0) return undefined + const onKeyDown = (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) return + const top = toasts[0] + if (top === undefined) return + event.preventDefault() + props.dismiss(top.id) + } + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + } + }, [toasts, props]) + if (toasts.length === 0) return null + return h(React.Fragment, null, + h('style', null, CSS), + h('div', { className: 'dsn-stack', role: 'region', 'aria-live': 'polite' }, + toasts.map((toast) => h('div', { + key: toast.id, + className: 'dsn-toast', + role: toast.kind === 'completion' ? 'status' : 'alert', + onMouseEnter: () => props.hold(toast.id), + onMouseLeave: () => props.arm(toast.id), + }, + h('span', { className: `dsn-toast-icon is-${toast.kind}` }, h(KindIcon, { kind: toast.kind, size: 16 })), + h('div', { className: 'dsn-toast-text' }, + h('div', { className: 'dsn-toast-title' }, toast.title), + h('div', { className: 'dsn-toast-desc' }, toast.body)), + h('div', { className: 'dsn-toast-actions' }, + h('button', { + type: 'button', + className: 'dsn-toast-action', + onClick: () => { + props.dismiss(toast.id) + props.open(toast.sessionId) + }, + }, tr('toast.view')), + h('button', { + type: 'button', + className: 'dsn-toast-close', + 'aria-label': tr('toast.dismiss'), + onClick: () => props.dismiss(toast.id), + }, h(CloseIcon, { size: 14 }))))))) +} diff --git a/src/client/ui/hooks.js b/src/client/ui/hooks.js new file mode 100644 index 0000000..7a9c897 --- /dev/null +++ b/src/client/ui/hooks.js @@ -0,0 +1,49 @@ +/** + * The React seams the components share: two subscriptions this plugin needs + * beyond the ones the slot owner already provides, plus the seat rule for + * translation. + * + * @module @dsh-plugin/session-notify/client/ui/hooks + */ + +import * as React from 'react' +import { windowIsAway } from '../platform.js' + +/** Subscribe to this plugin's own store from inside a component. */ +export function useOwnStore(source) { + return React.useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) +} + +/** + * Subscribe to every browser signal that says whether the app is visible: + * the window's focus events and the document's visibility change. The value + * itself is a boolean, so the observer re-renders once per transition. + */ +export function useWindowState() { + const subscribe = React.useCallback((listener) => { + try { + window.addEventListener('focus', listener) + window.addEventListener('blur', listener) + document.addEventListener('visibilitychange', listener) + } catch { + return () => {} + } + return () => { + window.removeEventListener('focus', listener) + window.removeEventListener('blur', listener) + document.removeEventListener('visibilitychange', listener) + } + }, []) + return React.useSyncExternalStore(subscribe, () => !windowIsAway(), () => true) +} + +/** + * Every component translates through this: the slot's own seat when the owner + * supplies one, else this package's own translator. + * @param props - the slot entry's props, which carry the owner's `t` when it has one. + * @param fallback - the plugin's own translator, used when the seat has none. + * @returns the translate function this render should use. + */ +export function seatTranslator(props, fallback) { + return typeof props.t === 'function' ? props.t : fallback +} diff --git a/src/client/ui/icons.js b/src/client/ui/icons.js new file mode 100644 index 0000000..73900a8 --- /dev/null +++ b/src/client/ui/icons.js @@ -0,0 +1,72 @@ +/** + * This bundle's own artwork, drawn like the shipped outline set. + * + * @module @dsh-plugin/session-notify/client/ui/icons + */ + +import * as React from 'react' + +const h = React.createElement + +/** Bell glyph: used for a finished conversation. */ +export function BellIcon({ size = 16 }) { + return h('svg', { + width: size, height: size, viewBox: '0 0 16 16', fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, + }, + h('path', { + d: 'M8 2.35C5.7 2.35 3.83 4.22 3.83 6.52V9.02L3.06 11.1C2.99 11.29 3.13 11.48 3.33 11.48H12.67C12.87 11.48 13.01 11.29 12.94 11.1L12.17 9.02V6.52C12.17 4.22 10.3 2.35 8 2.35Z', + stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round', + }), + h('path', { + d: 'M6.3 12.75C6.52 13.66 7.2 14.28 8 14.28C8.8 14.28 9.48 13.66 9.7 12.75', + stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', + })) +} + +/** Shield glyph for an approval request. */ +export function ShieldIcon({ size = 16 }) { + return h('svg', { + width: size, height: size, viewBox: '0 0 16 16', fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, + }, + h('path', { + d: 'M8 1.9 13.1 3.7V7.9C13.1 11 11 13.3 8 14.2C5 13.3 2.9 11 2.9 7.9V3.7L8 1.9Z', + stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round', + }), + h('path', { + d: 'M5.9 8.05L7.35 9.5L10.15 6.6', + stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round', + })) +} + +/** Question glyph for a pending question or plan review. */ +export function QuestionIcon({ size = 16 }) { + return h('svg', { + width: size, height: size, viewBox: '0 0 16 16', fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, + }, + h('circle', { cx: 8, cy: 8, r: 6.15, stroke: 'currentColor', strokeWidth: 1.2 }), + h('path', { + d: 'M6.35 6.35C6.35 5.44 7.09 4.7 8 4.7C8.91 4.7 9.65 5.44 9.65 6.35C9.65 7.75 8 7.7 8 9.15', + stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', + }), + h('circle', { cx: 8, cy: 11.3, r: 0.85, fill: 'currentColor' })) +} + +/** Close glyph, matching the primitives' own stroke weight and geometry. */ +export function CloseIcon({ size = 12 }) { + return h('svg', { + width: size, height: size, viewBox: '0 0 16 16', fill: 'none', + xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, + }, + h('path', { d: 'M2.5 2.5L13.5 13.5', stroke: 'currentColor', strokeWidth: 1.4 }), + h('path', { d: 'M13.5 2.5L2.5 13.5', stroke: 'currentColor', strokeWidth: 1.4 })) +} + +/** Select the per-trigger glyph. */ +export function KindIcon({ kind, size }) { + if (kind === 'approval') return h(ShieldIcon, { size }) + if (kind === 'question') return h(QuestionIcon, { size }) + return h(BellIcon, { size }) +} diff --git a/src/client/ui/styles.js b/src/client/ui/styles.js new file mode 100644 index 0000000..378fcdd --- /dev/null +++ b/src/client/ui/styles.js @@ -0,0 +1,59 @@ +/** + * Every style declaration this package owns. + * + * The popup copies the shipped toast surface (a fixed entry in the frame-wide + * overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair, + * `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text; + * the switch copies `Switch.module.css` behavior and proportion; the + * configuration sections follow the Plugin Manager page's own section rhythm + * (a column, 32px apart). Class names are renamed under `dsn-`; only the popup + * glyphs carry their own artwork colors. + * + * A plugin must not import a Harness Client package, so the declarations are + * this package's own text and reference theme tokens only. + * + * @module @dsh-plugin/session-notify/client/ui/styles + */ + +export const CSS = ` +.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none} +@media (max-width:520px){.dsn-stack{right:12px}} +.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out} +@keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}} +@media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}} +.dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)} +.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)} +.dsn-toast-icon.is-question{color:var(--dsw-alias-brand-primary)} +.dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +.dsn-toast-title{font-weight:500;font-size:14px;line-height:22px} +.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word} +.dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none} +.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-static-deepseek-400);font:inherit;font-size:13px;line-height:20px;cursor:pointer} +.dsn-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)} +.dsn-toast-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer} +.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} +.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.dsn-sections{display:flex;flex-direction:column;gap:24px} +.dsn-section{display:flex;flex-direction:column;gap:10px} +.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px} +.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)} +.dsn-row:last-child{border-bottom:0} +.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0} +.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} +.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px} +.dsn-status-error{color:var(--dsw-alias-state-error-primary)} +.dsn-switch{box-sizing:border-box;position:relative;flex:0 0 auto;width:36px;height:20px;padding:2px;border:0;border-radius:999px;background:var(--dsw-alias-border-l3);cursor:pointer} +.dsn-switch[aria-checked='true']{background:var(--dsw-alias-brand-primary)} +.dsn-switch:disabled{cursor:default;opacity:.5} +.dsn-switch:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +.dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease} +.dsn-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)} +.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)} +.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none} +.dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)} +.dsn-button:disabled{cursor:not-allowed;opacity:.4} +.dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} +`