12 Commits
41 changed files with 4734 additions and 1301 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 忽略安装与构建产物;这个包没有依赖,也没有构建步骤。
# 忽略安装产物。注意 client.js 虽然也是构建产物,但它是有意提交的(见 README 的「仓库结构」),
# 因为安装这个插件的人不该被要求装依赖、跑构建。
node_modules/
pnpm-lock.yaml
*.tgz
.DS_Store
Thumbs.db
+2 -1
View File
@@ -5,7 +5,8 @@
#
# 类型与 emoji:feat ✨ / fix 🐛 / docs 📝 / refactor ♻️ / perf ⚡️ /
# test ✅ / build 📦️ / ci 💚 / chore 🔧 / revert ⏪️
# 作用域(可选):client 浏览器半 / host 宿主半
# 作用域(可选):client 浏览器半 / host 宿主半 / build 构建与测试
# 例:✨ feat(client): 后台走系统通知,前台走应用内轻弹窗
# 例:🐛 fix(client): 前台判定改为现读可见性
# 例:📝 docs: 安装说明补充 Git 标签与本地路径两种方式
# 注意:改 src/client/** 后必须 `pnpm run build`,产物 client.js 要一起提交
+51
View File
@@ -2,6 +2,57 @@
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。
## v1.0.9
- 轻弹窗改成 **DSH 的浮层卡片**,跟随 DSH 的深色 / 浅色模式。原来用的是 DSH 的 `--dsw-alias-toast-bg`,而那个令牌在**两种模式下都是深灰**(浅色模式 `#353638`、深色模式 `#43454a`),所以在浅色页面里浮窗是一块接近纯黑的板子,和周围的卡片不是一种东西。现在照 DSH 自己的做法来(下面这些都是 shell 主题里真实存在、并且会随 `body[data-ds-dark-theme]` 一起切换的令牌):
- 底色 `--dsw-alias-bg-layer-2`:浅色 `#fff`、深色 `#2c2c2e`,和浮动菜单 / 弹窗用的是同一层;
- 发丝线与大阴影用 `--dsw-elevation-stroke-color` + `--dsw-elevation-prominent`(DSH 菜单的做法,发丝线取 `--dsw-alias-border-l1`:浅色 `#0000000a`、深色 `#ffffff0f`);
- 文字改 `--dsw-alias-label-primary` / `-secondary` / `-tertiary`,不再是写死的白字加 `opacity`;
- 入场动画用 shell 自己的 `--ds-transition-duration` 与 `--ds-ease-in-out`。
- 「查看」按钮改成 `--dsw-alias-state-business-primary`(浅色 `#4176e6`、深色 `#7aaaff`);原来是写死的 `--dsw-static-deepseek-400`,在白底上对比度不够、也不随主题变化。关闭按钮用 `--dsw-alias-label-tertiary`,hover 升到主文字色。三类图标仍是状态令牌:完成绿 `#22c55e`、授权琥珀 `#dd8629`、提问蓝(随主题)。
- 新增 `tests/unit/styles.test.js`(5 条)把两条不变量钉住:**样式表里不出现字面颜色、也不用 `--dsw-static-*` 这类不随主题切换的令牌**;弹窗必须用上面那套浮层配方,且**不许再出现 `--dsw-alias-toast-bg`**。以后谁把颜色写死、或又退回那个深色板子,测试会直接失败。
- 行为未改:位置(右上角)、尺寸、停留时间、hover 暂停、`Esc` / ✕ 关闭、同屏 3 条都不变,只换皮。
## v1.0.8
- 修复:**授权与提问两类提醒从来不会弹**(只有「会话已完成」会弹)。原因不是判据写错了,而是投递前的「是否还值得提醒」那道闸门用错了事实:
- `stillWorth` 原本对所有提醒都要求「会话已经不在运行」,可待处理请求恰恰出现在会话仍在运行的时候——客户端的 `running` 只镜像宿主的 `api-session/status`,和有没有待处理请求是两件独立的事(`dsh-client-ui-session` 的 `observeRunning` 就是这么写的),于是每条授权 / 提问提醒都在 400ms 确认窗口结束时被静默丢掉;
- 更糟的是丢之前 `pendingNotice` 已经记下了这个请求,所以它再也不会被重新排进队列——同一请求只报一次的规则反而变成了"一次都不报"。
- 现在两类提醒用各自该问的问题:**待处理请求问「它还在等吗」**(请求被你自己答掉就不提醒,还在就提醒,不看运行状态),**完成提醒仍问「这一轮真的停下来了吗」**(在确认窗口里又跑起来就不提醒)。
- 修复:**计划确认(`plan-review`)的文案从 v1.0.1 起就是死代码**——`copyFor` 先判断 `kind === 'question'`,而计划确认正是以 `kind: 'question'` 入队的,于是「计划正在等待你确认」永远走不到,实际显示的是提问那段文案。现在先看判别符,计划确认用自己的文案,与 README 的说明一致。
- 投递节流不再丢待处理提醒:1.5 秒的洪泛保护原本会直接吃掉被拦下的提醒,现在**待处理请求**被拦下时会回到队列、下一个 tick 重试(只有你能清掉它,所以它一直是欠你的);完成提醒保持原有的丢弃语义,多个会话同时结束仍然不会刷屏。
- 重新设计插件页(**插件 → 会话通知**):原来是一段孤立说明 + 四段标题与行标题同字号的段落,状态区把四句话和按钮挤在一行里。现在:
- 顶部一句「提醒是怎么送出去的」,下面三组:**提醒内容 / 系统通知权限 / 运行状态**,组标题加粗,组内是「左侧对象 + 右侧唯一控件」的列表,行之间用细线分隔;
- 权限状态带一个小圆点(绿=已开启,黄=未授权,红=被系统关掉,灰=当前环境不支持),下面一行按情况给出该去哪授权或该按钮验什么;
- 运行状态改成**键值对照表**(窗口状态 / 会话监听 / 最近一次投递 / 待补发提醒),最近一次投递拆成「时间 · 通道」加下一行提醒类型,不再是四个句子堆在一个格子里;
- 「测试提醒」独立成行右对齐,按钮区分主次(未授权时的「允许通知」是主按钮),行高与圆角统一。
- 字典随之增删:新增 `config.diag.watch`、`config.diag.replayValue`,`config.diag.replay` 改成短标签「待补发提醒」、`config.diag.replayNone` 改成「没有」,两个字典键集合仍然完全一致,没有死键。
- 测试从 36 条增加到 39 条:新增「待处理请求放着 `running: true` 也照样该提醒」的回归用例(这条如果早就有,v1.0.7 之前就能发现上面那个 bug)、计划确认文案用例,并把页面渲染测试改到新的结构与文案上。
## v1.0.7
- 重构:**客户端源码拆成模块,`client.js` 从「手写文件」变成「构建产物」**。原来的 `client.js` 是 1452 行单文件,通知引擎、轻弹窗、插件页、观察器、字典、样式、图标全塞在一个 `apply` 里,改任何一处都要在同一个文件里上下翻。现在:
- `src/client/core/` 是行为:`observe`(一轮派生)、`delivery`(通道决策、节流、400ms 确认窗口)、`system-channel`(系统通知与平台回执)、`replay`(回前台补发)、`toasts`(弹窗栈与计时)、`actions`(两个测试按钮、权限请求、开关)、以及 `copy` / `session` / `storage` / `store` / `log`;
- `src/client/ui/` 是界面:`ToastLayer`、`KindRow`、`ConfigSection`、`NotifyObserver`、`icons`、`styles`、`hooks`;
- `src/client/i18n/` 是字典与翻译器,`src/client/platform.js` 是浏览器与平台事实,`src/client/plugin.js` 是组合根(建 store、装配模块、注册三个 slot),`src/client/index.js` 是构建入口。
- **`client.js` 仍然是包根那一个文件,安装路径与发布清单都没变**:`exports`、`files`、`main`、`icon`、`dsh` 与 `cordis.patch.yml`、`locale/` 全部原样,使用者依旧零依赖、零构建。
- 新增 `scripts/build-client.mjs`(esbuild):把 `src/client/**` 打包成一个自包含单文件,并校验它仍然满足注册契约(只有一个 factory、`require('react')` 落在 factory 内、没有顶层 `import`/`export`、除了 `react` 不再向模块表要别的模块)。这一步无法省略:客户端模块系统只交给插件一个自包含 bundle,factory 里的 `require` 不能 require 同一个包里的相对文件。
- 新增 `tests/`(`pnpm test`,用 `node --test`,无第三方依赖):26 个纯函数单测(文案、会话派生、存储、字典与翻译器)、4 个产物测试(注册 id、factory 返回值、`apply` 在没有 DOM 的 Node 里也能跑通、**产物与 `src/**` 是否一致**)、6 个渲染测试(用一份最小 React 替身渲染插件页与轻弹窗,验证重构后的 props 装配:开关的选中态、诊断行、按钮回调、观察器的健康上报)。以后改完源码忘了重新构建,测试会直接失败。
- 维护者现在需要跑一次 `pnpm install`(唯一 devDependency 是 esbuild);**使用者不需要**。README 的安装说明按这个区分改写了,并新增「仓库结构」一节。
- 行为未改:三类提醒的判定、通道选择、节流与确认窗口、补发与去重、开关的存储键、CSS 全文与 `dsn-*` 类名、全部字典键与文案都逐字保持(构建产物与 v1.0.6 的字符串逐条比对通过,样式表 4615 字节完全一致)。
- 顺带记录一个**本次没有修的既有缺陷**:`body.planReview`(「计划正在等待你确认」)在 v1.0.6 里已是死代码——`copyFor` 先判断 `kind === 'question'`,而计划确认正是以 `kind: 'question'` 入队的,所以计划提醒实际显示的是提问那段文案。修它要调整判断顺序、属于行为变更,因此这次只把现状写进 `src/client/core/copy.js` 的注释,并在 `tests/unit/copy.test.js` 里钉住当前输出。
## v1.0.6
- 修复:**窗口不在前台时,提醒有可能一次都看不到**。排查证据:一次真实投递(会话 17:52:29 结束)在插件页留下 `09:52:30 · 系统通知`——`09:52:30` 是 UTC,本机时间是 17:52:30;而 Windows 的通知平台记录 `LastNotificationAddedTime` 停在 70 分钟前。也就是说 `new Notification()` 没抛错,但**构造函数成功不等于通知真的弹出来了**,加上系统通知的气泡只存在几秒,人不在电脑前时它等于没送达。这一版不再把"发出去"当成"收到了":
- 每条系统通知都订阅 `show` / `error`:系统是否确认显示会写进插件页的「最近一次投递」,不再只报"系统通知"三个字;
- **凡是在窗口不在前台时投递的提醒,都会在窗口回到前台的那一刻用应用内轻弹窗补发一次**。同一会话同一类只保留最新一条,超过 30 分钟不再补发;"正在看的会话不打扰"这条规则不适用于补发,因为它发生的时候你并不在看。唯一的例外是系统确认气泡已弹出、且你在 20 秒内就回到窗口(气泡还在屏幕上,不再重复);
- 点掉系统通知本身(`click`)会取消对应的补发,不重复打扰。
- 修复「最近一次投递」的时间显示成 UTC(比本地时间早 8 小时,17:52 显示成 09:52)的问题,现在显示本机时间。
- 修复确认窗口的队列会把提醒永久卡住的问题:一次确认窗口只投递一条,剩下没轮到的会自己再排一次(以前排完之后队列里再没人触发,后面的提醒就被吞了)。
- 系统通知对象改为有界持有引用:防御性改动,避免显示还没落定时对象被回收(实测中保留引用与否都能送达,所以这不是那次丢失的原因)。
- 插件页「运行状态」新增一行:还有几条提醒在等你回到窗口时补发。
## v1.0.5
- 修复:**应用在后台时不发系统通知**。前台/后台以前只靠 `window` 的 `focus`/`blur` 事件记住,桌面外壳里 `blur` 可能不到,插件就一直以为窗口在前台,于是走了轻弹窗分支(而用户没在看,表现就是完全没有通知)。现在每次投递都现读 `document.visibilityState` 与 `document.hasFocus()`,任一表示不可见即走系统通知;观察器订阅的事件只是让重算更及时,不再决定通道。
+3 -2
View File
@@ -41,10 +41,11 @@
## 作用域约定
这个插件只有一个包,作用域用来区分改动落在哪一半,不是必需的:
这个插件只有一个包,作用域用来区分改动落在哪一块,不是必需的:
- `client`:浏览器半(`client.js`)—— 通知引擎、轻弹窗、插件页配置、观察器;
- `client`:浏览器半(`src/client/**`,以及由它构建出的 `client.js`)—— 通知引擎、轻弹窗、插件页配置、观察器;
- `host`:宿主半(`index.js`、`cordis.patch.yml`)—— 目前是空壳,只有它变化时才需要重启 DSH;
- `build`:构建与测试(`scripts/build-client.mjs`、`package.json`、`tests/**`);
- 其它临时作用域(如 `deps`、`naming`)按需起,不必登记。
## 示例
+83 -65
View File
@@ -1,51 +1,71 @@
# 会话通知
DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗口不在前台时用**系统通知**,窗口在前台时用**应用内轻弹窗**。
Windows / macOS / Linux 三个平台走的是同一条通知通道(渲染进程的 Web Notification API),插件里没有平台分支。
DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗口不在前台用**系统通知**,窗口在前台用**应用内轻弹窗**。
## 提醒什么
| 触发 | 通知标题 |
| 什么时候提醒 | 通知标题 | 内容里带什么 |
| --- | --- | --- |
| 会话从「运行中」变为「空闲」(一轮回答结束) | 会话已完成 | 会话题目 |
| 出现等待你处理的工具授权请求 | 需要授权 | 等待授权的工具名 |
| 出现等待你回答的提问,或等待你确认的计划 | 需要回答 | 提问提醒带问题原文,计划确认带会话题目 |
三类提醒可以分别开关。
每条只提醒一次:同一会话的同一轮只报一次完成,同一个待处理请求只报一次;请求消失之后再次出现,会重新提醒。
## 提醒送到哪里
按 DSH 窗口**当时**的状态选通道,你不需要预先设置:
| 窗口状态 | 怎么提醒 |
| --- | --- |
| 会话从「运行中」变为「空闲」(一轮回答结束) | 会话已完成 |
| 待处理的工具授权请求(`approval`) | 需要授权 |
| 待处理的提问 / 计划确认(`question` / `plan-review`) | 需要回答 |
| 窗口**不在前台** | **系统通知**(Windows 通知中心 / macOS 通知中心 / Linux 通知守护)。点一下通知:窗口回到前台,并跳到那个会话 |
| 窗口在**前台**,但不是你正在看的那个会话 | **应用内轻弹窗**:右上角浮出一张卡片,标题是提醒类型,下面一行是会话题目与细节 |
| 窗口在**前台**,且正是你**在看的**那个会话 | 不打扰(你就在看着,不需要弹) |
每条只提醒一次:同一会话的同一轮只报一次完成,同一个待处理请求只报一次;请求消失后再来才会再报。
系统通知的气泡只存在几秒,**人不在电脑前时它等于没送到**,所以:
## 走哪个通道
- 凡是在窗口不在前台时投递的提醒,**窗口回到前台的那一刻会再用轻弹窗补发一次**(同一会话同一类只留最新一条,超过 30 分钟不再补发);
- 唯一的例外:系统确认气泡已经弹出来、而且你 20 秒内就回到窗口——那条气泡还在屏幕上,就不再重复;
- 补发不受「正在看的会话不打扰」限制——这条提醒发生的时候你并不在看;
- 点掉系统通知本身就会取消对应的补发;
- 插件页「最近一次投递」会写清系统到底有没有确认显示(`系统已确认弹出` / `系统没有回报` / `没弹出来`)。
| 状态 | 行为 |
| --- | --- |
| DSH 窗口**不在前台** | **系统通知**(这是插件唯一能拿到的系统通知通道,三个平台一致)。点通知:窗口回到前台并跳到该会话 |
| DSH 窗口在前台,且**不是**你正在看的那个会话 | **应用内轻弹窗**:右上角悬浮,标题是提醒类型、下面一行是会话题目与细节;点「查看」跳过去,6 秒自动消失,鼠标悬停时不消失,Esc 或者 ✕ 关掉 |
| DSH 窗口在前台,且就是你**正在看的**那个会话 | 不打扰(你在看,不需要弹) |
轻弹窗的用法:
- 点「查看」跳到该会话;
- 6 秒后自动消失;鼠标停在上面期间不消失,移开后重新计时;
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
- 同屏最多叠 3 条,更早的会被挤掉;
- 轻弹窗是 DSH 的浮层卡片样式(和菜单、弹窗同一层底色与阴影),**跟随 DSH 的深色 / 浅色模式**。
## 设置
**插件 → 会话通知**(插件自己的介绍页里):
**插件 → 会话通知**:
- 三个开关分别控制**完成 / 授权 / 提问**三类提醒(默认全开),状态存在浏览器本地,重装插件不丢;
- 系统通知权限状态;未授权时点「允许通知」请求权限(浏览器要求必须由你手动点击才能弹权限框),授权后自动发一条测试通知;权限被系统层关闭时按平台给出开启路径;
- 「测试系统通知」直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用;
- 「运行状态」一段显示:窗口现在是前台还是后台(决定会走哪条通道)、观察器是否真的收到了会话状态、以及**最近一次投递走的通道与时间**。
| 分组 | 内容 |
| --- | --- |
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
| 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 |
| 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 |
设置放在插件自己的页面里,而不是「设置 → 通用」:它是这个插件的配置,不是 DSH 的通用偏好。页面顶部是 DSH 自己渲染的插件标题与描述,所以这块配置不再重复标题,直接用一句「提醒是怎么送出去的」开头。
- 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。
- 开关只影响提醒,不改 DSH 本身的任何设置。
## 安装
### 1. 从 Git 仓库安装(推荐)
### 从 Git 仓库安装(推荐)
在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
```text
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.3
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.9
```
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.3`。仓库是公开的,不需要凭据,也不用改「安装源」。
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.9`。仓库是公开的,不需要凭据,也不用改「安装源」。
### 2. 本地路径安装
### 本地路径安装
把仓库克隆或下载到本机,在同一个输入框里填包目录的绝对路径,例如:
@@ -53,62 +73,60 @@ https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.3
D:\DeepSeek Harness Plugins\dsh-session-notify
```
### 装完之后
### 安装之后
- **浏览器半**随页面加载,**刷新页面**即生效;
- **宿主半**是空壳(不改任何 DSH 状态、不注册服务与路由),只有它变化时才需要完全退出 DSH 再打开;
- 本插件没有第三方依赖,不需要 pnpm 下载,也没有构建步骤。
- 装好**刷新页面**即生效;仓库里已经带好构建产物,所以**既不需要 pnpm 下载,也不需要构建**,本插件运行时没有第三方依赖。
- 卸载:在插件页里移除 `@dsh-plugin/session-notify`。
- 上面这两条说的都是**使用者**。要改插件本身,只需要一个 `pnpm install`(唯一依赖是打包用的 esbuild),见下面的[仓库结构](#仓库结构)。
卸载:插件页里移除 `@dsh-plugin/session-notify`(它同时是 profile 的一个 bundle)。
## 提醒规则
## 平台说明与验证范围
- **只在「运行中 → 空闲」的转变上提醒**:打开应用时已经在跑的会话只建立基线,不补发;历史里早已结束的会话不会被翻出来提醒。
- **正在看的那个会话完成后不打扰**。
- **子智能体的会话不单独提醒**,等它归属的主会话结束时才提醒。
- **空白会话完成不提醒**。
- **授权 / 提问会立刻提醒,不押后**:待处理请求只有你能清掉,而会话「正在等它」本来就是会话的状态(`running` 是宿主自己的另一条事实,和有没有待处理请求无关),所以不会因为「会话还在跑」而压后提醒;请求在 400ms 确认窗口内被你自己答掉,就不再提醒。
- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏。
- **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。
- **窗口不在前台时投递过的提醒,回到前台会补发一次轻弹窗**(30 分钟内有效)。
- 提醒只在 DSH 运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。
- **通道是跨平台统一的**:插件的提醒都通过页面(Electron 渲染进程)的 `Notification` 构造器发出,Windows 通知中心 / macOS 通知中心 / Linux 通知守护(libnotify、GNOME、KDE)都由系统把它转成原生通知;应用内轻弹窗是纯 DOM,与平台无关。
- **前台/后台的判定不依赖单一信号**:每次都现读页面的可见性与文档焦点(任一为"不可见"就算后台),不靠"记住上次的 blur 事件"——桌面外壳里漏掉一次 blur 曾让插件误判为前台而吃掉提醒(v1.0.5 修)。
- **宿主半拿不到系统通知**:DSH Desktop 的宿主进程是纯 Node 进程(不是 Electron 主进程),没有 Electron 的 `Notification` 可用,所以插件的所有提醒都在浏览器半产生——这也正是"三个平台一套代码"的原因。
- **已验证**:Windows 上的安装、三个 slot 条目注册、以及 28 项离线断言(接线 / 国际化 / 两种后台信号 / 系统通道被拒时的降级 / 配置页诊断 / 全部投递规则)。
- **未验证**:macOS 与 Linux 上的实际弹窗效果本机无法测试。结构上它们与 Windows 共用同一条通道、同一份代码,只有"权限被系统关闭时显示的开启路径"是分平台的。
- 提醒只在 DSH 进程运行、页面打开时产生:**DSH 完全退出期间结束的会话不会再补发通知**。
## 平台
## 行为细节(都已验证)
- **只有观测到「运行中 → 空闲」的转变才提醒**:打开应用时已经在跑的会话只建立基线,不补发;历史里早已结束的会话不会被翻出来提醒。
- **子智能体会话不单独提醒**(`origin === 'subagent'`),归属它的主会话结束时才提醒。
- **新会话(空白会话)完成不提醒**。
- **授权/提问在会话仍在运行时被延后提醒**:先记下,等这一轮真正停下来再报,避免在模型还在跑的时候就打扰你。
- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏;完成提醒前有 400ms 的确认窗口,如果那一轮马上又跑起来就不报。
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不堆叠。
- Windows / macOS / Linux 都支持,三个平台走同一条通知通道,行为一致;应用内轻弹窗与平台无关。
- 当前环境不支持系统通知时(权限一行会显示「不可用」),提醒仍然以应用内轻弹窗的形式送达。
- 系统通知被拒绝或发送失败时,插件会改用轻弹窗,并把原因写在插件页的权限 / 状态那一段,不会静默失败。
## 兼容性
基于 **DeepSeek Harness 0.2.0-rc.2** 编写,遵循 DSH 插件规范:`dsh.manifestVersion: 1`、`dsh.bundle.patch` 声明宿主行、`dsh.client` 声明 `platform: "web"` 的浏览器半。
需要 **DeepSeek Harness 0.2.0-rc.2 或更高**。
依赖的都是稳定契约:
## 仓库结构
- 浏览器半只依赖 **slot 的标准 props**(`useSessions`、`useSessionStatus`)读取会话状态,这是 DSH 官方给插件读会话数据的方式;不轮询、不自己订阅会话事件、不读别的插件的 DOM;
- 只往两个官方 slot 注册:`plugins.bundle.config`(本插件在插件页里的配置,key 就是包名)与 `shell.overlay`(两个条目:轻弹窗层 + 无渲染的观察器,内容永远是 `null`);
- **配置放在插件页**:`plugins.bundle.config` 的 key 取包名(`@dsh-plugin/session-notify`),插件页就会渲染这块配置;官方 `dsh-experimental-voice-input-bundle` 用的就是这个口子。行级口子是 `plugins.row.config`,key 形如 `<包名>#<行 id>`,会在该行上多出一个「配置」入口;
- **不要**把"无渲染"条目放进 `sidebar.panellist`:该 slot 的宿主会把**每个条目的 id 当成一个左侧面板按钮**(`entriesOfSlot('sidebar.panellist')` → 面板列表,按钮文字取 `options.label ?? options.id`,条目本身作为图标内联渲染),放进去会在左侧多出一行空面板并挤坏侧边栏。本插件第一版踩过这个坑,现已改挂到通用浮层;
- **文案必须注册字典**:`ctx.locale.register('session-notify', { zh, en })`,否则 `locale.bind(ns)` 在查不到该命名空间时会把 key 原样返回——界面上就会显示成 `settings.title` 这样的原始键(本插件第二版踩过这个坑)。注册会在 locale 修订号上打点,已经渲染出来的部件会自动换上文案,不需要刷新;
- **前台/后台要现读、不能只靠事件**:桌面外壳里 `blur` 可能不到,只记事件会让插件一直以为在前台而吃掉系统通知(本插件第三版踩过这个坑)。现在每次投递都读 `document.visibilityState` 与 `document.hasFocus()`;
- 另外自带一份本地字典兜底:即使拿不到框架的 `t`、或 locale 服务没有保存注册,也会渲染中文/英文文案而不是原始键;
- 样式只用主题 token,不 import 任何 `@deepseek-ai/dsh-client-*` 包(规范要求,也是渲染不被上游改动打断的前提);
- 宿主半不声明 `inject`、不注册服务、不注册路由;三个开关存浏览器本地,不改 DSH 的配置文件。
给要改这个插件本身的人;只是使用它的话,这一节不需要看。
上游若改动 slot 名或 hook 名:注册会静默跳过而不是把页面弄坏,插件会退化成"不提醒";这类情况请按上面的契约名核对。
| 路径 | 是什么 |
| --- | --- |
| `src/client/` | 浏览器半的全部源码:`core/`(观察、投递、系统通知通道、补发、弹窗栈、开关与动作)、`ui/`(弹窗、开关行、插件页、观察器、图标、样式、hooks)、`i18n/`(中英文字典与翻译器)、`platform.js`(窗口可见性、通知权限、图标、平台提示)、`plugin.js`(组合根:建 store、装配模块、注册 slot) |
| `client.js` | **构建产物**:`src/client/**` 打包成一个自包含单文件,由 DSH 的客户端模块系统加载。它提交进仓库——这正是安装免构建的原因 |
| `index.js` / `main.js` | 宿主半(目前是空壳),以及给按 `main` 解析的加载器用的同义入口 |
| `cordis.patch.yml`、`icon.svg`、`locale/` | 组合包 patch、图标、插件展示用的标题与简介 |
| `scripts/build-client.mjs` | 构建脚本:打包,并校验产物仍然满足注册契约 |
| `tests/` | `node --test`:纯函数单测、组件渲染测试(含轻弹窗与插件页)、产物冒烟、以及产物与源码是否一致 |
## 验证方式(开发记录)
**为什么必须是单个文件**:DSH 的客户端模块系统只把**一个自包含 bundle** 交给插件(`window.__ModuleLoader__.load({ id, factory })`),factory 里的 `require` 只能解析 `react` 这类平台模块,**不能** require 同一个包里的相对文件。所以源码拆成模块之后,必须再打包回单文件——官方客户端插件(例如 `dsh-experimental-client-ui-voice-input`)也是这个形态:`src/**` 源码 + 构建出的客户端 bundle。
`client.js` 是纯 JavaScript、浏览器端运行,可以在 Node 里用桩模块加载器评估并驱动:
维护者命令:
- 模块接线:插件返回值、注册集合(含"配置挂在插件页""观察器绝不在 `sidebar.*` 里"这两条);
- 国际化:字典中英键集合一致且没有死键、插件页与弹窗渲染出的每个字符串都不是原始键、拿不到框架 `t` 时本地字典兜底;
- 前台/后台判定:**只给"页面不可见"信号**、或**只给"文档失焦"信号**(都不发 blur 事件)时,都必须走系统通知;
- 系统通道被拒时降级:构造器抛错时改用轻弹窗,并在配置页写明被拒;
- 配置页诊断:窗口状态、观察器健康、最近一次投递的通道与时间都能渲染;
- 投递规则:完成提醒一次、当前会话不打扰、授权/提问各提醒一次、运行中延后、子会话跳过、空白会话跳过、历史不补发等。
```bash
pnpm install # 只装 esbuild 一个 devDependency
pnpm run build # 从 src/client 重新生成 client.js —— 改完源码必须跑
pnpm test # node --test
```
合计 28 项断言。
- **不要直接改 `client.js`**:它每次构建都会被覆盖;改完 `src/` 忘记重新构建,`tests/bundle.test.js` 会直接报「产物已过期」。
- 改完产物后,正在运行的 DSH 里要在插件页把这一行**停用再启用**(或重启 DSH):宿主对客户端 bundle 有缓存,只刷新页面不一定重新读取它。
## License
+1284 -1230
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dsh-plugin/session-notify",
"version": "1.0.5",
"version": "1.0.9",
"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": {
+285
View File
@@ -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
+155
View File
@@ -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)`)
}
+49
View File
@@ -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
+87
View File
@@ -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 }
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 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), `deliveryFacts` and `replayValue`
* (the two status values the settings page shows).
*/
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 }) }
}
// A plan review arrives as a question with its own discriminator, so it is
// answered before the question branch below — a plan review says what it is,
// rather than repeating the plan text the user has not read yet.
if (candidate.pendingKind === 'plan-review') {
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
}
if (candidate.kind === 'question') {
const question = candidate.detail
return {
kind: 'question',
title: t('notification.question'),
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
}
}
const tool = candidate.detail
return {
kind: 'approval',
title: t('notification.approval'),
body: tool === '' ? t('body.approvalPlain', { title }) : t('body.approval', { title, tool }),
}
}
/**
* Split the newest delivery into the three facts the status list shows.
* @param last - `store.lastDelivery`.
* @param tr - the translate function the outcome label is rendered with.
* @returns `{ at, outcome, title }`, or null when nothing was ever delivered.
*/
const deliveryFacts = (last, tr) => {
if (last === null || last === undefined) return null
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 { at: last.at, outcome, title: last.title ?? '' }
}
/** How many away-channel alerts are still waiting for the user to come back. */
const replayValue = (count, tr) => (
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replayValue', { count })
)
return { copyFor, deliveryFacts, replayValue }
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 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, stillOwed } 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.
* @returns `false` when the flood guard suppressed this alert, `true` when the
* delivery path itself ran (including a deliberate skip for the conversation
* on screen).
*/
const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) {
const now = Date.now()
if (now - lastDelivery < THROTTLE_MS) return false
lastDelivery = now
}
if (!windowIsAway()) {
if (isOnScreen(candidate.sessionId, live.list)) return true
toasts.showToast(candidate)
recordDelivery('popup', candidate)
return true
}
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 true
}
// 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)
return true
}
/**
* 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.
*
* A pending interaction the flood guard holds back is put back in the queue
* instead of being dropped: only the user can clear it, so it stays owed, and
* the next tick retries it. Flood control still drops the completions it was
* written for.
*/
const flushSettle = () => {
settleTimer = 0
const candidate = pending.shift()
if (candidate !== undefined && stillOwed(candidate, live)) {
const delivered = deliver(candidate, false)
if (delivered === false && candidate.pendingKind !== '') pending.push(candidate)
}
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 }
}
+19
View File
@@ -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 */
}
}
+113
View File
@@ -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 }
}
+85
View File
@@ -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 }
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 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.
*
* The domains that can ask the user something are `@deepseek-ai/dsh-client-ui-approval`
* (a tool authorisation) and `@deepseek-ai/dsh-client-ui-user-questions` (a
* question batch, or the plan review its `planReviewOf` marks); each publishes a
* literal `kind` on its pending value. 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 owed, read from the LIVE snapshot rather
* than the one captured when the transition was seen.
*
* The two triggers are owed for different reasons, so they are checked for
* different things:
*
* - A **pending interaction** (approval, question, plan review) is owed until
* the user answers it. It is deliberately NOT gated on the run state: the
* session is `running` while it waits — the client's own `observeRunning`
* only mirrors `api-session/status` and says nothing about a pending request —
* so a running gate would silently drop every approval and question alert.
* - A **completion** is owed only while the conversation stayed idle. A
* conversation that resumed inside 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 stillOwed(candidate, live) {
if (candidate.test === true) return true
if (candidate.pendingKind !== '') {
const sessionStatus = live.status?.get?.(candidate.sessionId)
// No status entry contradicts the request, so it is still the user's move.
if (sessionStatus === undefined) return true
return servedPendingKind(sessionStatus.pendingInteraction) === candidate.pendingKind
}
const list = live.list
if (list === undefined) return true
const summary = list.byId?.[candidate.sessionId]
if (summary === undefined) return true
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running
return running !== true
}
+43
View File
@@ -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 */
}
}
+25
View File
@@ -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()
},
}
}
+112
View File
@@ -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 }
}
+80
View File
@@ -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 }
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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.watch': 'Session watch',
'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': 'Replay',
'config.diag.replayValue': '{count} waiting',
'config.diag.replayNone': 'None',
}
+73
View File
@@ -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
))
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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.watch': '会话监听',
'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': '待补发提醒',
'config.diag.replayValue': '{count} 条',
'config.diag.replayNone': '没有',
}
+25
View File
@@ -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'
+105
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><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" fill="none" stroke="%23247bbf" stroke-width="1.2"/><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" fill="none" stroke="%23247bbf" stroke-width="1.2" stroke-linecap="round"/></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'
}
+216
View File
@@ -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))
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 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.
*
* The page is laid out as three groups — what to notify about, the system
* channel's own state, and what the plugin is doing right now. Each group keeps
* its controls in one place: a list whose rows carry the subject on the left and
* the control on the right, and a fact list for the readings, so the status
* block is scannable instead of being four sentences in one cell.
*
* @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 promptFailed = snapshot.promptError !== ''
const permissionText = promptFailed ? snapshot.promptError : tr(statusKey)
const dotClass = permission === 'granted'
? ''
: failed ? ' is-error' : supported ? ' is-warn' : ' is-muted'
const permissionNote = promptFailed
? ''
: permission === 'denied' || !supported
? tr(`settings.permission.hint.${props.platform}`)
: permission === 'granted'
? tr('config.testHint')
: tr('settings.permission.hint.plain')
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'
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr)
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsn-page' },
// The page header already shows this bundle's title and description, so this
// lede says what the header cannot: how an alert travels, and that the
// channel is chosen at delivery time.
h('p', { className: 'dsn-lede' }, tr('config.intro')),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.kinds')),
h('div', { className: 'dsn-list' },
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-group-note' }, tr('config.saveNote'))),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.permission')),
h('div', { className: 'dsn-list' },
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('span', {
className: 'dsn-state',
role: failed ? 'alert' : 'status',
},
h('span', { className: `dsn-dot${dotClass}` }),
permissionText),
h('p', { className: 'dsn-row-sub' }, permissionNote)),
h('button', {
type: 'button',
className: `dsn-button${supported && permission !== 'granted' ? ' is-primary' : ''}`,
disabled: !supported,
onClick: permission === 'granted' ? props.testSystem : props.ask,
}, tr(!supported
? 'settings.action.unsupported'
: permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))))),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.status')),
h('dl', { className: 'dsn-facts' },
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.window')),
h('dd', { className: 'dsn-fact' },
h('p', { className: 'dsn-fact-value' }, tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront'))),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.watch')),
h('dd', { className: 'dsn-fact' },
h('p', {
className: `dsn-fact-value${healthFailed ? ' is-error' : ''}`,
role: healthFailed ? 'alert' : 'status',
}, healthText)),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.last')),
h('dd', { className: 'dsn-fact' },
delivery === null
? h('p', { className: 'dsn-fact-value' }, tr('config.diag.none'))
: [
h('p', { key: 'line', className: 'dsn-fact-value' }, `${delivery.at} · ${delivery.outcome}`),
delivery.title === ''
? null
: h('p', { key: 'title', className: 'dsn-fact-title' }, delivery.title),
]),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.replay')),
h('dd', { className: 'dsn-fact' },
h('p', { className: 'dsn-fact-value' }, props.copy.replayValue(props.replayCount(), tr)))),
h('div', { className: 'dsn-list' },
h('div', { className: 'dsn-row is-action' },
h('button', {
type: 'button',
className: 'dsn-button',
onClick: props.testAny,
}, tr('settings.action.testAny')))))))
}
+25
View File
@@ -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' })))
}
+42
View File
@@ -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
}
+62
View File
@@ -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 })))))))
}
+49
View File
@@ -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
}
+72
View File
@@ -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 })
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Every style declaration this package owns.
*
* Both surfaces are built out of the theme's own vocabulary and nothing else, so
* they follow the shell's light and dark modes without a branch of their own (the
* theme swaps every `--dsw-alias-*` under `body[data-ds-dark-theme]`):
*
* - The popup is a floating card, using the same recipe the shipped menus and
* modals use — `--dsw-alias-bg-layer-2` as the surface, the
* `--dsw-elevation-stroke-color` / `--dsw-elevation-prominent` pair for its
* hairline and shadow, `--dsw-alias-label-*` for text, and the shell's motion
* tokens. It deliberately does NOT use `--dsw-alias-toast-bg`, which is a fixed
* dark slate (#353638 in light mode) and would read as a black slab on a light
* page.
* - The settings page is a reading order, not a form dump: a lede that says how
* alerts travel, then one group per concern. Inside a group a title introduces
* a list whose rows put the subject on the left and the single control on the
* right, with hairlines only between rows; the status group is a two-column
* fact list, because four sentences stacked in one cell are not scannable.
*
* Class names are renamed under `dsn-`; only the popup glyphs carry their own
* artwork colors — and those are theme-aware state tokens too.
*
* A plugin must not import a Harness Client package, so the declarations are
* this package's own text. `tests/unit/styles.test.js` holds both invariants:
* tokens only, and no theme-fixed color.
*
* @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-bg-layer-2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;animation:dsn-toast-in var(--ds-transition-duration,.2s) var(--ds-ease-in-out,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-state-business-primary)}
.dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-label-secondary);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-alias-state-business-primary);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-label-tertiary);cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.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-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsn-group{display:flex;flex-direction:column;gap:2px}
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-list{display:flex;flex-direction:column}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)}
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 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-sub{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-state{display:inline-flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)}
.dsn-dot.is-warn{background:var(--dsw-alias-state-warn-label)}
.dsn-dot.is-error{background:var(--dsw-alias-state-error-primary)}
.dsn-dot.is-muted{background:var(--dsw-alias-border-l3)}
.dsn-facts{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px 14px;margin:0;min-width:0}
.dsn-fact-key{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap}
.dsn-fact{display:flex;flex-direction:column;gap:2px;margin:0;min-width:0}
.dsn-fact-value{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
.dsn-fact-value.is-error{color:var(--dsw-alias-state-error-primary)}
.dsn-fact-title{margin:0;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
.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:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;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}
.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}
.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88}
`
+157
View File
@@ -0,0 +1,157 @@
// The built artifact, exercised the way the page exercises it: the module table
// calls `window.__ModuleLoader__.load`, materializes the factory, and hands the
// result to Cordis. This runs under Node with stubs, which is exactly the point:
// the bundle may not need a DOM before its components render, and `require` may
// only ever ask for modules the module table actually holds.
//
// The freshness case is the guard against the one real maintenance hazard of
// this layout: editing src/client/** and committing without rebuilding.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const artifactPath = resolve(repoRoot, 'client.js')
const source = await readFile(artifactPath, 'utf8')
/** The smallest React the bundle can be handed: property access only. */
const reactStub = {
createElement: () => null,
Fragment: Symbol('Fragment'),
useCallback: (callback) => callback,
useEffect: () => {},
useSyncExternalStore: () => undefined,
}
/** Load the artifact against a stub module table and return its registration. */
function materialize(moduleTable = { react: reactStub }) {
let registration
const previous = globalThis.window
globalThis.window = { __ModuleLoader__: { load: (value) => { registration = value } } }
try {
// eslint-disable-next-line no-new-func -- executing the artifact IS the test
new Function(source)()
} finally {
globalThis.window = previous
}
assert.ok(registration, 'the bundle never registered a factory')
const requested = []
const exports = registration.factory((specifier) => {
requested.push(specifier)
if (!Object.hasOwn(moduleTable, specifier)) {
throw new Error(`the bundle asked the module table for "${specifier}"`)
}
return moduleTable[specifier]
})
return { registration, exports, requested }
}
/** The smallest client root context that lets `apply` run without a page. */
function fakeContext() {
const effects = []
const slots = []
const registrations = []
return {
effects,
slots,
registrations,
ctx: {
get: () => undefined,
effect: (callback, label) => {
effects.push(label)
return () => {}
},
slots: {
inject: (name, setup) => {
slots.push(name)
setup()
},
register: (definition, component) => {
registrations.push({ definition, component })
return { dispose() {} }
},
},
},
}
}
test('the artifact is a generated, self-contained script', () => {
assert.match(source.split('\n')[0], /^\/\/ Generated by scripts\/build-client\.mjs/)
assert.ok(!source.includes('\r'), 'the artifact must be LF-only (see .gitattributes)')
assert.ok(source.includes('window.__ModuleLoader__.load({'), 'missing the registration wrapper')
assert.ok(source.includes("id: '@dsh-plugin/session-notify',"), 'the registration id must be the package name')
assert.ok(source.trimEnd().endsWith('})'), 'the registration wrapper is not closed')
const factoryAt = source.indexOf('factory(require) {')
assert.ok(factoryAt > 0, 'the factory is missing')
const requires = [...source.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)]
assert.ok(requires.length > 0, 'the bundle never requires react')
for (const [whole, specifier] of requires) {
assert.equal(specifier, 'react', 'only platform seeds may be requested synchronously')
assert.ok(source.indexOf(whole) > factoryAt, 'require must sit inside the factory, or it would load eagerly')
}
assert.ok(!/^\s*(?:import|export)\s/m.test(source), 'a self-contained bundle has no top-level import/export')
})
test('materializing the factory yields the plugin the module table expects', () => {
const { registration, exports, requested } = materialize()
assert.equal(registration.id, '@dsh-plugin/session-notify')
assert.deepEqual(exports.inject, ['slots', 'locale'])
assert.equal(typeof exports.apply, 'function')
assert.deepEqual([...new Set(requested)], ['react'])
})
test('applying the plugin registers its three seats without touching the DOM', () => {
const { exports } = materialize()
const { ctx, effects, slots, registrations } = fakeContext()
exports.apply(ctx)
assert.deepEqual(effects, [
'session-notify: dictionaries',
'session-notify: window focus',
'session-notify: disposal',
])
assert.deepEqual(slots, ['shell.overlay', 'plugins.bundle.config', 'shell.overlay'])
assert.deepEqual(registrations.map(({ definition }) => definition.id ?? definition.key), [
'session-notify-toasts',
'@dsh-plugin/session-notify',
'session-notify-watch',
])
assert.deepEqual(registrations.map(({ definition }) => definition.order), [100, undefined, 101])
for (const { component } of registrations) assert.equal(typeof component, 'function')
const [toasts, config] = registrations
const toastProps = toasts.definition.inject()
assert.deepEqual(toastProps.store.getSnapshot().kinds, { completion: true, approval: true, question: true })
assert.equal(toastProps.store.getSnapshot().permission, 'unsupported')
assert.equal(toastProps.platform, undefined)
for (const operation of ['dismiss', 'arm', 'hold', 'open']) {
assert.equal(typeof toastProps[operation], 'function', `${operation} is not a function`)
}
assert.equal(typeof toastProps.fallbackT, 'function')
const configProps = config.definition.inject()
assert.equal(typeof configProps.ask, 'function')
assert.equal(typeof configProps.testAny, 'function')
assert.equal(typeof configProps.testSystem, 'function')
assert.equal(typeof configProps.setKind, 'function')
assert.equal(configProps.replayCount(), 0)
// No locale service in this fake context, so the plugin's own translator falls
// back to its English dictionary.
assert.equal(configProps.copy.deliveryFacts(null, configProps.fallbackT), null)
assert.equal(configProps.copy.replayValue(0, configProps.fallbackT), 'None')
})
test('the committed artifact is exactly what src/client builds', async (t) => {
const { buildClient } = await import('../scripts/build-client.mjs')
let built
try {
built = await buildClient({ write: false })
} catch (error) {
t.skip(`cannot rebuild without devDependencies: ${error.message}`)
return
}
assert.equal(built, source, 'client.js is stale — run `pnpm run build` and commit the result')
})
+320
View File
@@ -0,0 +1,320 @@
// The components, rendered through a tiny React double.
//
// Why a double instead of React: this repository ships no runtime dependency at
// all, and the whole point of these tests is the wiring — which component reads
// which prop through which seat. The double implements the four React entry
// points this package uses, so a component render is exercised end to end (hooks
// included) without a DOM or a real React.
//
// It runs against the BUILT artifact, because that is what the page loads.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const source = await readFile(resolve(repoRoot, 'client.js'), 'utf8')
/** The smallest React this package can run on, plus a way to run effects on demand. */
function createReactDouble({ runEffects = false } = {}) {
const pending = []
return {
pending,
React: {
Fragment: Symbol('Fragment'),
createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
useCallback: (callback) => callback,
useEffect: (effect) => {
pending.push(effect)
// Components here may only touch the DOM from their own effects, so this
// is opt-in: ToastLayer's escape-key effect needs a document.
if (runEffects) effect()
},
useSyncExternalStore: (subscribe, getSnapshot) => getSnapshot(),
},
}
}
/** Materialize the artifact, then install it, and hand back its three seats. */
function mount({ runEffects = false } = {}) {
const { React, pending } = createReactDouble({ runEffects })
let registration
const previous = globalThis.window
globalThis.window = { __ModuleLoader__: { load: (value) => { registration = value } } }
try {
// eslint-disable-next-line no-new-func -- executing the artifact IS the test
new Function(source)()
} finally {
globalThis.window = previous
}
assert.ok(registration, 'the bundle never registered a factory')
const exports = registration.factory((specifier) => {
assert.equal(specifier, 'react', 'only platform seeds may be requested')
return React
})
const effects = []
const registrations = []
exports.apply({
get: () => undefined,
effect: (callback, label) => {
effects.push(label)
return () => {}
},
slots: {
inject: (name, setup) => setup(),
register: (definition, component) => {
registrations.push({ definition, component })
return { dispose() {} }
},
},
})
const [toasts, config, observer] = registrations
return { toasts, config, observer, pending, effects }
}
/** Walk a rendered element tree, collecting text, host types and every element. */
function walk(node, collected = { text: [], types: [], elements: [] }) {
if (node === null || node === undefined || typeof node === 'boolean') return collected
if (Array.isArray(node)) {
for (const child of node) walk(child, collected)
return collected
}
if (typeof node === 'string' || typeof node === 'number') {
collected.text.push(String(node))
return collected
}
if (typeof node.type === 'function') {
walk(node.type(node.props), collected)
return collected
}
collected.types.push(node.type)
collected.elements.push(node)
// The double stores a `createElement` call's children on the element itself,
// exactly where React's own elements keep them.
walk(node.children, collected)
return collected
}
const flatten = (node) => walk(node).text.join(' ')
const classesOf = (rendered) => rendered.elements.map((element) => element.props.className ?? '')
/**
* The permission hint the plugin shows on this machine. Node exposes a
* `navigator` too, so the platform branch is exercised here rather than falling
* through to the neutral copy.
*/
const HINT_BY_PLATFORM = {
win32: 'Windows Settings → System → Notifications → DeepSeek Harness',
darwin: 'System Settings → Notifications → DeepSeek Harness',
linux: 'Allow DeepSeek Harness in your desktop notification settings (GNOME / KDE)',
}
const platformHint = HINT_BY_PLATFORM[process.platform] ?? 'Allow DeepSeek Harness in your system notification settings'
test('the plugin page reads as three groups with copy, not raw keys', () => {
const { config } = mount()
const rendered = walk(config.component({ ...config.definition.inject(), view: 'page' }))
const text = rendered.text.join(' ')
// No locale service in this context, so the seat falls back to English.
for (const copy of [
'Every alert comes from this page',
'What to notify about',
'The switches are stored in this browser',
'System notification permission',
'This environment cannot show system notifications',
platformHint,
'Status',
'Window',
'Session watch',
'Last delivery',
'nothing delivered yet',
'Replay',
'Test alert',
]) {
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
}
assert.ok(!text.includes('config.'), 'a raw translation key reached the page')
assert.ok(!text.includes('settings.'), 'a raw translation key reached the page')
const classes = classesOf(rendered)
for (const className of [
'dsn-page',
'dsn-lede',
'dsn-group',
'dsn-group-title',
'dsn-group-note',
'dsn-list',
'dsn-row',
'dsn-row-title',
'dsn-state',
'dsn-facts',
'dsn-fact-key',
'dsn-fact-value',
'dsn-button',
]) {
assert.ok(classes.includes(className), `the page is missing .${className}`)
}
// Three switches, one status dot, and the readings as key/value pairs.
assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3)
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1)
assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4)
assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4)
})
test('the page follows the store: switches, permission state and delivery facts', () => {
const { config } = mount()
const props = config.definition.inject()
props.store.set({
...props.store.getSnapshot(),
kinds: { completion: false, approval: true, question: true },
})
const switched = walk(config.component({ ...props, view: 'page' }))
assert.deepEqual(
switched.elements
.filter((element) => element.props.className === 'dsn-switch')
.map((element) => element.props['aria-checked']),
[false, true, true],
)
props.store.set({
...props.store.getSnapshot(),
permission: 'granted',
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
})
const granted = walk(config.component({ ...props, view: 'page' }))
const grantedClasses = classesOf(granted)
// A healthy permission has no warning colour and the button becomes the
// channel-specific test.
assert.equal(grantedClasses.includes('dsn-dot is-muted'), false)
assert.equal(grantedClasses.includes('dsn-dot is-error'), false)
assert.ok(grantedClasses.includes('dsn-dot'))
assert.ok(granted.text.join(' ').includes('Test system notification'))
assert.ok(granted.text.join(' ').includes('17:52:30 · system notification (confirmed on screen)'))
assert.ok(grantedClasses.includes('dsn-fact-title'))
props.store.set({
...props.store.getSnapshot(),
permission: 'denied',
lastDelivery: null,
})
const denied = walk(config.component({ ...props, view: 'page' }))
const deniedText = denied.text.join(' ')
assert.ok(classesOf(denied).includes('dsn-dot is-error'))
assert.ok(deniedText.includes('switched off in system settings'))
assert.ok(deniedText.includes(platformHint))
assert.ok(deniedText.includes('nothing delivered yet'))
})
test('the summary view is the one-line description the plugin row shows', () => {
const { config } = mount()
const text = flatten(config.component({ ...config.definition.inject(), view: 'summary' }))
assert.equal(text.trim(), '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.')
})
test('the popup stack renders nothing while empty, and one card per alert', () => {
const { toasts } = mount()
const props = toasts.definition.inject()
assert.equal(toasts.component(props), null)
props.store.set({
...props.store.getSnapshot(),
toasts: [{ id: 'dsn-1', kind: 'approval', title: 'Approval required', body: 'Conversation: Bash is waiting for your approval', sessionId: 'session-1' }],
})
const rendered = walk(toasts.component(props))
const text = rendered.text.join(' ')
assert.ok(text.includes('Approval required'))
assert.ok(text.includes('Bash is waiting for your approval'))
assert.ok(text.includes('View'))
const classes = classesOf(rendered)
for (const className of ['dsn-stack', 'dsn-toast', 'dsn-toast-icon is-approval', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-action', 'dsn-toast-close']) {
assert.ok(classes.includes(className), `the popup is missing .${className}`)
}
// The close control is a square hit area with an icon only, so its label is
// the only accessible name it has.
assert.equal(
rendered.elements.find((element) => element.props.className === 'dsn-toast-close').props['aria-label'],
'Dismiss',
)
// The stack announces itself politely, and an approval is an alert, not a status.
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-stack').props['aria-live'], 'polite')
assert.equal(rendered.elements.find((element) => element.props.className === 'dsn-toast').props.role, 'alert')
})
test('the popup buttons call back into the plugin, and the card pauses its own lifetime', () => {
const { toasts } = mount()
const called = []
const props = {
...toasts.definition.inject(),
dismiss: (id) => called.push(['dismiss', id]),
open: (sessionId) => called.push(['open', sessionId]),
arm: (id) => called.push(['arm', id]),
hold: (id) => called.push(['hold', id]),
}
props.store.set({
...props.store.getSnapshot(),
toasts: [{ id: 'dsn-7', kind: 'question', title: 'Answer needed', body: 'A · continue?', sessionId: 'session-9' }],
})
const rendered = walk(toasts.component(props))
const card = rendered.elements.find((element) => element.props.className === 'dsn-toast')
const [view, close] = rendered.elements.filter((element) => typeof element.props.onClick === 'function')
card.props.onMouseEnter()
card.props.onMouseLeave()
view.props.onClick()
close.props.onClick()
assert.deepEqual(called, [
['hold', 'dsn-7'],
['arm', 'dsn-7'],
['dismiss', 'dsn-7'],
['open', 'session-9'],
['dismiss', 'dsn-7'],
])
})
test('the observer reports its own health once the session hooks arrive', () => {
const { observer, toasts, effects } = mount({ runEffects: true })
const store = toasts.definition.inject().store
const observation = observer.definition.inject().observation
// Health changes that are not `watching` also reach the tagged console, so the
// diagnostic is captured and asserted instead of polluting the test output.
// Note which way round it fires: the observer logs when it recovers from a
// degraded state, and it logs the initial transition, but a healthy observer
// turning silent reaches the console only as the settings page's own error line.
const logged = []
const originalError = console.error
console.error = (message) => logged.push(String(message))
try {
observer.component({
observation,
useSessions: (select) => select({ byId: {} }),
useSessionStatus: (select) => select(new Map()),
})
assert.equal(store.getSnapshot().health.state, 'watching')
observer.component({ observation, useSessions: undefined, useSessionStatus: undefined })
assert.equal(store.getSnapshot().health.state, 'noHooks')
observer.component({
observation,
useSessions: (select) => select({ byId: {} }),
useSessionStatus: (select) => select(new Map()),
})
assert.equal(store.getSnapshot().health.state, 'watching')
} finally {
console.error = originalError
}
assert.equal(logged.length, 2, `expected two health diagnostics, got ${JSON.stringify(logged)}`)
for (const line of logged) assert.match(line, /session-notify: observer health: watching\b/)
// The observer owns no visible UI, and it is registered without a seat locale.
assert.equal(observer.definition.id, 'session-notify-watch')
assert.equal(observer.definition.order, 101)
assert.equal(observer.component({ observation }), null)
assert.deepEqual(effects, ['session-notify: dictionaries', 'session-notify: window focus', 'session-notify: disposal'])
})
+82
View File
@@ -0,0 +1,82 @@
// The copy table: what a candidate becomes on screen, and how the settings page
// describes the newest delivery. Assertions use the real Chinese dictionary, so
// a key rename in the dictionaries and a rename here cannot drift apart.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCopy } from '../../src/client/core/copy.js'
import { createTranslator } from '../../src/client/i18n/index.js'
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
const { copyFor, deliveryFacts, replayValue } = createCopy(t)
/** One trigger candidate with every field a caller may leave empty. */
function candidate(overrides) {
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides }
}
test('a finished conversation names the trigger as the title and the conversation as the body', () => {
assert.deepEqual(copyFor(candidate()), {
kind: 'completion',
title: '会话已完成',
body: '标题 已完成这一轮回答',
})
})
test('an empty title falls back to the untitled copy', () => {
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答')
})
test('an approval names the waiting tool when the client publishes one', () => {
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: 'Bash' }))
assert.deepEqual(notice, {
kind: 'approval',
title: '需要授权',
body: '标题:工具 Bash 正在等待你的授权',
})
})
test('an approval without a tool name still says what is happening', () => {
const notice = copyFor(candidate({ kind: 'approval', pendingKind: 'approval', detail: '' }))
assert.equal(notice.body, '标题:有工具正在等待你的授权')
})
test('a question carries the question text', () => {
assert.deepEqual(
copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' })),
{ kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' },
)
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
})
test('a plan review says what it is instead of repeating the plan text', () => {
// A plan review arrives as `kind: 'question'` with its own discriminator, so the
// discriminator has to be read first — otherwise the plan-review copy is unreachable.
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '计划正文…' }))
assert.equal(review.kind, 'question')
assert.equal(review.body, '标题:计划正在等待你确认')
assert.equal(review.title, '需要回答')
})
test('a delivery that never happened has no facts to show', () => {
assert.equal(deliveryFacts(null, t), null)
assert.equal(deliveryFacts(undefined, t), null)
})
test('the newest delivery is split into time, channel and what it was', () => {
assert.deepEqual(
deliveryFacts({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t),
{ at: '17:52:30', outcome: '系统通知(系统已确认弹出)', title: '会话已完成' },
)
assert.equal(deliveryFacts({ outcome: 'system', shown: undefined, at: '1:00:00', title: 'X' }, t).outcome, '系统通知(系统没有回报,回到窗口时补发轻弹窗)')
assert.equal(deliveryFacts({ outcome: 'system', shown: false, at: '1:00:00', title: 'X' }, t).outcome, '系统通知没弹出来,回到窗口时补发轻弹窗')
assert.equal(deliveryFacts({ outcome: 'system-refused-threw', at: '1:00:00', title: 'X' }, t).outcome, '系统通知被拒绝,改用了轻弹窗')
assert.equal(deliveryFacts({ outcome: 'replay', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗(回到窗口时补发)')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: undefined }, t).title, '')
})
test('the replay value is short enough for one status line', () => {
assert.equal(replayValue(0, t), '没有')
assert.equal(replayValue(3, t), '3 条')
})
+93
View File
@@ -0,0 +1,93 @@
// The dictionaries and the translator. The key sets are asserted equal because
// the translator falls back to whichever dictionary has a key: an asymmetric key
// would silently switch language instead of failing, and a key nobody reads is a
// defect this repository has removed once already.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createTranslator, dictionaries, registerDictionaries } from '../../src/client/i18n/index.js'
import { zh } from '../../src/client/i18n/zh.js'
import { en } from '../../src/client/i18n/en.js'
test('both dictionaries carry exactly the same keys', () => {
assert.deepEqual(Object.keys(zh).sort(), Object.keys(en).sort())
assert.deepEqual(Object.keys(dictionaries).sort(), ['en', 'zh'])
})
test('every key carries copy in both languages', () => {
for (const [key, value] of Object.entries(zh)) {
assert.equal(typeof value, 'string', `${key} is not a string in zh`)
assert.notEqual(value, '', `${key} is empty in zh`)
}
for (const [key, value] of Object.entries(en)) {
assert.equal(typeof value, 'string', `${key} is not a string in en`)
assert.notEqual(value, '', `${key} is empty in en`)
}
})
test('the seat translation wins, and a key it does not know falls back to the dictionary', () => {
const locale = {
bind: (namespace) => {
assert.equal(namespace, 'session-notify')
return (key, params) => (key === 'notification.completion' ? `seat:${params?.title ?? ''}` : key)
},
getSnapshot: () => ({ active: 'zh' }),
}
const t = createTranslator(locale)
assert.equal(t('notification.completion', { title: 'X' }), 'seat:X')
assert.equal(t('body.untitled'), '未命名会话')
})
test('the active language selects the dictionary, and an unknown key renders itself', () => {
const t = createTranslator({ bind: () => undefined, getSnapshot: () => ({ active: 'zh-CN' }) })
assert.equal(t('body.untitled'), '未命名会话')
const english = createTranslator(undefined)
assert.equal(english('body.untitled'), 'Untitled conversation')
assert.equal(english('nope.not.a.key'), 'nope.not.a.key')
})
test('interpolation replaces every known placeholder and leaves unknown ones alone', () => {
const t = createTranslator({ getSnapshot: () => ({ active: 'en' }) })
assert.equal(t('body.approval', { title: 'A', tool: 'Bash' }), 'A: Bash is waiting for your approval')
assert.equal(t('body.approval', { title: 'A' }), 'A: {tool} is waiting for your approval')
assert.equal(t('body.untitled', { unused: 'x' }), 'Untitled conversation')
})
test('a locale service that throws on every call keeps the copy renderable', () => {
const t = createTranslator({
bind: () => {
throw new Error('no such namespace')
},
getSnapshot: () => {
throw new Error('no snapshot')
},
})
assert.equal(t('settings.title'), 'Session notifications')
})
test('registration prefers the typed form and falls back to one call per locale', () => {
const typed = []
const disposeTyped = registerDictionaries({ register: (ns, dict) => { typed.push([ns, dict]); return () => {} } })
assert.deepEqual(typed, [['session-notify', dictionaries]])
assert.equal(typeof disposeTyped, 'function')
const single = []
let disposed = 0
const disposeSingle = registerDictionaries({
register: (ns, id, dict) => {
if (typeof id === 'object') throw new Error('typed form unsupported')
single.push([ns, id, dict])
return () => { disposed += 1 }
},
})
assert.deepEqual(single.map(([ns, id]) => [ns, id]), [['session-notify', 'zh'], ['session-notify', 'en']])
disposeSingle()
assert.equal(disposed, 2)
})
test('a profile without a locale service is not an error', () => {
assert.equal(typeof registerDictionaries(undefined), 'function')
assert.doesNotThrow(() => registerDictionaries(undefined)())
assert.equal(createTranslator(undefined)('body.untitled'), 'Untitled conversation')
})
+102
View File
@@ -0,0 +1,102 @@
// The pure derivations over the client's own session state: what counts as a
// served pending interaction, what a title or a preview becomes, which
// conversation is on screen, and whether a queued alert is still owed.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
isOnScreen,
preview,
questionText,
servedPendingKind,
stillOwed,
titleOf,
} from '../../src/client/core/session.js'
test('only the three interaction kinds the domains publish are served', () => {
// The literals the shipped domains publish: dsh-client-ui-approval sets
// `kind = "approval"`, dsh-client-ui-user-questions picks between `"question"`
// and `"plan-review"` for one batch.
assert.equal(servedPendingKind({ kind: 'approval' }), 'approval')
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
assert.equal(servedPendingKind({ kind: 'something-else' }), undefined)
assert.equal(servedPendingKind({ kind: 42 }), undefined)
assert.equal(servedPendingKind(undefined), undefined)
assert.equal(servedPendingKind(null), undefined)
assert.equal(servedPendingKind('approval'), undefined)
})
test('a preview collapses whitespace and never exceeds one popup line', () => {
assert.equal(preview(' a\n\t b '), 'a b')
assert.equal(preview(undefined), '')
assert.equal(preview('x'.repeat(80)), 'x'.repeat(80))
assert.equal(preview('x'.repeat(81)), `${'x'.repeat(79)}…`)
})
test('the first question text is read across every shape the client publishes', () => {
assert.equal(questionText({ questions: [{ question: '继续吗?' }] }), '继续吗?')
assert.equal(questionText({ questions: [{ text: '继续吗?' }] }), '继续吗?')
assert.equal(questionText({ question: '继续吗?' }), '继续吗?')
assert.equal(questionText({ prompt: '继续吗?' }), '继续吗?')
assert.equal(questionText({ displayReason: { text: '继续吗?' } }), '继续吗?')
assert.equal(questionText({ reason: { text: '继续吗?' } }), '继续吗?')
assert.equal(questionText({}), '')
assert.equal(questionText(null), '')
assert.equal(questionText('继续吗?'), '')
})
test('a session title falls back to its identity', () => {
assert.equal(titleOf({ title: ' 会话 A ' }, 'abcdef123456'), '会话 A')
assert.equal(titleOf({ title: '' }, 'abcdef123456'), 'abcdef12')
assert.equal(titleOf(undefined, 'abcdef123456'), 'abcdef12')
assert.equal(titleOf({ title: 42 }, 'abcdef123456'), 'abcdef12')
})
test('the conversation on screen is the one the main view retains', () => {
const list = { byId: { a: { retainedBy: { mainView: 1 } }, b: { retainedBy: { mainView: 0 } } } }
assert.equal(isOnScreen('a', list), true)
assert.equal(isOnScreen('b', list), false)
assert.equal(isOnScreen('c', list), false)
assert.equal(isOnScreen('a', undefined), false)
})
test('a pending interaction stays owed while it waits, even though the run is still running', () => {
// The session is `running` whenever the agent loop is active, and a pending
// approval or question is exactly that: the loop is waiting for the human. The
// run state must therefore not gate these alerts — it used to drop every one
// of them, which is why only completions ever reached the user.
const candidate = { kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: false }
const status = (pendingInteraction, running) => new Map([['a', { pendingInteraction, running }]])
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status({ kind: 'approval' }, true) }), true)
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: false } } }, status: status({ kind: 'approval' }, false) }), true)
// Answered inside the settle window: the request is gone, so nothing is owed.
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status(undefined, true) }), false)
// Replaced by a different request of the same kind: a new request is a new alert.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'question' }, { list: { byId: {} }, status: status({ kind: 'approval' }, true) }), false)
// A plan review is owed on its own discriminator.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'plan-review' }, { list: { byId: {} }, status: status({ kind: 'plan-review' }, true) }), true)
// Nothing contradicts the request, so it is still the user's move.
assert.equal(stillOwed(candidate, { list: { byId: {} }, status: new Map() }), true)
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
})
test('a completion is owed only while the conversation stayed idle', () => {
const candidate = { kind: 'completion', sessionId: 'a', pendingKind: '', test: false }
const live = (byId, status) => ({ list: { byId }, status })
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
assert.equal(stillOwed(candidate, live({ a: { running: false } })), true)
assert.equal(stillOwed(candidate, live({})), true)
assert.equal(stillOwed(candidate, live({ a: { running: true } })), false)
// The per-session selector wins over the list summary, which still carries the
// pre-transition flag.
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
})
test('a test alert is always owed', () => {
const live = { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: true }]]) }
assert.equal(stillOwed({ kind: 'completion', sessionId: 'a', pendingKind: '', test: true }, live), true)
assert.equal(stillOwed({ kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: true }, live), true)
})
+61
View File
@@ -0,0 +1,61 @@
// The browser-local trigger switches. Storage is foreign input: it can be
// absent, hold another version's shape, or refuse to write, and none of those
// may stop the plugin from working with its defaults.
import { afterEach, test } from 'node:test'
import assert from 'node:assert/strict'
import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js'
/** Install a storage double, optionally one that throws. */
function useStorage(behaviour) {
const data = new Map()
globalThis.localStorage = {
getItem: (key) => {
if (behaviour === 'read-throws') throw new Error('denied')
return data.has(key) ? data.get(key) : null
},
setItem: (key, value) => {
if (behaviour === 'write-throws') throw new Error('quota')
data.set(key, String(value))
},
}
return data
}
afterEach(() => {
delete globalThis.localStorage
})
test('everything is on when this browser has no stored choice', () => {
useStorage()
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
})
test('without storage at all the defaults still apply', () => {
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
})
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
const data = useStorage()
writeStoredKinds({ completion: false, approval: true, question: false })
assert.equal(data.get(STORAGE_KEY), JSON.stringify({ completion: false, approval: true, question: false }))
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
data.set(STORAGE_KEY, '{ not json')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
data.set(STORAGE_KEY, JSON.stringify({ completion: 'yes', approval: false }))
assert.deepEqual(readStoredKinds(), { completion: true, approval: false, question: true })
data.set(STORAGE_KEY, JSON.stringify({ somethingElse: false }))
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
})
test('storage that refuses to be read or written never throws', () => {
useStorage('read-throws')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
useStorage('write-throws')
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
})
+81
View File
@@ -0,0 +1,81 @@
// The stylesheet's two contracts, both of which the popup broke once:
//
// 1. it may only paint with theme tokens — a literal color cannot follow the
// shell's light/dark switch, and a `--dsw-static-*` token is fixed by design;
// 2. the popup must use the same floating-surface recipe as the shipped menus and
// modals (surface + hairline + elevation + label tokens), not the fixed dark
// `--dsw-alias-toast-bg` that made it a black slab on a light page.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { CSS } from '../../src/client/ui/styles.js'
/** Every `property:value` declaration in the sheet, selector-less. */
function declarations(sheet) {
return [...sheet.matchAll(/([a-z-]+)\s*:\s*([^;{}]+);/g)].map((match) => ({ property: match[1], value: match[2].trim() }))
}
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(|\b(?:white|black|red|green|blue|gray|grey)\b/
test('the stylesheet paints with tokens and never with a literal color', () => {
const properties = ['color', 'background', 'background-color', 'border', 'border-top', 'border-color', 'box-shadow', 'outline']
const painted = declarations(CSS).filter(({ property }) => properties.includes(property))
assert.ok(painted.length > 20, 'expected the sheet to carry its painted declarations')
for (const { property, value } of painted) {
// Keyword values carry no color of their own.
if (/^(none|transparent|inherit|currentColor)$/.test(value)) continue
assert.ok(!COLOR_LITERAL.test(value), `${property}: ${value} hardcodes a color instead of using a theme token`)
assert.ok(!value.includes('--dsw-static-'), `${property}: ${value} uses a theme-fixed static token that cannot follow dark mode`)
}
})
test('the popup is the shipped floating-card recipe, in both themes', () => {
const toast = CSS.match(/\.dsn-toast\{([^}]*)\}/)?.[1]
assert.ok(toast !== undefined, 'the popup rule is missing')
// The surface, the hairline and the shadow are what DSH's menus and modals use.
assert.ok(toast.includes('background:var(--dsw-alias-bg-layer-2)'), 'the popup must sit on the elevated layer surface')
assert.ok(toast.includes('--dsw-elevation-stroke-color:var(--dsw-alias-border-l1)'), 'the popup must take the menu hairline')
assert.ok(toast.includes('box-shadow:var(--dsw-elevation-prominent)'), 'the popup must take the prominent elevation')
assert.ok(toast.includes('color:var(--dsw-alias-label-primary)'), 'the popup text must be the primary label token')
assert.ok(toast.includes('border-radius:var(--dsw-radius-lg)'), 'the popup must take the large radius')
assert.ok(toast.includes('var(--ds-transition-duration') && toast.includes('var(--ds-ease-in-out'), 'the entrance must use the shell motion tokens')
// The one thing it must not do: the fixed dark toast slab, or any of its labels.
assert.ok(!toast.includes('--dsw-alias-toast-'), 'the popup must not use the theme-fixed toast surface')
assert.ok(!CSS.includes('--dsw-alias-toast-'), 'no rule may fall back to the fixed toast surface')
})
test('the popup reads as one card: title, body and both controls use label tokens', () => {
const rule = (name) => CSS.match(new RegExp(`\\.${name}\\{([^}]*)\\}`))?.[1] ?? ''
assert.ok(rule('dsn-toast-title').includes('var(--dsw-alias-label-primary)'))
assert.ok(rule('dsn-toast-desc').includes('var(--dsw-alias-label-secondary)'))
assert.ok(rule('dsn-toast-close').includes('color:var(--dsw-alias-label-tertiary)'))
assert.ok(rule('dsn-toast-action').includes('var(--dsw-alias-state-business-primary)'))
// Hover feedback comes from the shared interactive token, not a hand-rolled color.
assert.ok(rule('dsn-toast-action:hover').includes('var(--dsw-alias-interactive-bg-hover)'))
assert.ok(rule('dsn-toast-close:hover').includes('var(--dsw-alias-interactive-bg-hover)'))
})
test('the three popup glyphs stay distinguishable through state tokens', () => {
assert.ok(CSS.includes('.dsn-toast-icon{') && CSS.includes('var(--dsw-alias-state-warn-label)'))
assert.ok(CSS.includes('.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}'))
assert.ok(CSS.includes('.dsn-toast-icon.is-question{color:var(--dsw-alias-state-business-primary)}'))
})
test('every class the components render has a rule, so nothing falls back to defaults', () => {
// Guards the redesign: a renamed class that the sheet no longer styles would
// render as an unstyled block instead of failing loudly.
const expected = [
'dsn-stack', 'dsn-toast', 'dsn-toast-icon', 'dsn-toast-icon is-completion', 'dsn-toast-icon is-question',
'dsn-toast-text', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-actions', 'dsn-toast-action', 'dsn-toast-close',
'dsn-page', 'dsn-lede', 'dsn-group', 'dsn-group-title', 'dsn-group-note', 'dsn-list', 'dsn-row', 'dsn-row-text',
'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-facts', 'dsn-fact-key', 'dsn-fact', 'dsn-fact-value',
'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button',
]
for (const className of expected) {
const selector = `.${className.split(' ').join('.')}`
assert.ok(CSS.includes(`${selector}{`) || CSS.includes(`${selector}[`) || CSS.includes(`${selector}:`) || CSS.includes(`${selector}.`), `no rule for ${selector}`)
}
})