From 6b83a6ac19e3a65f20446385dcf90941c83e51f4 Mon Sep 17 00:00:00 2001 From: pyh Date: Wed, 30 Sep 2026 15:43:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20v1.0.1=EF=BC=88=E7=B3=BB=E7=BB=9F=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=20+=20=E5=BA=94=E7=94=A8=E5=86=85=E8=BD=BB=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话完成、需要工具授权、需要回答时提醒: - 窗口不在前台走系统通知(Windows / macOS / Linux 同一条渲染进程通知通道,无平台分支),点击回到 DSH 并跳到该会话; - 窗口在前台走应用内轻弹窗(右上角、6 秒自动消失、可悬停暂停、Esc 关闭、点「查看」跳转); - 正在看的那个会话完成时不打扰。 触发与去重:只对「运行中 → 空闲」的转变提醒,同一会话同一轮只报一次,同一待处理请求只报一次; 子智能体会话与空白会话跳过;打开应用时已在运行的会话只建基线不补发; 多个会话同时完成有 1.5 秒节流,完成提醒前有 400ms 确认窗口(那一轮又跑起来就不报)。 设置 → 通用 → 会话通知:权限状态、请求权限、测试通知、完成/授权/提问三个开关, 以及观察器自身的健康状态(收不到会话状态时直接显示,不会静默失效)。 实现形态遵循 DSH 插件规范:dsh.manifestVersion 1、dsh.bundle.patch 声明宿主行、 dsh.client 声明 platform: web 的浏览器半;宿主半为空壳,不注册任何服务与路由。 会话状态只通过 slot 的标准 props(useSessions / useSessionStatus)读取, 只往 settings.general.item 与 shell.overlay 注册,样式只用主题 token,不 import 客户端包。 修复:观察器原先挂在 sidebar.panellist,而该 slot 的宿主会把每个条目的 id 当作一个 左侧面板按钮(label 取 options.label ?? options.id,条目作为图标内联渲染), 导致左侧多出一行空面板并挤坏侧边栏;现改挂通用浮层 shell.overlay。 --- .gitattributes | 3 + .gitignore | 6 + LICENSE | 21 + README.md | 91 +++++ client.js | 1012 ++++++++++++++++++++++++++++++++++++++++++++++ cordis.patch.yml | 9 + icon.svg | 6 + index.js | 29 ++ locale/en.json | 6 + locale/zh.json | 6 + main.js | 11 + package.json | 50 +++ 12 files changed, 1250 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client.js create mode 100644 cordis.patch.yml create mode 100644 icon.svg create mode 100644 index.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 main.js create mode 100644 package.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d5e5f86 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 仓库内一律使用 LF:这是跨平台插件(Windows / macOS / Linux), +# 换行符不该随检出机器变化。 +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e008e31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# 忽略安装与构建产物;这个包没有依赖,也没有构建步骤。 +node_modules/ +pnpm-lock.yaml +*.tgz +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0fabed2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 pyh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c19b88f --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# 会话通知 + +DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗口不在前台时用**系统通知**,窗口在前台时用**应用内轻弹窗**。 + +Windows / macOS / Linux 三个平台走的是同一条通知通道(渲染进程的 Web Notification API),插件里没有平台分支。 + +## 提醒什么 + +| 触发 | 通知标题 | +| --- | --- | +| 会话从「运行中」变为「空闲」(一轮回答结束) | 会话已完成 | +| 待处理的工具授权请求(`approval`) | 需要授权 | +| 待处理的提问 / 计划确认(`question` / `plan-review`) | 需要回答 | + +每条只提醒一次:同一会话的同一轮只报一次完成,同一个待处理请求只报一次;请求消失后再来才会再报。 + +## 走哪个通道 + +| 状态 | 行为 | +| --- | --- | +| DSH 窗口**不在前台** | **系统通知**(这是插件唯一能拿到的系统通知通道,三个平台一致)。点通知:窗口回到前台并跳到该会话 | +| DSH 窗口在前台,且**不是**你正在看的那个会话 | **应用内轻弹窗**:右上角悬浮,点「查看」跳过去,6 秒自动消失,鼠标悬停时不消失,Esc 关掉最上面一条 | +| DSH 窗口在前台,且就是你**正在看的**那个会话 | 不打扰(你在看,不需要弹) | + +## 设置 + +**设置 → 通用 → 会话通知**: + +- 显示系统通知权限状态; +- 未授权时点「允许通知」请求权限(浏览器要求必须由你手动点击才能弹权限框),授权后自动发一条测试通知; +- 已授权时点「测试通知」随时验证; +- 权限被系统层关闭时,按平台给出开启路径(Windows / macOS 的通知设置、Linux 桌面环境的通知设置); +- 三个开关分别控制**完成 / 授权 / 提问**三类提醒(默认全开;开关状态存在当前页面内存里,刷新回到默认——本插件不写配置文件)。 + +## 安装 + +在 DSH 的 **插件 → 添加插件** 里填本目录的绝对路径即可(`plugin_manager` 也会做同样的安装): + +```text +D:\DeepSeek Harness Plugins\dsh-session-notify +``` + +安装后: + +- **浏览器半**随页面加载,刷新页面即生效; +- **宿主半**为空壳(不改任何 DSH 状态),如需重启才生效,完全退出 DSH 再打开即可; +- 本插件没有第三方依赖,不需要 pnpm 下载,也不需要构建步骤。 + +卸载:插件页里移除 `@dsh-plugin/session-notify`(它同时是 profile 的一个 bundle)。 + +## 平台说明与验证范围 + +- **通道是跨平台统一的**:插件的提醒都通过页面(Electron 渲染进程)的 `Notification` 构造器发出,Windows 通知中心 / macOS 通知中心 / Linux 通知守护(libnotify、GNOME、KDE)都由系统把它转成原生通知;应用内轻弹窗是纯 DOM,与平台无关。 +- **宿主半拿不到系统通知**:DSH Desktop 的宿主进程是纯 Node 进程(不是 Electron 主进程),没有 Electron 的 `Notification` 可用,所以插件的所有提醒都在浏览器半产生——这也正是"三个平台一套代码"的原因。 +- **已验证**:Windows 上的安装、三个 slot 条目注册、以及 22 条投递规则(见下)的离线验证;设置行与轻弹窗的文案/交互按 DSH 自带的 toast 与 switch 组件对齐。 +- **未验证**:macOS 与 Linux 上的实际弹窗效果本机无法测试。结构上它们与 Windows 共用同一条通道、同一份代码,只有"权限被系统关闭时显示的开启路径"是分平台的。 +- 提醒只在 DSH 进程运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。 + +## 行为细节(都已验证) + +- **只有观测到「运行中 → 空闲」的转变才提醒**:打开应用时已经在跑的会话只建立基线,不补发;历史里早已结束的会话不会被翻出来提醒。 +- **子智能体会话不单独提醒**(`origin === 'subagent'`),归属它的主会话结束时才提醒。 +- **新会话(空白会话)完成不提醒**。 +- **授权/提问在会话仍在运行时被延后提醒**:先记下,等这一轮真正停下来再报,避免在模型还在跑的时候就打扰你。 +- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏;完成提醒前有 400ms 的确认窗口,如果那一轮马上又跑起来就不报。 +- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不堆叠。 + +## 兼容性 + +基于 **DeepSeek Harness 0.2.0-rc.2** 编写,遵循 DSH 插件规范:`dsh.manifestVersion: 1`、`dsh.bundle.patch` 声明宿主行、`dsh.client` 声明 `platform: "web"` 的浏览器半。 + +依赖的都是稳定契约: + +- 浏览器半只依赖 **slot 的标准 props**(`useSessions`、`useSessionStatus`)读取会话状态,这是 DSH 官方给插件读会话数据的方式;不轮询、不自己订阅会话事件、不读别的插件的 DOM; +- 只往两个官方 slot 注册:`settings.general.item`(设置行)与 `shell.overlay`(两个条目:轻弹窗层 + 无渲染的观察器,内容永远是 `null`); +- **不要**把"无渲染"条目放进 `sidebar.panellist`:该 slot 的宿主会把**每个条目的 id 当成一个左侧面板按钮**(`entriesOfSlot('sidebar.panellist')` → 面板列表,按钮文字取 `options.label ?? options.id`,条目本身作为图标内联渲染),放进去会在左侧多出一行空面板并挤坏侧边栏。本插件第一版踩过这个坑,现已改挂到通用浮层; +- 样式只用主题 token,不 import 任何 `@deepseek-ai/dsh-client-*` 包(规范要求,也是渲染不被上游改动打断的前提); +- 宿主半不声明 `inject`、不注册服务、不注册路由。 + +上游若改动 slot 名或 hook 名:注册会静默跳过而不是把页面弄坏,插件会退化成"不提醒";这类情况请按上面的契约名核对。 + +## 验证方式(开发记录) + +`client.js` 是纯 JavaScript、浏览器端运行,可以在 Node 里用桩模块加载器评估并驱动: + +- 模块接线:加载后返回的插件、`inject` 声明、注册集合(含"观察器绝不在 sidebar.* 里"这一条)、以及各组件在空状态 / 权限被拒 / 无 Notification API 下的渲染都不报错; +- 投递规则:40 项断言覆盖上面"行为细节"里的每一条(完成提醒一次、当前会话不打扰、后台走系统通知、授权/提问各提醒一次、运行中延后、子会话跳过、历史不补发、开关生效、弹窗自动消失等)。 + +## License + +[MIT](LICENSE) diff --git a/client.js b/client.js new file mode 100644 index 0000000..1817c74 --- /dev/null +++ b/client.js @@ -0,0 +1,1012 @@ +/** + * 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 + */ + +window.__ModuleLoader__.load({ + id: '@dsh-plugin/session-notify', + factory(require) { + const React = require('react') + const h = React.createElement + + /** Locale namespace owned by this package. */ + const NS = '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 + + 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.test': '测试通知', + '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': '提问', + } + 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.test': 'Test notification', + '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', + } + + /** + * 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`), the switch copies `Switch.module.css` behavior and + * proportion, and the settings row copies the General-section row pattern. + * Class names are renamed under `dsn-`; only the popup glyphs carry their own + * artwork colors. + */ + 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:center;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding: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;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{min-width:0} +.dsn-toast-action{padding:0;border:0;background:none;color:var(--dsw-static-deepseek-400);font:inherit;cursor:pointer;margin-inline:3px} +.dsn-toast-action:hover{opacity:.8} +.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:18px;height:18px;border:0;border-radius:var(--dsw-radius-xs);background:transparent;color:var(--dsw-alias-toast-label);opacity:.7;cursor:pointer} +.dsn-toast-close: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-row{border-bottom:.5px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex} +.dsn-row-text{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex} +.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px} +.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px} +.dsn-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px} +.dsn-status + .dsn-status,.dsn-row-desc + .dsn-status,.dsn-kinds + .dsn-status{margin-top:8px} +.dsn-status-error{color:var(--dsw-alias-state-error-primary)} +.dsn-kinds{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} +.dsn-kind{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary)} +.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} +` + + /** 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) + } + }, + 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' + } + + /** Read the window's live focus state rather than a cached value. */ + function currentFocus() { + try { + return typeof document.hasFocus === 'function' ? document.hasFocus() : true + } catch { + return true + } + } + + /** 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 ?? '') + } + + /** + * Build a translate function for one locale service: the service's own bound + * namespace first, then the current language's dictionary, 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') return bound(key, params) + } catch { + /* a locale service without this namespace still renders the fallback */ + } + } + let language = '' + try { + const snapshot = locale?.getSnapshot?.() + language = String(snapshot?.language ?? snapshot?.locale ?? snapshot?.id ?? '') + } 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 + )) + } + } + + /** + * 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: { completion: true, approval: true, question: true }, + toasts: [], + promptError: '', + health: { state: 'starting', message: '' }, + }) + const notify = (message) => { + report(`session-notify: ${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, + live: { + list: undefined, + status: undefined, + focus: true, + }, + } + 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. */ + const showToast = (candidate) => { + const notice = copyFor(candidate) + state.toastSeq += 1 + const toast = { + id: `dsn-${String(state.toastSeq)}`, + kind: notice.kind, + 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)}`) + } + } + + /** Raise one system notification, tolerating every refusal a browser may give. */ + const systemNotify = (candidate, force) => { + const Ctor = globalThis.Notification + const permission = notificationPermission() + if (permission === 'unsupported') return false + if (!force && permission !== 'granted') return false + 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) + notification.onclick = () => { + focusWindow() + openSession(candidate.sessionId) + try { + notification.close() + } catch { + /* close is best effort on every platform */ + } + } + return true + } catch (error) { + notify(`system notification failed: ${text(error)}`) + return false + } + } + + // -------------------------------------------------------------- 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 + } + + /** + * 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 in the background, the light popup while + * it is in the foreground, and nothing at all for the conversation on + * screen. + */ + const deliver = (candidate, ignoreThrottle) => { + if (ignoreThrottle !== true) { + const now = Date.now() + if (now - state.lastDelivery < THROTTLE_MS) return + state.lastDelivery = now + } + if (state.live.focus) { + if (!isOnScreen(candidate.sessionId, state.live.list)) showToast(candidate) + return + } + if (systemNotify(candidate, candidate.test === true)) return + if (candidate.test === true) showToast(candidate) + } + + /** + * Take the delivery decision on a later tick, from the freshest state. + */ + const flushSettle = () => { + state.settleTimer = 0 + const candidate = state.pending.shift() + if (disposed || candidate === undefined) return + if (!stillWorth(candidate)) return + deliver(candidate, false) + } + + /** 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. + */ + const observe = (focus, list, status) => { + if (list === undefined || status === undefined) return + state.live = { list, status, focus } + 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 + + /** Raise one test notification through the channel the current state selects. */ + const sendTest = () => { + const candidate = { + kind: 'completion', + sessionId: 'test', + title: t('settings.title'), + detail: '', + pendingKind: '', + test: true, + } + if (!currentFocus() && notificationPermission() === 'granted' && systemNotify(candidate, true)) return + showToast(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. */ + const setKindEnabled = (kind, enabled) => { + const snapshot = store.getSnapshot() + store.set({ ...snapshot, kinds: { ...snapshot.kinds, [kind]: enabled } }) + } + + // ----------------------------------------------------------- components + + /** Subscribe to this plugin's own store from inside a component. */ + function useOwnStore(source) { + return React.useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot) + } + + /** The window's focus state as a React value, kept current by the DOM's own events. */ + function useFocus() { + const subscribe = React.useCallback((listener) => { + try { + window.addEventListener('focus', listener) + window.addEventListener('blur', listener) + } catch { + return () => {} + } + return () => { + window.removeEventListener('focus', listener) + window.removeEventListener('blur', listener) + } + }, []) + return React.useSyncExternalStore(subscribe, currentFocus, () => 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. + */ + function NotifyObserver(props) { + const focus = useFocus() + 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(focus, 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('span', { className: 'dsn-toast-text' }, toast.body), + 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: 12 })))))) + } + + /** The General-settings row: permission state, one action, per-trigger switches. */ + function SettingsRow(props) { + const snapshot = useOwnStore(props.store) + const permission = snapshot.permission + const supported = permission !== 'unsupported' + const failed = permission === 'denied' || snapshot.promptError !== '' + const tr = seatFor(props) + const statusKey = permission === 'granted' + ? 'settings.permission.granted' + : permission === 'denied' + ? 'settings.permission.denied' + : supported + ? 'settings.permission.default' + : 'settings.permission.unsupported' + const statusText = 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') + return h(React.Fragment, null, + h('style', null, CSS), + h('div', { className: 'dsn-row' }, + h('div', { className: 'dsn-row-text' }, + h('div', { className: 'dsn-row-title' }, tr('settings.title')), + h('div', { className: 'dsn-row-desc' }, tr('settings.description')), + h('div', { + className: `dsn-status${failed ? ' dsn-status-error' : ''}`, + role: failed ? 'alert' : 'status', + }, statusText), + h('div', { + className: `dsn-status${health.state === 'watching' || health.state === 'starting' ? '' : ' dsn-status-error'}`, + role: health.state === 'watching' || health.state === 'starting' ? 'status' : 'alert', + }, healthText), + h('div', { className: 'dsn-kinds' }, + h('span', null, tr('settings.kinds.label')), + KINDS.map((kind) => h('span', { className: 'dsn-kind', key: kind }, + h('span', null, tr(`settings.kind.${kind}`)), + h('button', { + type: 'button', + role: 'switch', + className: 'dsn-switch', + 'aria-checked': snapshot.kinds[kind] === true, + 'aria-label': tr(`settings.kind.${kind}`), + disabled: !supported, + onClick: () => props.setKind(kind, snapshot.kinds[kind] !== true), + }, h('span', { className: 'dsn-thumb' })))))), + h('button', { + type: 'button', + className: 'dsn-button', + disabled: !supported, + onClick: permission === 'granted' ? props.test : props.ask, + }, tr(!supported ? 'settings.action.unsupported' : permission === 'granted' ? 'settings.action.test' : 'settings.action.allow')))) + } + + // ---------------------------------------------------------- registration + + /** Keep the page's own focus events owned by this plugin's effect. */ + const installBridges = () => { + const onFocusChange = () => { + state.lastDelivery = 0 + } + try { + window.addEventListener('focus', onFocusChange) + window.addEventListener('blur', onFocusChange) + } catch { + /* an unreadable window still delivers through live focus reads */ + } + return () => { + try { + window.removeEventListener('focus', onFocusChange) + window.removeEventListener('blur', onFocusChange) + } catch { + /* nothing to detach */ + } + if (state.settleTimer !== 0) { + clearTimeout(state.settleTimer) + state.settleTimer = 0 + } + for (const timer of timers.values()) clearTimeout(timer) + timers.clear() + } + } + + 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)) + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: NS, + order: 97, + locale: NS, + inject: () => ({ + store, + platform: platformKey(), + ask: requestPermission, + test: sendTest, + setKind: setKindEnabled, + }), + }, SettingsRow)) + + // 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, + } + }, +}) diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..d4f3461 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,9 @@ +# Bundle patch layer for @dsh-plugin/session-notify. +# +# One Host row is enough: the Host half owns no services and no routes, and the +# browser half is picked up automatically from this package's `dsh.client` +# declaration (`dsh-client-modules` scans enabled Loader entries, serves +# `/plugins/@dsh-plugin/session-notify/client.js`, and boots it with the page). +- insert: + - id: session-notify + name: '@dsh-plugin/session-notify' diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..2980449 --- /dev/null +++ b/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..af6ca53 --- /dev/null +++ b/index.js @@ -0,0 +1,29 @@ +/** + * Host half of the session-notify bundle. + * + * Every notification in this plugin is produced by the browser half: the page + * owns the DOM (the in-app light popup) and is the only place a Web + * `Notification` can be constructed, which is the one system-notification + * channel that behaves the same on Windows, macOS, and Linux inside the Harness + * Desktop shell. The Host half therefore owns no state, registers no service, + * no route, and no event listener — it exists so the bundle has the ordinary + * profile-level plugin row its patch declares, and so a future Host-side + * capability (for example persisting the per-kind switches into this profile's + * patch) has a place to live. + * + * The export form is the documented one — `export function apply(ctx, config)` + * with no `inject` and no `Config` — and it is deliberately the only export + * form this package uses. + * + * @module @dsh-plugin/session-notify + */ + +/** + * Mount the Host row. + * @param ctx - Host context; no service is required, so this never blocks activation. + */ +export function apply(ctx) { + ctx.logger?.debug?.( + 'session-notify: host half idle by design — notifications run in the browser half', + ) +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..50e1f6b --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Session notifications", + "description": "Tells you when a conversation finishes, needs approval, or needs an answer — a system notification when the window is in the background, a light in-app popup when it is in the foreground." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..c4fa25c --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话通知", + "description": "会话完成、需要授权或需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。" + } +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..8630e92 --- /dev/null +++ b/main.js @@ -0,0 +1,11 @@ +/** + * CommonJS-resolvable entry alias for the Host half. + * + * `package.json` `exports` names `./index.js`, which is what the Cordis Loader + * and the plugin manager read. A loader that resolves this package by + * `main` instead of `exports` would otherwise find nothing, so this file + * re-exports the same plugin without adding behavior or export forms. + * + * @module @dsh-plugin/session-notify/main + */ +export { apply } from './index.js' diff --git a/package.json b/package.json new file mode 100644 index 0000000..1da38b8 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "@dsh-plugin/session-notify", + "version": "1.0.1", + "private": true, + "type": "module", + "description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://gitea.iwake.top/dsh-plugin/session-notify.git" + }, + "homepage": "https://gitea.iwake.top/dsh-plugin/session-notify", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "icon": "./icon.svg", + "meta": { + "title": "会话通知", + "description": "会话完成 / 需要授权 / 需要回答时提醒,支持系统通知与应用内轻弹窗。" + }, + "files": [ + "index.js", + "main.js", + "client.js", + "cordis.patch.yml", + "icon.svg", + "locale/*.json", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22" + }, + "dsh": { + "manifestVersion": 1, + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + } + } +}