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}
+`