Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f89dd90e5 | ||
|
|
5efb311194 | ||
|
|
faa3930ae8 | ||
|
|
9218521dcb | ||
|
|
43875b991b | ||
|
|
8aa002b485 | ||
|
|
ac6963279c | ||
|
|
302e0a8bf5 | ||
|
|
ec8661b317 | ||
|
|
074cdccdb9 |
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。
|
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。
|
||||||
|
|
||||||
|
## v1.0.10
|
||||||
|
|
||||||
|
- 新增:**应用内轻弹窗的自动关闭时间可以在插件页设置**(3 / 6 / 10 / 30 秒,或**永不**)。原来固定 6 秒,现在:
|
||||||
|
- 插件页多了一组「轻弹窗」,选项就是它要设的值,选中的那一个是主色按钮样式(`role=radiogroup`,键盘可聚焦);
|
||||||
|
- 「永不」就是 `0`:卡片不再自己消失,要自己点 ✕ 或按 `Esc`。鼠标停在卡片上暂停计时的行为不变,同屏上限仍是 3 条,所以「永不」也不会越堆越多;
|
||||||
|
- 改这个值时,**屏幕上已有的卡片会按新值重新计时**(鼠标正停在上面那张除外,它要等你移开才开始计时),不会留着旧时间表继续走;
|
||||||
|
- 这个值与三个开关存在**同一份**浏览器本地记录里,各自的写入是合并式的:只存过开关的旧记录能正常升级,改开关也不会把关闭时间重置回去;取值只接受上列选项,别的值一律退回默认 6000ms。
|
||||||
|
- 调整:**「会话已完成」的提醒,描述行只显示会话题目**。标题已经写着「会话已完成」,描述再写一遍「已完成这一轮回答」只是重复;现在描述就是会话标题本身(没有标题的会话仍显示「未命名会话」)。字典里的 `body.completion` 随之删除。
|
||||||
|
- 顺带清掉两个死键:`settings.title`、`settings.kinds.label`(插件页早就改用 `config.section.*` 与 `settings.kind.*`,这两个键没有任何地方读)。现在中英各 62 个键,一一对应、没有死键。
|
||||||
|
- 测试从 44 条增加到 52 条:弹窗生命周期的 5 条(按时关闭 / 永不由时间关闭 / 悬停暂停后按新值续期 / 非法值退回默认 / 同屏上限),存储的合并与校验 3 条,插件页新选项的渲染与回写 1 条。
|
||||||
|
|
||||||
|
## 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
|
## v1.0.7
|
||||||
|
|
||||||
- 重构:**客户端源码拆成模块,`client.js` 从「手写文件」变成「构建产物」**。原来的 `client.js` 是 1452 行单文件,通知引擎、轻弹窗、插件页、观察器、字典、样式、图标全塞在一个 `apply` 里,改任何一处都要在同一个文件里上下翻。现在:
|
- 重构:**客户端源码拆成模块,`client.js` 从「手写文件」变成「构建产物」**。原来的 `client.js` 是 1452 行单文件,通知引擎、轻弹窗、插件页、观察器、字典、样式、图标全塞在一个 `apply` 里,改任何一处都要在同一个文件里上下翻。现在:
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
|||||||
轻弹窗的用法:
|
轻弹窗的用法:
|
||||||
|
|
||||||
- 点「查看」跳到该会话;
|
- 点「查看」跳到该会话;
|
||||||
- 6 秒后自动消失;鼠标停在上面期间不消失,移开后重新计时;
|
- **默认 6 秒**后自动消失(可在插件页改成 3 / 10 / 30 秒,或设成**永不关闭**);鼠标停在上面期间都不消失,移开后重新计时;
|
||||||
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
|
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
|
||||||
- 同屏最多叠 3 条,更早的会被挤掉。
|
- 同屏最多叠 3 条,更早的会被挤掉(即使设成永不关闭也一样,不会越堆越多);
|
||||||
|
- 轻弹窗是 DSH 的浮层卡片样式(和菜单、弹窗同一层底色与阴影),**跟随 DSH 的深色 / 浅色模式**。
|
||||||
|
|
||||||
## 设置
|
## 设置
|
||||||
|
|
||||||
@@ -46,8 +47,9 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
|||||||
| 分组 | 内容 |
|
| 分组 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
|
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
|
||||||
| 系统通知权限 | 当前权限状态;尚未授权时点「允许通知」请求授权(浏览器要求必须由你手动点击),授权成功后会自动发一条测试提醒。权限被系统层关闭时,会按当前平台告诉你去哪里打开 |
|
| 轻弹窗 | **自动关闭时间**:3 / 6 / 10 / 30 秒,或**永不**(永不时要自己点 ✕ 或按 `Esc`);鼠标停在卡片上时都不会消失 |
|
||||||
| 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、**最近一次提醒走的通道、系统是否确认弹出、时间**(本机时间),以及还有几条提醒在等你回到窗口时补发;旁边「测试提醒」按钮发一条测试提醒 |
|
| 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 |
|
||||||
|
| 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 |
|
||||||
|
|
||||||
- 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。
|
- 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。
|
||||||
- 开关只影响提醒,不改 DSH 本身的任何设置。
|
- 开关只影响提醒,不改 DSH 本身的任何设置。
|
||||||
@@ -59,10 +61,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
|||||||
在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
|
在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.7
|
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.10
|
||||||
```
|
```
|
||||||
|
|
||||||
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.7`。仓库是公开的,不需要凭据,也不用改「安装源」。
|
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.10`。仓库是公开的,不需要凭据,也不用改「安装源」。
|
||||||
|
|
||||||
### 本地路径安装
|
### 本地路径安装
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ D:\DeepSeek Harness Plugins\dsh-session-notify
|
|||||||
- **正在看的那个会话完成后不打扰**。
|
- **正在看的那个会话完成后不打扰**。
|
||||||
- **子智能体的会话不单独提醒**,等它归属的主会话结束时才提醒。
|
- **子智能体的会话不单独提醒**,等它归属的主会话结束时才提醒。
|
||||||
- **空白会话完成不提醒**。
|
- **空白会话完成不提醒**。
|
||||||
- **授权 / 提问如果出现的当下会话仍在运行,会先记下、等这一轮真正停下来再提醒**,不在模型还在跑的时候打扰你。
|
- **授权 / 提问会立刻提醒,不押后**:待处理请求只有你能清掉,而会话「正在等它」本来就是会话的状态(`running` 是宿主自己的另一条事实,和有没有待处理请求无关),所以不会因为「会话还在跑」而压后提醒;请求在 400ms 确认窗口内被你自己答掉,就不再提醒。
|
||||||
- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏。
|
- **节流**:1.5 秒内只投递一条,多个会话同时完成不会刷屏。
|
||||||
- **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。
|
- **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。
|
||||||
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。
|
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ var THROTTLE_MS = 1500;
|
|||||||
var SETTLE_MS = 400;
|
var SETTLE_MS = 400;
|
||||||
var TOAST_DURATION_MS = 6e3;
|
var TOAST_DURATION_MS = 6e3;
|
||||||
var TOAST_LIMIT = 3;
|
var TOAST_LIMIT = 3;
|
||||||
|
var DISMISS_CHOICES = [3e3, 6e3, 1e4, 3e4, 0];
|
||||||
var RAISED_LIMIT = 8;
|
var RAISED_LIMIT = 8;
|
||||||
var REPLAY_LIMIT = 8;
|
var REPLAY_LIMIT = 8;
|
||||||
var REPLAY_TTL_MS = 30 * 60 * 1e3;
|
var REPLAY_TTL_MS = 30 * 60 * 1e3;
|
||||||
@@ -64,7 +65,6 @@ var zh = {
|
|||||||
"notification.completion": "会话已完成",
|
"notification.completion": "会话已完成",
|
||||||
"notification.approval": "需要授权",
|
"notification.approval": "需要授权",
|
||||||
"notification.question": "需要回答",
|
"notification.question": "需要回答",
|
||||||
"body.completion": "{title} 已完成这一轮回答",
|
|
||||||
"body.approval": "{title}:工具 {tool} 正在等待你的授权",
|
"body.approval": "{title}:工具 {tool} 正在等待你的授权",
|
||||||
"body.approvalPlain": "{title}:有工具正在等待你的授权",
|
"body.approvalPlain": "{title}:有工具正在等待你的授权",
|
||||||
"body.question": "{title}:正在等待你的回答",
|
"body.question": "{title}:正在等待你的回答",
|
||||||
@@ -72,7 +72,6 @@ var zh = {
|
|||||||
"body.untitled": "未命名会话",
|
"body.untitled": "未命名会话",
|
||||||
"toast.view": "查看",
|
"toast.view": "查看",
|
||||||
"toast.dismiss": "关闭",
|
"toast.dismiss": "关闭",
|
||||||
"settings.title": "会话通知",
|
|
||||||
"settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。",
|
"settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。",
|
||||||
"settings.permission.granted": "系统通知已开启",
|
"settings.permission.granted": "系统通知已开启",
|
||||||
"settings.permission.default": "尚未授予系统通知权限",
|
"settings.permission.default": "尚未授予系统通知权限",
|
||||||
@@ -91,19 +90,27 @@ var zh = {
|
|||||||
"settings.health.noHooks": "未收到会话状态(通知不会触发)",
|
"settings.health.noHooks": "未收到会话状态(通知不会触发)",
|
||||||
"settings.health.error": "监听会话状态出错:{message}",
|
"settings.health.error": "监听会话状态出错:{message}",
|
||||||
"settings.health.starting": "正在等待会话列表",
|
"settings.health.starting": "正在等待会话列表",
|
||||||
"settings.kinds.label": "提醒内容",
|
|
||||||
"settings.kind.completion": "完成",
|
"settings.kind.completion": "完成",
|
||||||
"settings.kind.approval": "授权",
|
"settings.kind.approval": "授权",
|
||||||
"settings.kind.question": "提问",
|
"settings.kind.question": "提问",
|
||||||
|
"settings.dismiss.label": "自动关闭",
|
||||||
|
"settings.dismiss.3000": "3 秒",
|
||||||
|
"settings.dismiss.6000": "6 秒",
|
||||||
|
"settings.dismiss.10000": "10 秒",
|
||||||
|
"settings.dismiss.30000": "30 秒",
|
||||||
|
"settings.dismiss.0": "永不",
|
||||||
"config.section.kinds": "提醒内容",
|
"config.section.kinds": "提醒内容",
|
||||||
|
"config.section.popup": "轻弹窗",
|
||||||
"config.section.permission": "系统通知权限",
|
"config.section.permission": "系统通知权限",
|
||||||
"config.section.status": "运行状态",
|
"config.section.status": "运行状态",
|
||||||
"config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。",
|
"config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。",
|
||||||
"config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。",
|
"config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。",
|
||||||
|
"config.dismiss.note": "鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。",
|
||||||
"config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。",
|
"config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。",
|
||||||
"config.testAny": "测试提醒",
|
"config.testAny": "测试提醒",
|
||||||
"config.testSystem": "测试系统通知",
|
"config.testSystem": "测试系统通知",
|
||||||
"config.diag.window": "窗口状态",
|
"config.diag.window": "窗口状态",
|
||||||
|
"config.diag.watch": "会话监听",
|
||||||
"config.diag.inFront": "在前台(会走轻弹窗)",
|
"config.diag.inFront": "在前台(会走轻弹窗)",
|
||||||
"config.diag.away": "不在前台(会走系统通知)",
|
"config.diag.away": "不在前台(会走系统通知)",
|
||||||
"config.diag.last": "最近一次投递",
|
"config.diag.last": "最近一次投递",
|
||||||
@@ -114,8 +121,9 @@ var zh = {
|
|||||||
"config.diag.replayDelivery": "应用内轻弹窗(回到窗口时补发)",
|
"config.diag.replayDelivery": "应用内轻弹窗(回到窗口时补发)",
|
||||||
"config.diag.popup": "应用内轻弹窗",
|
"config.diag.popup": "应用内轻弹窗",
|
||||||
"config.diag.refused": "系统通知被拒绝,改用了轻弹窗",
|
"config.diag.refused": "系统通知被拒绝,改用了轻弹窗",
|
||||||
"config.diag.replay": "等你回到窗口时补发:{count} 条",
|
"config.diag.replay": "待补发提醒",
|
||||||
"config.diag.replayNone": "没有待补发的提醒"
|
"config.diag.replayValue": "{count} 条",
|
||||||
|
"config.diag.replayNone": "没有"
|
||||||
};
|
};
|
||||||
|
|
||||||
// src/client/i18n/en.js
|
// src/client/i18n/en.js
|
||||||
@@ -123,7 +131,6 @@ var en = {
|
|||||||
"notification.completion": "Conversation finished",
|
"notification.completion": "Conversation finished",
|
||||||
"notification.approval": "Approval required",
|
"notification.approval": "Approval required",
|
||||||
"notification.question": "Answer needed",
|
"notification.question": "Answer needed",
|
||||||
"body.completion": "{title} finished this round",
|
|
||||||
"body.approval": "{title}: {tool} is waiting for your approval",
|
"body.approval": "{title}: {tool} is waiting for your approval",
|
||||||
"body.approvalPlain": "{title}: a tool is waiting for your approval",
|
"body.approvalPlain": "{title}: a tool is waiting for your approval",
|
||||||
"body.question": "{title}: waiting for your answer",
|
"body.question": "{title}: waiting for your answer",
|
||||||
@@ -131,7 +138,6 @@ var en = {
|
|||||||
"body.untitled": "Untitled conversation",
|
"body.untitled": "Untitled conversation",
|
||||||
"toast.view": "View",
|
"toast.view": "View",
|
||||||
"toast.dismiss": "Dismiss",
|
"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.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.granted": "System notifications are on",
|
||||||
"settings.permission.default": "System notification permission has not been granted",
|
"settings.permission.default": "System notification permission has not been granted",
|
||||||
@@ -150,19 +156,27 @@ var en = {
|
|||||||
"settings.health.noHooks": "No session status received (notifications will not fire)",
|
"settings.health.noHooks": "No session status received (notifications will not fire)",
|
||||||
"settings.health.error": "Watching session status failed: {message}",
|
"settings.health.error": "Watching session status failed: {message}",
|
||||||
"settings.health.starting": "Waiting for the session list",
|
"settings.health.starting": "Waiting for the session list",
|
||||||
"settings.kinds.label": "Notify about",
|
|
||||||
"settings.kind.completion": "Finished",
|
"settings.kind.completion": "Finished",
|
||||||
"settings.kind.approval": "Approval",
|
"settings.kind.approval": "Approval",
|
||||||
"settings.kind.question": "Questions",
|
"settings.kind.question": "Questions",
|
||||||
|
"settings.dismiss.label": "Auto-close",
|
||||||
|
"settings.dismiss.3000": "3s",
|
||||||
|
"settings.dismiss.6000": "6s",
|
||||||
|
"settings.dismiss.10000": "10s",
|
||||||
|
"settings.dismiss.30000": "30s",
|
||||||
|
"settings.dismiss.0": "Never",
|
||||||
"config.section.kinds": "What to notify about",
|
"config.section.kinds": "What to notify about",
|
||||||
|
"config.section.popup": "Popup",
|
||||||
"config.section.permission": "System notification permission",
|
"config.section.permission": "System notification permission",
|
||||||
"config.section.status": "Status",
|
"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.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.saveNote": "The switches are stored in this browser and survive reinstalling the plugin.",
|
||||||
|
"config.dismiss.note": "Hovering a card pauses the countdown; with Never, close each one with ✕ or Esc.",
|
||||||
"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.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.testAny": "Test alert",
|
||||||
"config.testSystem": "Test system notification",
|
"config.testSystem": "Test system notification",
|
||||||
"config.diag.window": "Window",
|
"config.diag.window": "Window",
|
||||||
|
"config.diag.watch": "Session watch",
|
||||||
"config.diag.inFront": "in front (popup is used)",
|
"config.diag.inFront": "in front (popup is used)",
|
||||||
"config.diag.away": "not in front (system notification is used)",
|
"config.diag.away": "not in front (system notification is used)",
|
||||||
"config.diag.last": "Last delivery",
|
"config.diag.last": "Last delivery",
|
||||||
@@ -173,8 +187,9 @@ var en = {
|
|||||||
"config.diag.replayDelivery": "in-app popup (replayed when you came back)",
|
"config.diag.replayDelivery": "in-app popup (replayed when you came back)",
|
||||||
"config.diag.popup": "in-app popup",
|
"config.diag.popup": "in-app popup",
|
||||||
"config.diag.refused": "system channel refused, popup was used instead",
|
"config.diag.refused": "system channel refused, popup was used instead",
|
||||||
"config.diag.replay": "waiting for you to come back: {count}",
|
"config.diag.replay": "Replay",
|
||||||
"config.diag.replayNone": "nothing waiting to be replayed"
|
"config.diag.replayValue": "{count} waiting",
|
||||||
|
"config.diag.replayNone": "None"
|
||||||
};
|
};
|
||||||
|
|
||||||
// src/client/i18n/index.js
|
// src/client/i18n/index.js
|
||||||
@@ -304,24 +319,41 @@ function createStore(initial) {
|
|||||||
|
|
||||||
// src/client/core/storage.js
|
// src/client/core/storage.js
|
||||||
var STORAGE_KEY = "dsh-plugin/session-notify";
|
var STORAGE_KEY = "dsh-plugin/session-notify";
|
||||||
function readStoredKinds() {
|
function defaults() {
|
||||||
const kinds = { completion: true, approval: true, question: true };
|
return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS };
|
||||||
|
}
|
||||||
|
function readStored() {
|
||||||
|
const preferences = defaults();
|
||||||
try {
|
try {
|
||||||
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
|
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
|
||||||
if (typeof raw !== "string" || raw === "") return kinds;
|
if (typeof raw !== "string" || raw === "") return preferences;
|
||||||
const stored = JSON.parse(raw);
|
const stored = JSON.parse(raw);
|
||||||
for (const kind of KINDS) {
|
for (const kind of KINDS) {
|
||||||
if (typeof stored?.[kind] === "boolean") kinds[kind] = stored[kind];
|
if (typeof stored?.[kind] === "boolean") preferences[kind] = stored[kind];
|
||||||
}
|
}
|
||||||
|
if (DISMISS_CHOICES.includes(stored?.dismissAfterMs)) preferences.dismissAfterMs = stored.dismissAfterMs;
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return kinds;
|
return preferences;
|
||||||
|
}
|
||||||
|
function writeStored(patch) {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify({ ...readStored(), ...patch }));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function readStoredKinds() {
|
||||||
|
const { completion, approval, question } = readStored();
|
||||||
|
return { completion, approval, question };
|
||||||
}
|
}
|
||||||
function writeStoredKinds(kinds) {
|
function writeStoredKinds(kinds) {
|
||||||
try {
|
writeStored(kinds);
|
||||||
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds));
|
}
|
||||||
} catch {
|
function readStoredDismissMs() {
|
||||||
}
|
return readStored().dismissAfterMs;
|
||||||
|
}
|
||||||
|
function writeStoredDismissMs(ms) {
|
||||||
|
writeStored({ dismissAfterMs: ms });
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/client/core/copy.js
|
// src/client/core/copy.js
|
||||||
@@ -329,7 +361,10 @@ function createCopy(t) {
|
|||||||
const copyFor = (candidate) => {
|
const copyFor = (candidate) => {
|
||||||
const title = candidate.title === "" ? t("body.untitled") : candidate.title;
|
const title = candidate.title === "" ? t("body.untitled") : candidate.title;
|
||||||
if (candidate.kind === "completion") {
|
if (candidate.kind === "completion") {
|
||||||
return { kind: "completion", title: t("notification.completion"), body: t("body.completion", { title }) };
|
return { kind: "completion", title: t("notification.completion"), body: title };
|
||||||
|
}
|
||||||
|
if (candidate.pendingKind === "plan-review") {
|
||||||
|
return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) };
|
||||||
}
|
}
|
||||||
if (candidate.kind === "question") {
|
if (candidate.kind === "question") {
|
||||||
const question = candidate.detail;
|
const question = candidate.detail;
|
||||||
@@ -339,9 +374,6 @@ function createCopy(t) {
|
|||||||
body: question === "" ? t("body.question", { title }) : `${title} · ${question}`
|
body: question === "" ? t("body.question", { title }) : `${title} · ${question}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (candidate.pendingKind === "plan-review") {
|
|
||||||
return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) };
|
|
||||||
}
|
|
||||||
const tool = candidate.detail;
|
const tool = candidate.detail;
|
||||||
return {
|
return {
|
||||||
kind: "approval",
|
kind: "approval",
|
||||||
@@ -349,20 +381,26 @@ function createCopy(t) {
|
|||||||
body: tool === "" ? t("body.approvalPlain", { title }) : t("body.approval", { title, tool })
|
body: tool === "" ? t("body.approvalPlain", { title }) : t("body.approval", { title, tool })
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const describeDelivery = (last, tr) => {
|
const deliveryFacts = (last, tr) => {
|
||||||
if (last === null || last === void 0) return tr("config.diag.none");
|
if (last === null || last === void 0) 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");
|
const outcome = last.outcome.startsWith("system-refused") ? tr("config.diag.refused") : last.outcome === "system" ? last.shown === true ? tr("config.diag.systemShown") : last.shown === false ? tr("config.diag.systemFailed") : tr("config.diag.systemUnconfirmed") : last.outcome === "replay" ? tr("config.diag.replayDelivery") : tr("config.diag.popup");
|
||||||
return `${tr("config.diag.last")}: ${last.at} · ${outcome} · ${last.title}`;
|
return { at: last.at, outcome, title: last.title ?? "" };
|
||||||
};
|
};
|
||||||
const describeReplay = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replay", { count });
|
const replayValue = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replayValue", { count });
|
||||||
return { copyFor, describeDelivery, describeReplay };
|
return { copyFor, deliveryFacts, replayValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/client/core/toasts.js
|
// src/client/core/toasts.js
|
||||||
function createToastStack({ store, copy }) {
|
function createToastStack({ store, copy }) {
|
||||||
const timers = /* @__PURE__ */ new Map();
|
const timers = /* @__PURE__ */ new Map();
|
||||||
|
const held = /* @__PURE__ */ new Set();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
const lifetime = () => {
|
||||||
|
const configured = store.getSnapshot().dismissAfterMs;
|
||||||
|
return typeof configured === "number" && configured >= 0 ? configured : TOAST_DURATION_MS;
|
||||||
|
};
|
||||||
const closeToast = (id) => {
|
const closeToast = (id) => {
|
||||||
|
held.delete(id);
|
||||||
const timer = timers.get(id);
|
const timer = timers.get(id);
|
||||||
if (timer !== void 0) {
|
if (timer !== void 0) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -373,16 +411,26 @@ function createToastStack({ store, copy }) {
|
|||||||
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) });
|
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) });
|
||||||
};
|
};
|
||||||
const armToast = (id) => {
|
const armToast = (id) => {
|
||||||
|
held.delete(id);
|
||||||
const timer = timers.get(id);
|
const timer = timers.get(id);
|
||||||
if (timer !== void 0) clearTimeout(timer);
|
if (timer !== void 0) clearTimeout(timer);
|
||||||
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS));
|
timers.delete(id);
|
||||||
|
const duration = lifetime();
|
||||||
|
if (duration === 0) return;
|
||||||
|
timers.set(id, setTimeout(() => closeToast(id), duration));
|
||||||
};
|
};
|
||||||
const holdToast = (id) => {
|
const holdToast = (id) => {
|
||||||
|
held.add(id);
|
||||||
const timer = timers.get(id);
|
const timer = timers.get(id);
|
||||||
if (timer === void 0) return;
|
if (timer === void 0) return;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timers.delete(id);
|
timers.delete(id);
|
||||||
};
|
};
|
||||||
|
const rearmAll = () => {
|
||||||
|
for (const toast of store.getSnapshot().toasts) {
|
||||||
|
if (!held.has(toast.id)) armToast(toast.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
const showToast = (candidate) => {
|
const showToast = (candidate) => {
|
||||||
const notice = copy.copyFor(candidate);
|
const notice = copy.copyFor(candidate);
|
||||||
seq += 1;
|
seq += 1;
|
||||||
@@ -401,6 +449,7 @@ function createToastStack({ store, copy }) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timers.delete(dropped.id);
|
timers.delete(dropped.id);
|
||||||
}
|
}
|
||||||
|
held.delete(dropped.id);
|
||||||
}
|
}
|
||||||
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) });
|
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) });
|
||||||
armToast(toast.id);
|
armToast(toast.id);
|
||||||
@@ -408,8 +457,9 @@ function createToastStack({ store, copy }) {
|
|||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
for (const timer of timers.values()) clearTimeout(timer);
|
for (const timer of timers.values()) clearTimeout(timer);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
|
held.clear();
|
||||||
};
|
};
|
||||||
return { showToast, closeToast, armToast, holdToast, dispose };
|
return { showToast, closeToast, armToast, holdToast, rearmAll, dispose };
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/client/core/replay.js
|
// src/client/core/replay.js
|
||||||
@@ -548,13 +598,18 @@ function isOnScreen(sessionId, list) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function stillWorth(candidate, live) {
|
function stillOwed(candidate, live) {
|
||||||
|
if (candidate.test === true) return true;
|
||||||
|
if (candidate.pendingKind !== "") {
|
||||||
|
const sessionStatus = live.status?.get?.(candidate.sessionId);
|
||||||
|
if (sessionStatus === void 0) return true;
|
||||||
|
return servedPendingKind(sessionStatus.pendingInteraction) === candidate.pendingKind;
|
||||||
|
}
|
||||||
const list = live.list;
|
const list = live.list;
|
||||||
if (candidate.test === true || list === void 0) return true;
|
if (list === void 0) return true;
|
||||||
const summary = list.byId?.[candidate.sessionId];
|
const summary = list.byId?.[candidate.sessionId];
|
||||||
if (summary === void 0) return true;
|
if (summary === void 0) return true;
|
||||||
const status = live.status;
|
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running;
|
||||||
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running;
|
|
||||||
return running !== true;
|
return running !== true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,28 +637,32 @@ function createDelivery({ store, copy, live, toasts, system, replay }) {
|
|||||||
const deliver = (candidate, ignoreThrottle) => {
|
const deliver = (candidate, ignoreThrottle) => {
|
||||||
if (ignoreThrottle !== true) {
|
if (ignoreThrottle !== true) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastDelivery < THROTTLE_MS) return;
|
if (now - lastDelivery < THROTTLE_MS) return false;
|
||||||
lastDelivery = now;
|
lastDelivery = now;
|
||||||
}
|
}
|
||||||
if (!windowIsAway()) {
|
if (!windowIsAway()) {
|
||||||
if (isOnScreen(candidate.sessionId, live.list)) return;
|
if (isOnScreen(candidate.sessionId, live.list)) return true;
|
||||||
toasts.showToast(candidate);
|
toasts.showToast(candidate);
|
||||||
recordDelivery("popup", candidate);
|
recordDelivery("popup", candidate);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const result = system.notifySystem(candidate, candidate.test === true);
|
const result = system.notifySystem(candidate, candidate.test === true);
|
||||||
if (result.outcome === "raised") {
|
if (result.outcome === "raised") {
|
||||||
recordDelivery("system", candidate, result);
|
recordDelivery("system", candidate, result);
|
||||||
if (candidate.test !== true) replay.holdForReplay(candidate, result);
|
if (candidate.test !== true) replay.holdForReplay(candidate, result);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
toasts.showToast(candidate);
|
toasts.showToast(candidate);
|
||||||
recordDelivery(`system-refused-${result.outcome}`, candidate);
|
recordDelivery(`system-refused-${result.outcome}`, candidate);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
const flushSettle = () => {
|
const flushSettle = () => {
|
||||||
settleTimer = 0;
|
settleTimer = 0;
|
||||||
const candidate = pending.shift();
|
const candidate = pending.shift();
|
||||||
if (candidate !== void 0 && stillWorth(candidate, live)) deliver(candidate, false);
|
if (candidate !== void 0 && 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);
|
if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS);
|
||||||
};
|
};
|
||||||
const queue = (candidate) => {
|
const queue = (candidate) => {
|
||||||
@@ -745,7 +804,13 @@ function createActions({ store, t, delivery, system, toasts, publish }) {
|
|||||||
writeStoredKinds(kinds);
|
writeStoredKinds(kinds);
|
||||||
store.set({ ...snapshot, kinds });
|
store.set({ ...snapshot, kinds });
|
||||||
};
|
};
|
||||||
return { sendTest, sendTestSystem, requestPermission, setKindEnabled };
|
const setDismissAfter = (ms) => {
|
||||||
|
writeStoredDismissMs(ms);
|
||||||
|
const snapshot = store.getSnapshot();
|
||||||
|
store.set({ ...snapshot, dismissAfterMs: ms });
|
||||||
|
toasts.rearmAll();
|
||||||
|
};
|
||||||
|
return { sendTest, sendTestSystem, requestPermission, setKindEnabled, setDismissAfter };
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/client/ui/ToastLayer.js
|
// src/client/ui/ToastLayer.js
|
||||||
@@ -878,33 +943,50 @@ function seatTranslator(props, fallback) {
|
|||||||
var CSS = `
|
var CSS = `
|
||||||
.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
|
.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}}
|
@media (max-width:520px){.dsn-stack{right:12px}}
|
||||||
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out}
|
.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)}}
|
@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}}
|
@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{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-completion{color:var(--dsw-alias-state-success-primary)}
|
||||||
.dsn-toast-icon.is-question{color:var(--dsw-alias-brand-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-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||||
.dsn-toast-title{font-weight:500;font-size:14px;line-height:22px}
|
.dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
|
||||||
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word}
|
.dsn-toast-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-actions{display:flex;align-items:center;gap:2px;flex:none}
|
||||||
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-static-deepseek-400);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
|
.dsn-toast-action{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: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-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer}
|
.dsn-toast-close{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);opacity:1}
|
.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-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
.dsn-sections{display:flex;flex-direction:column;gap:24px}
|
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
|
||||||
.dsn-section{display:flex;flex-direction:column;gap:10px}
|
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
||||||
.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
|
.dsn-group{display:flex;flex-direction:column;gap:2px}
|
||||||
.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
|
||||||
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
||||||
.dsn-row:last-child{border-bottom:0}
|
.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-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-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
|
||||||
.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
.dsn-chips{display:flex;align-items:center;gap:6px;flex:none}
|
||||||
.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
.dsn-chip{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;min-width:46px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;line-height:18px;cursor:pointer}
|
||||||
.dsn-status-error{color:var(--dsw-alias-state-error-primary)}
|
.dsn-chip:hover:not([aria-checked='true']){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsn-chip[aria-checked='true']{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);cursor:default}
|
||||||
|
.dsn-chip:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
|
.dsn-row-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{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[aria-checked='true']{background:var(--dsw-alias-brand-primary)}
|
||||||
.dsn-switch:disabled{cursor:default;opacity:.5}
|
.dsn-switch:disabled{cursor:default;opacity:.5}
|
||||||
@@ -912,10 +994,12 @@ var CSS = `
|
|||||||
.dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease}
|
.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='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)}
|
||||||
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
|
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
|
||||||
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
|
.dsn-button{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:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
.dsn-button:disabled{cursor:not-allowed;opacity:.4}
|
.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: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}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// src/client/ui/ToastLayer.js
|
// src/client/ui/ToastLayer.js
|
||||||
@@ -986,12 +1070,12 @@ function ToastLayer(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/client/ui/ConfigSection.js
|
// src/client/ui/ConfigSection.js
|
||||||
var React5 = __toESM(require("react"), 1);
|
var React6 = __toESM(require("react"), 1);
|
||||||
|
|
||||||
// src/client/ui/KindRow.js
|
// src/client/ui/DismissRow.js
|
||||||
var React4 = __toESM(require("react"), 1);
|
var React4 = __toESM(require("react"), 1);
|
||||||
var h3 = React4.createElement;
|
var h3 = React4.createElement;
|
||||||
function KindRow(props) {
|
function DismissRow(props) {
|
||||||
return h3(
|
return h3(
|
||||||
"div",
|
"div",
|
||||||
{ className: "dsn-row" },
|
{ className: "dsn-row" },
|
||||||
@@ -1000,7 +1084,34 @@ function KindRow(props) {
|
|||||||
{ className: "dsn-row-text" },
|
{ className: "dsn-row-text" },
|
||||||
h3("div", { className: "dsn-row-title" }, props.label)
|
h3("div", { className: "dsn-row-title" }, props.label)
|
||||||
),
|
),
|
||||||
h3("button", {
|
h3(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-chips", role: "radiogroup", "aria-label": props.label },
|
||||||
|
props.options.map((option) => h3("button", {
|
||||||
|
key: String(option.value),
|
||||||
|
type: "button",
|
||||||
|
role: "radio",
|
||||||
|
className: "dsn-chip",
|
||||||
|
"aria-checked": option.value === props.value,
|
||||||
|
onClick: () => props.onChange(option.value)
|
||||||
|
}, option.label))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/client/ui/KindRow.js
|
||||||
|
var React5 = __toESM(require("react"), 1);
|
||||||
|
var h4 = React5.createElement;
|
||||||
|
function KindRow(props) {
|
||||||
|
return h4(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-row" },
|
||||||
|
h4(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-row-text" },
|
||||||
|
h4("div", { className: "dsn-row-title" }, props.label)
|
||||||
|
),
|
||||||
|
h4("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
role: "switch",
|
role: "switch",
|
||||||
className: "dsn-switch",
|
className: "dsn-switch",
|
||||||
@@ -1008,99 +1119,155 @@ function KindRow(props) {
|
|||||||
"aria-label": props.label,
|
"aria-label": props.label,
|
||||||
disabled: props.disabled,
|
disabled: props.disabled,
|
||||||
onClick: props.onToggle
|
onClick: props.onToggle
|
||||||
}, h3("span", { className: "dsn-thumb" }))
|
}, h4("span", { className: "dsn-thumb" }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/client/ui/ConfigSection.js
|
// src/client/ui/ConfigSection.js
|
||||||
var h4 = React5.createElement;
|
var h5 = React6.createElement;
|
||||||
function ConfigSection(props) {
|
function ConfigSection(props) {
|
||||||
const snapshot = useOwnStore(props.store);
|
const snapshot = useOwnStore(props.store);
|
||||||
const tr = seatTranslator(props, props.fallbackT);
|
const tr = seatTranslator(props, props.fallbackT);
|
||||||
if (props.view === "summary") return h4(React5.Fragment, null, tr("settings.description"));
|
if (props.view === "summary") return h5(React6.Fragment, null, tr("settings.description"));
|
||||||
const permission = snapshot.permission;
|
const permission = snapshot.permission;
|
||||||
const supported = permission !== "unsupported";
|
const supported = permission !== "unsupported";
|
||||||
const failed = permission === "denied" || snapshot.promptError !== "";
|
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 statusKey = permission === "granted" ? "settings.permission.granted" : permission === "denied" ? "settings.permission.denied" : supported ? "settings.permission.default" : "settings.permission.unsupported";
|
||||||
const permissionText = snapshot.promptError === "" ? `${tr(statusKey)}${permission === "denied" || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ""}` : snapshot.promptError;
|
const 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 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 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 healthFailed = health.state !== "watching" && health.state !== "starting";
|
||||||
return h4(
|
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr);
|
||||||
React5.Fragment,
|
return h5(
|
||||||
|
React6.Fragment,
|
||||||
null,
|
null,
|
||||||
h4("style", null, CSS),
|
h5("style", null, CSS),
|
||||||
h4(
|
h5(
|
||||||
"div",
|
"div",
|
||||||
{ className: "dsn-sections" },
|
{ className: "dsn-page" },
|
||||||
// The page header already shows this bundle's title and description, so
|
// The page header already shows this bundle's title and description, so this
|
||||||
// this section opens with what the header cannot say: how alerts are
|
// lede says what the header cannot: how an alert travels, and that the
|
||||||
// carried, and what this form changes.
|
// channel is chosen at delivery time.
|
||||||
h4(
|
h5("p", { className: "dsn-lede" }, tr("config.intro")),
|
||||||
|
h5(
|
||||||
"section",
|
"section",
|
||||||
{ className: "dsn-section" },
|
{ className: "dsn-group" },
|
||||||
h4("p", { className: "dsn-section-desc" }, tr("config.intro"))
|
h5("h3", { className: "dsn-group-title" }, tr("config.section.kinds")),
|
||||||
),
|
h5(
|
||||||
h4(
|
|
||||||
"section",
|
|
||||||
{ className: "dsn-section" },
|
|
||||||
h4("h3", { className: "dsn-section-title" }, tr("config.section.kinds")),
|
|
||||||
KINDS.map((kind) => h4(KindRow, {
|
|
||||||
key: kind,
|
|
||||||
label: tr(`settings.kind.${kind}`),
|
|
||||||
checked: snapshot.kinds[kind] === true,
|
|
||||||
disabled: false,
|
|
||||||
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true)
|
|
||||||
})),
|
|
||||||
h4("p", { className: "dsn-section-desc" }, tr("config.saveNote"))
|
|
||||||
),
|
|
||||||
h4(
|
|
||||||
"section",
|
|
||||||
{ className: "dsn-section" },
|
|
||||||
h4("h3", { className: "dsn-section-title" }, tr("config.section.permission")),
|
|
||||||
h4(
|
|
||||||
"div",
|
"div",
|
||||||
{ className: "dsn-row" },
|
{ className: "dsn-list" },
|
||||||
h4(
|
KINDS.map((kind) => h5(KindRow, {
|
||||||
"div",
|
key: kind,
|
||||||
{ className: "dsn-row-text" },
|
label: tr(`settings.kind.${kind}`),
|
||||||
h4("p", {
|
checked: snapshot.kinds[kind] === true,
|
||||||
className: `dsn-status${failed ? " dsn-status-error" : ""}`,
|
disabled: false,
|
||||||
role: failed ? "alert" : "status"
|
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true)
|
||||||
}, permissionText)
|
}))
|
||||||
),
|
|
||||||
h4("button", {
|
|
||||||
type: "button",
|
|
||||||
className: "dsn-button",
|
|
||||||
disabled: !supported,
|
|
||||||
onClick: permission === "granted" ? props.testSystem : props.ask
|
|
||||||
}, tr(!supported ? "settings.action.unsupported" : permission === "granted" ? "settings.action.testSystem" : "settings.action.allow"))
|
|
||||||
),
|
),
|
||||||
h4("p", { className: "dsn-section-desc" }, tr("config.testHint"))
|
h5("p", { className: "dsn-group-note" }, tr("config.saveNote"))
|
||||||
),
|
),
|
||||||
h4(
|
h5(
|
||||||
"section",
|
"section",
|
||||||
{ className: "dsn-section" },
|
{ className: "dsn-group" },
|
||||||
h4("h3", { className: "dsn-section-title" }, tr("config.section.status")),
|
h5("h3", { className: "dsn-group-title" }, tr("config.section.popup")),
|
||||||
h4(
|
h5(
|
||||||
"div",
|
"div",
|
||||||
{ className: "dsn-row" },
|
{ className: "dsn-list" },
|
||||||
h4(
|
h5(DismissRow, {
|
||||||
|
label: tr("settings.dismiss.label"),
|
||||||
|
options: DISMISS_CHOICES.map((value) => ({ value, label: tr(`settings.dismiss.${value}`) })),
|
||||||
|
value: snapshot.dismissAfterMs,
|
||||||
|
onChange: props.setDismiss
|
||||||
|
})
|
||||||
|
),
|
||||||
|
h5("p", { className: "dsn-group-note" }, tr("config.dismiss.note"))
|
||||||
|
),
|
||||||
|
h5(
|
||||||
|
"section",
|
||||||
|
{ className: "dsn-group" },
|
||||||
|
h5("h3", { className: "dsn-group-title" }, tr("config.section.permission")),
|
||||||
|
h5(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-list" },
|
||||||
|
h5(
|
||||||
"div",
|
"div",
|
||||||
{ className: "dsn-row-text" },
|
{ className: "dsn-row" },
|
||||||
h4("p", { className: "dsn-status" }, `${tr("config.diag.window")}: ${tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")}`),
|
h5(
|
||||||
h4("p", {
|
"div",
|
||||||
className: `dsn-status${healthFailed ? " dsn-status-error" : ""}`,
|
{ className: "dsn-row-text" },
|
||||||
role: healthFailed ? "alert" : "status"
|
h5(
|
||||||
}, healthText),
|
"span",
|
||||||
h4("p", { className: "dsn-status" }, props.copy.describeDelivery(snapshot.lastDelivery, tr)),
|
{
|
||||||
h4("p", { className: "dsn-status" }, props.copy.describeReplay(props.replayCount(), tr))
|
className: "dsn-state",
|
||||||
|
role: failed ? "alert" : "status"
|
||||||
|
},
|
||||||
|
h5("span", { className: `dsn-dot${dotClass}` }),
|
||||||
|
permissionText
|
||||||
|
),
|
||||||
|
h5("p", { className: "dsn-row-sub" }, permissionNote)
|
||||||
|
),
|
||||||
|
h5("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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h5(
|
||||||
|
"section",
|
||||||
|
{ className: "dsn-group" },
|
||||||
|
h5("h3", { className: "dsn-group-title" }, tr("config.section.status")),
|
||||||
|
h5(
|
||||||
|
"dl",
|
||||||
|
{ className: "dsn-facts" },
|
||||||
|
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.window")),
|
||||||
|
h5(
|
||||||
|
"dd",
|
||||||
|
{ className: "dsn-fact" },
|
||||||
|
h5("p", { className: "dsn-fact-value" }, tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront"))
|
||||||
),
|
),
|
||||||
h4("button", {
|
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")),
|
||||||
type: "button",
|
h5(
|
||||||
className: "dsn-button",
|
"dd",
|
||||||
onClick: props.testAny
|
{ className: "dsn-fact" },
|
||||||
}, tr("settings.action.testAny"))
|
h5("p", {
|
||||||
|
className: `dsn-fact-value${healthFailed ? " is-error" : ""}`,
|
||||||
|
role: healthFailed ? "alert" : "status"
|
||||||
|
}, healthText)
|
||||||
|
),
|
||||||
|
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.last")),
|
||||||
|
h5(
|
||||||
|
"dd",
|
||||||
|
{ className: "dsn-fact" },
|
||||||
|
delivery === null ? h5("p", { className: "dsn-fact-value" }, tr("config.diag.none")) : [
|
||||||
|
h5("p", { key: "line", className: "dsn-fact-value" }, `${delivery.at} · ${delivery.outcome}`),
|
||||||
|
delivery.title === "" ? null : h5("p", { key: "title", className: "dsn-fact-title" }, delivery.title)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")),
|
||||||
|
h5(
|
||||||
|
"dd",
|
||||||
|
{ className: "dsn-fact" },
|
||||||
|
h5("p", { className: "dsn-fact-value" }, props.copy.replayValue(props.replayCount(), tr))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h5(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-list" },
|
||||||
|
h5(
|
||||||
|
"div",
|
||||||
|
{ className: "dsn-row is-action" },
|
||||||
|
h5("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "dsn-button",
|
||||||
|
onClick: props.testAny
|
||||||
|
}, tr("settings.action.testAny"))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1108,15 +1275,15 @@ function ConfigSection(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/client/ui/NotifyObserver.js
|
// src/client/ui/NotifyObserver.js
|
||||||
var React6 = __toESM(require("react"), 1);
|
var React7 = __toESM(require("react"), 1);
|
||||||
function NotifyObserver(props) {
|
function NotifyObserver(props) {
|
||||||
useWindowState();
|
useWindowState();
|
||||||
const useSessions = props.useSessions;
|
const useSessions = props.useSessions;
|
||||||
const useSessionStatus = props.useSessionStatus;
|
const useSessionStatus = props.useSessionStatus;
|
||||||
const select = React6.useCallback((snapshot) => snapshot, []);
|
const select = React7.useCallback((snapshot) => snapshot, []);
|
||||||
const list = typeof useSessions === "function" ? useSessions(select) : void 0;
|
const list = typeof useSessions === "function" ? useSessions(select) : void 0;
|
||||||
const status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0;
|
const status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0;
|
||||||
React6.useEffect(() => {
|
React7.useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") {
|
if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") {
|
||||||
props.observation.reportHealth({ state: "noHooks", message: "" });
|
props.observation.reportHealth({ state: "noHooks", message: "" });
|
||||||
@@ -1143,6 +1310,7 @@ function apply(ctx) {
|
|||||||
const store = createStore({
|
const store = createStore({
|
||||||
permission: notificationPermission(),
|
permission: notificationPermission(),
|
||||||
kinds: readStoredKinds(),
|
kinds: readStoredKinds(),
|
||||||
|
dismissAfterMs: readStoredDismissMs(),
|
||||||
toasts: [],
|
toasts: [],
|
||||||
promptError: "",
|
promptError: "",
|
||||||
health: { state: "starting", message: "" },
|
health: { state: "starting", message: "" },
|
||||||
@@ -1245,7 +1413,8 @@ function apply(ctx) {
|
|||||||
ask: actions.requestPermission,
|
ask: actions.requestPermission,
|
||||||
testAny: actions.sendTest,
|
testAny: actions.sendTest,
|
||||||
testSystem: actions.sendTestSystem,
|
testSystem: actions.sendTestSystem,
|
||||||
setKind: actions.setKindEnabled
|
setKind: actions.setKindEnabled,
|
||||||
|
setDismiss: actions.setDismissAfter
|
||||||
})
|
})
|
||||||
}, ConfigSection));
|
}, ConfigSection));
|
||||||
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dsh-plugin/session-notify",
|
"name": "@dsh-plugin/session-notify",
|
||||||
"version": "1.0.7",
|
"version": "1.0.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
|
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
|
||||||
|
|||||||
@@ -28,10 +28,17 @@ export const THROTTLE_MS = 1500
|
|||||||
/** Settle delay before a completion is delivered, so a resumed run stays silent. */
|
/** Settle delay before a completion is delivered, so a resumed run stays silent. */
|
||||||
export const SETTLE_MS = 400
|
export const SETTLE_MS = 400
|
||||||
|
|
||||||
/** In-app popup lifetime, and how many may stack. */
|
/** Default in-app popup lifetime, and how many may stack. */
|
||||||
export const TOAST_DURATION_MS = 6000
|
export const TOAST_DURATION_MS = 6000
|
||||||
export const TOAST_LIMIT = 3
|
export const TOAST_LIMIT = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The popup lifetimes the settings page offers, in milliseconds, in the order it
|
||||||
|
* shows them. `0` is the "never auto-close" choice: such a card stays until the
|
||||||
|
* user dismisses it (✕, `Esc`, or a newer card pushing it out).
|
||||||
|
*/
|
||||||
|
export const DISMISS_CHOICES = [3000, 6000, 10000, 30000, 0]
|
||||||
|
|
||||||
/** Notifications kept referenced, so a collection can never cancel a pending display. */
|
/** Notifications kept referenced, so a collection can never cancel a pending display. */
|
||||||
export const RAISED_LIMIT = 8
|
export const RAISED_LIMIT = 8
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { notificationApi, notificationPermission } from '../platform.js'
|
import { notificationApi, notificationPermission } from '../platform.js'
|
||||||
import { writeStoredKinds } from './storage.js'
|
import { writeStoredDismissMs, writeStoredKinds } from './storage.js'
|
||||||
import { text } from './log.js'
|
import { text } from './log.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,5 +83,20 @@ export function createActions({ store, t, delivery, system, toasts, publish }) {
|
|||||||
store.set({ ...snapshot, kinds })
|
store.set({ ...snapshot, kinds })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sendTest, sendTestSystem, requestPermission, setKindEnabled }
|
/**
|
||||||
|
* Remember how long an in-app popup lives.
|
||||||
|
*
|
||||||
|
* The stack reads this at the moment it arms a card, and the cards already on
|
||||||
|
* screen are re-armed so the new choice applies to them too — except a card the
|
||||||
|
* pointer is resting on, which nothing may close.
|
||||||
|
* @param ms - milliseconds, or `0` for "never auto-close".
|
||||||
|
*/
|
||||||
|
const setDismissAfter = (ms) => {
|
||||||
|
writeStoredDismissMs(ms)
|
||||||
|
const snapshot = store.getSnapshot()
|
||||||
|
store.set({ ...snapshot, dismissAfterMs: ms })
|
||||||
|
toasts.rearmAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sendTest, sendTestSystem, requestPermission, setKindEnabled, setDismissAfter }
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-19
@@ -8,14 +8,24 @@
|
|||||||
/**
|
/**
|
||||||
* Build the copy table for one translate function.
|
* Build the copy table for one translate function.
|
||||||
* @param t - the translate function every string here goes through.
|
* @param t - the translate function every string here goes through.
|
||||||
* @returns `copyFor` (candidate → notice) and the two diagnosis renderers.
|
* @returns `copyFor` (candidate → notice), `deliveryFacts` and `replayValue`
|
||||||
|
* (the two status values the settings page shows).
|
||||||
*/
|
*/
|
||||||
export function createCopy(t) {
|
export function createCopy(t) {
|
||||||
/** Build the delivered copy for one candidate. */
|
/** Build the delivered copy for one candidate. */
|
||||||
const copyFor = (candidate) => {
|
const copyFor = (candidate) => {
|
||||||
const title = candidate.title === '' ? t('body.untitled') : candidate.title
|
const title = candidate.title === '' ? t('body.untitled') : candidate.title
|
||||||
if (candidate.kind === 'completion') {
|
if (candidate.kind === 'completion') {
|
||||||
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
|
// The heading already says what happened ("conversation finished"), so the
|
||||||
|
// description is the conversation itself — naming it twice would only make
|
||||||
|
// the card longer.
|
||||||
|
return { kind: 'completion', title: t('notification.completion'), body: 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') {
|
if (candidate.kind === 'question') {
|
||||||
const question = candidate.detail
|
const question = candidate.detail
|
||||||
@@ -25,15 +35,6 @@ export function createCopy(t) {
|
|||||||
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
|
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Kept in v1.0.6's order, which makes this branch unreachable today: the
|
|
||||||
// observer queues a plan review as `kind: 'question'`, so the branch above
|
|
||||||
// already answered with the plan's own text. Restoring the documented copy
|
|
||||||
// (`body.planReview`, "计划正在等待你确认") means testing `pendingKind`
|
|
||||||
// before `kind` — a user-visible behavior change, so it is deliberately NOT
|
|
||||||
// part of this refactor. `tests/unit/copy.test.js` pins today's output.
|
|
||||||
if (candidate.pendingKind === 'plan-review') {
|
|
||||||
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
|
|
||||||
}
|
|
||||||
const tool = candidate.detail
|
const tool = candidate.detail
|
||||||
return {
|
return {
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
@@ -42,9 +43,14 @@ export function createCopy(t) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Describe the newest alert's fate in one line. */
|
/**
|
||||||
const describeDelivery = (last, tr) => {
|
* Split the newest delivery into the three facts the status list shows.
|
||||||
if (last === null || last === undefined) return tr('config.diag.none')
|
* @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')
|
const outcome = last.outcome.startsWith('system-refused')
|
||||||
? tr('config.diag.refused')
|
? tr('config.diag.refused')
|
||||||
: last.outcome === 'system'
|
: last.outcome === 'system'
|
||||||
@@ -56,13 +62,13 @@ export function createCopy(t) {
|
|||||||
: last.outcome === 'replay'
|
: last.outcome === 'replay'
|
||||||
? tr('config.diag.replayDelivery')
|
? tr('config.diag.replayDelivery')
|
||||||
: tr('config.diag.popup')
|
: tr('config.diag.popup')
|
||||||
return `${tr('config.diag.last')}: ${last.at} · ${outcome} · ${last.title}`
|
return { at: last.at, outcome, title: last.title ?? '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Say how many away-channel alerts are still waiting to be replayed in-app. */
|
/** How many away-channel alerts are still waiting for the user to come back. */
|
||||||
const describeReplay = (count, tr) => (
|
const replayValue = (count, tr) => (
|
||||||
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count })
|
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replayValue', { count })
|
||||||
)
|
)
|
||||||
|
|
||||||
return { copyFor, describeDelivery, describeReplay }
|
return { copyFor, deliveryFacts, replayValue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
|
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
|
||||||
import { windowIsAway } from '../platform.js'
|
import { windowIsAway } from '../platform.js'
|
||||||
import { isOnScreen, stillWorth } from './session.js'
|
import { isOnScreen, stillOwed } from './session.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the delivery path.
|
* Build the delivery path.
|
||||||
@@ -67,29 +67,33 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
|
|||||||
* never actually receives.
|
* never actually receives.
|
||||||
* @param candidate - the alert to deliver.
|
* @param candidate - the alert to deliver.
|
||||||
* @param ignoreThrottle - deliver immediately, bypassing the flood guard.
|
* @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) => {
|
const deliver = (candidate, ignoreThrottle) => {
|
||||||
if (ignoreThrottle !== true) {
|
if (ignoreThrottle !== true) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastDelivery < THROTTLE_MS) return
|
if (now - lastDelivery < THROTTLE_MS) return false
|
||||||
lastDelivery = now
|
lastDelivery = now
|
||||||
}
|
}
|
||||||
if (!windowIsAway()) {
|
if (!windowIsAway()) {
|
||||||
if (isOnScreen(candidate.sessionId, live.list)) return
|
if (isOnScreen(candidate.sessionId, live.list)) return true
|
||||||
toasts.showToast(candidate)
|
toasts.showToast(candidate)
|
||||||
recordDelivery('popup', candidate)
|
recordDelivery('popup', candidate)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
const result = system.notifySystem(candidate, candidate.test === true)
|
const result = system.notifySystem(candidate, candidate.test === true)
|
||||||
if (result.outcome === 'raised') {
|
if (result.outcome === 'raised') {
|
||||||
recordDelivery('system', candidate, result)
|
recordDelivery('system', candidate, result)
|
||||||
if (candidate.test !== true) replay.holdForReplay(candidate, result)
|
if (candidate.test !== true) replay.holdForReplay(candidate, result)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
// A test alert still has to reach the user, and so does a real one when
|
// 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.
|
// the system channel refuses: the popup is the channel that remains.
|
||||||
toasts.showToast(candidate)
|
toasts.showToast(candidate)
|
||||||
recordDelivery(`system-refused-${result.outcome}`, candidate)
|
recordDelivery(`system-refused-${result.outcome}`, candidate)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,11 +102,19 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
|
|||||||
* One candidate per tick keeps the throttle meaningful, and anything still
|
* 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
|
* 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.
|
* 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 = () => {
|
const flushSettle = () => {
|
||||||
settleTimer = 0
|
settleTimer = 0
|
||||||
const candidate = pending.shift()
|
const candidate = pending.shift()
|
||||||
if (candidate !== undefined && stillWorth(candidate, live)) deliver(candidate, false)
|
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)
|
if (pending.length > 0) settleTimer = setTimeout(() => flushSettle(), SETTLE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -8,9 +8,13 @@
|
|||||||
import { PENDING_KINDS } from '../constants.js'
|
import { PENDING_KINDS } from '../constants.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discriminate a pending interaction the client published. Only the three
|
* Discriminate a pending interaction the client published.
|
||||||
* domains the Harness itself renders are served; anything else is ignored
|
*
|
||||||
* rather than guessed at.
|
* 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`.
|
* @param value - `status.pendingInteraction`.
|
||||||
* @returns a served kind, or undefined.
|
* @returns a served kind, or undefined.
|
||||||
*/
|
*/
|
||||||
@@ -55,21 +59,39 @@ export function isOnScreen(sessionId, list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a queued candidate is still worth delivering. Read from the LIVE
|
* Whether a queued candidate is still owed, read from the LIVE snapshot rather
|
||||||
* snapshot rather than the one captured when the transition was seen: a
|
* than the one captured when the transition was seen.
|
||||||
* conversation that resumed in the settle window owes no alert, and the
|
*
|
||||||
* snapshot the transition was derived from still carries the pre-transition
|
* The two triggers are owed for different reasons, so they are checked for
|
||||||
* `running` flag of the session record.
|
* 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 candidate - the alert waiting for its settle tick.
|
||||||
* @param live - the freshest `{ list, status }` the observer has seen.
|
* @param live - the freshest `{ list, status }` the observer has seen.
|
||||||
* @returns whether the alert is still owed.
|
* @returns whether the alert is still owed.
|
||||||
*/
|
*/
|
||||||
export function stillWorth(candidate, live) {
|
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
|
const list = live.list
|
||||||
if (candidate.test === true || list === undefined) return true
|
if (list === undefined) return true
|
||||||
const summary = list.byId?.[candidate.sessionId]
|
const summary = list.byId?.[candidate.sessionId]
|
||||||
if (summary === undefined) return true
|
if (summary === undefined) return true
|
||||||
const status = live.status
|
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running
|
||||||
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running
|
|
||||||
return running !== true
|
return running !== true
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-18
@@ -1,43 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* This package's browser-local preferences.
|
* This package's browser-local preferences: the three trigger switches and the
|
||||||
|
* in-app popup lifetime.
|
||||||
|
*
|
||||||
|
* They 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. Every read is defensive, because
|
||||||
|
* storage can be unavailable or hold what an older version wrote, and every
|
||||||
|
* write merges into the stored object so that setting one preference never
|
||||||
|
* clears another.
|
||||||
*
|
*
|
||||||
* @module @dsh-plugin/session-notify/client/core/storage
|
* @module @dsh-plugin/session-notify/client/core/storage
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { KINDS } from '../constants.js'
|
import { DISMISS_CHOICES, KINDS, TOAST_DURATION_MS } from '../constants.js'
|
||||||
|
|
||||||
/** Storage key for this package's own preferences. */
|
/** Storage key for this package's own preferences. */
|
||||||
export const STORAGE_KEY = 'dsh-plugin/session-notify'
|
export const STORAGE_KEY = 'dsh-plugin/session-notify'
|
||||||
|
|
||||||
|
/** Every preference, at the value a browser that has never seen this plugin uses. */
|
||||||
|
function defaults() {
|
||||||
|
return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read this package's stored trigger switches.
|
* Read the stored preferences, one field at a time.
|
||||||
*
|
*
|
||||||
* These are browser preferences rather than cordis configuration, so they live
|
* A field is only taken when it has the shape this version understands: a
|
||||||
* in this origin's own storage instead of the profile's patch file: the plugin
|
* boolean per trigger, and a popup lifetime from {@link DISMISS_CHOICES}. Anything
|
||||||
* writes nothing into DSH's configuration, and every read is defensive because
|
* else keeps the default, so a foreign or older value can never produce a state
|
||||||
* storage can be unavailable or hold something an older version wrote.
|
* the plugin cannot render.
|
||||||
* @returns the stored switches, defaulting to everything on.
|
* @returns the effective preferences.
|
||||||
*/
|
*/
|
||||||
export function readStoredKinds() {
|
function readStored() {
|
||||||
const kinds = { completion: true, approval: true, question: true }
|
const preferences = defaults()
|
||||||
try {
|
try {
|
||||||
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
|
const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
|
||||||
if (typeof raw !== 'string' || raw === '') return kinds
|
if (typeof raw !== 'string' || raw === '') return preferences
|
||||||
const stored = JSON.parse(raw)
|
const stored = JSON.parse(raw)
|
||||||
for (const kind of KINDS) {
|
for (const kind of KINDS) {
|
||||||
if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind]
|
if (typeof stored?.[kind] === 'boolean') preferences[kind] = stored[kind]
|
||||||
}
|
}
|
||||||
|
if (DISMISS_CHOICES.includes(stored?.dismissAfterMs)) preferences.dismissAfterMs = stored.dismissAfterMs
|
||||||
} catch {
|
} catch {
|
||||||
/* unreadable or foreign storage keeps the defaults */
|
/* unreadable or foreign storage keeps the defaults */
|
||||||
}
|
}
|
||||||
return kinds
|
return preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a patch over the stored preferences, keeping every field the patch does not name. */
|
||||||
|
function writeStored(patch) {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify({ ...readStored(), ...patch }))
|
||||||
|
} catch {
|
||||||
|
/* a refused write leaves the preference live for this page only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read this package's stored trigger switches.
|
||||||
|
* @returns the stored switches, defaulting to everything on.
|
||||||
|
*/
|
||||||
|
export function readStoredKinds() {
|
||||||
|
const { completion, approval, question } = readStored()
|
||||||
|
return { completion, approval, question }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store one trigger switch, ignoring storage that refuses to write. */
|
/** Store one trigger switch, ignoring storage that refuses to write. */
|
||||||
export function writeStoredKinds(kinds) {
|
export function writeStoredKinds(kinds) {
|
||||||
try {
|
writeStored(kinds)
|
||||||
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds))
|
}
|
||||||
} catch {
|
|
||||||
/* a refused write leaves the switches live for this page only */
|
/**
|
||||||
}
|
* Read the stored in-app popup lifetime.
|
||||||
|
* @returns milliseconds before a card closes itself, or `0` for "never".
|
||||||
|
*/
|
||||||
|
export function readStoredDismissMs() {
|
||||||
|
return readStored().dismissAfterMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the popup lifetime the user picked.
|
||||||
|
* @param ms - milliseconds, or `0` for "never auto-close".
|
||||||
|
*/
|
||||||
|
export function writeStoredDismissMs(ms) {
|
||||||
|
writeStored({ dismissAfterMs: ms })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* The light in-app popup stack: its lifetime, its ordering, and its bound.
|
* The light in-app popup stack: its lifetime, its ordering, and its bound.
|
||||||
*
|
*
|
||||||
|
* The lifetime is the user's, read from the store at the moment a card is armed:
|
||||||
|
* the settings page offers a few delays plus "never", and re-arming after a hover
|
||||||
|
* picks the current choice up. `0` means the card waits for the user instead of a
|
||||||
|
* timer.
|
||||||
|
*
|
||||||
* @module @dsh-plugin/session-notify/client/core/toasts
|
* @module @dsh-plugin/session-notify/client/core/toasts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,10 +18,19 @@ import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js'
|
|||||||
*/
|
*/
|
||||||
export function createToastStack({ store, copy }) {
|
export function createToastStack({ store, copy }) {
|
||||||
const timers = new Map()
|
const timers = new Map()
|
||||||
|
/** Cards the pointer is resting on: they carry no timer until it leaves. */
|
||||||
|
const held = new Set()
|
||||||
let seq = 0
|
let seq = 0
|
||||||
|
|
||||||
|
/** The configured lifetime, falling back to the default for a value this version cannot use. */
|
||||||
|
const lifetime = () => {
|
||||||
|
const configured = store.getSnapshot().dismissAfterMs
|
||||||
|
return typeof configured === 'number' && configured >= 0 ? configured : TOAST_DURATION_MS
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop one popup and cancel its lifetime timer. */
|
/** Drop one popup and cancel its lifetime timer. */
|
||||||
const closeToast = (id) => {
|
const closeToast = (id) => {
|
||||||
|
held.delete(id)
|
||||||
const timer = timers.get(id)
|
const timer = timers.get(id)
|
||||||
if (timer !== undefined) {
|
if (timer !== undefined) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -27,21 +41,42 @@ export function createToastStack({ store, copy }) {
|
|||||||
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) })
|
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Arm one popup's lifetime; hovering calls `hold` first, then this again. */
|
/**
|
||||||
|
* Arm one popup's lifetime; hovering calls `hold` first, then this again.
|
||||||
|
* A lifetime of `0` arms nothing: the card stays until it is dismissed.
|
||||||
|
*/
|
||||||
const armToast = (id) => {
|
const armToast = (id) => {
|
||||||
|
held.delete(id)
|
||||||
const timer = timers.get(id)
|
const timer = timers.get(id)
|
||||||
if (timer !== undefined) clearTimeout(timer)
|
if (timer !== undefined) clearTimeout(timer)
|
||||||
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS))
|
timers.delete(id)
|
||||||
|
const duration = lifetime()
|
||||||
|
if (duration === 0) return
|
||||||
|
timers.set(id, setTimeout(() => closeToast(id), duration))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pause one popup's lifetime while the pointer rests on it. */
|
/** Pause one popup's lifetime while the pointer rests on it. */
|
||||||
const holdToast = (id) => {
|
const holdToast = (id) => {
|
||||||
|
held.add(id)
|
||||||
const timer = timers.get(id)
|
const timer = timers.get(id)
|
||||||
if (timer === undefined) return
|
if (timer === undefined) return
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timers.delete(id)
|
timers.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current lifetime to the cards already on screen.
|
||||||
|
*
|
||||||
|
* Changing the setting restarts the countdown for what is visible — otherwise
|
||||||
|
* a card raised under the old lifetime would still close on the old schedule.
|
||||||
|
* A held card is left alone: the pointer is on it, so nothing may close it.
|
||||||
|
*/
|
||||||
|
const rearmAll = () => {
|
||||||
|
for (const toast of store.getSnapshot().toasts) {
|
||||||
|
if (!held.has(toast.id)) armToast(toast.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show one light in-app popup: the trigger as the title, the conversation
|
* 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
|
* and its detail as the description, so the heading says what happened and
|
||||||
@@ -65,6 +100,7 @@ export function createToastStack({ store, copy }) {
|
|||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timers.delete(dropped.id)
|
timers.delete(dropped.id)
|
||||||
}
|
}
|
||||||
|
held.delete(dropped.id)
|
||||||
}
|
}
|
||||||
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) })
|
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) })
|
||||||
armToast(toast.id)
|
armToast(toast.id)
|
||||||
@@ -74,7 +110,8 @@ export function createToastStack({ store, copy }) {
|
|||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
for (const timer of timers.values()) clearTimeout(timer)
|
for (const timer of timers.values()) clearTimeout(timer)
|
||||||
timers.clear()
|
timers.clear()
|
||||||
|
held.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { showToast, closeToast, armToast, holdToast, dispose }
|
return { showToast, closeToast, armToast, holdToast, rearmAll, dispose }
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -12,7 +12,6 @@ export const en = {
|
|||||||
'notification.completion': 'Conversation finished',
|
'notification.completion': 'Conversation finished',
|
||||||
'notification.approval': 'Approval required',
|
'notification.approval': 'Approval required',
|
||||||
'notification.question': 'Answer needed',
|
'notification.question': 'Answer needed',
|
||||||
'body.completion': '{title} finished this round',
|
|
||||||
'body.approval': '{title}: {tool} is waiting for your approval',
|
'body.approval': '{title}: {tool} is waiting for your approval',
|
||||||
'body.approvalPlain': '{title}: a tool is waiting for your approval',
|
'body.approvalPlain': '{title}: a tool is waiting for your approval',
|
||||||
'body.question': '{title}: waiting for your answer',
|
'body.question': '{title}: waiting for your answer',
|
||||||
@@ -20,7 +19,6 @@ export const en = {
|
|||||||
'body.untitled': 'Untitled conversation',
|
'body.untitled': 'Untitled conversation',
|
||||||
'toast.view': 'View',
|
'toast.view': 'View',
|
||||||
'toast.dismiss': 'Dismiss',
|
'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.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.granted': 'System notifications are on',
|
||||||
'settings.permission.default': 'System notification permission has not been granted',
|
'settings.permission.default': 'System notification permission has not been granted',
|
||||||
@@ -39,19 +37,27 @@ export const en = {
|
|||||||
'settings.health.noHooks': 'No session status received (notifications will not fire)',
|
'settings.health.noHooks': 'No session status received (notifications will not fire)',
|
||||||
'settings.health.error': 'Watching session status failed: {message}',
|
'settings.health.error': 'Watching session status failed: {message}',
|
||||||
'settings.health.starting': 'Waiting for the session list',
|
'settings.health.starting': 'Waiting for the session list',
|
||||||
'settings.kinds.label': 'Notify about',
|
|
||||||
'settings.kind.completion': 'Finished',
|
'settings.kind.completion': 'Finished',
|
||||||
'settings.kind.approval': 'Approval',
|
'settings.kind.approval': 'Approval',
|
||||||
'settings.kind.question': 'Questions',
|
'settings.kind.question': 'Questions',
|
||||||
|
'settings.dismiss.label': 'Auto-close',
|
||||||
|
'settings.dismiss.3000': '3s',
|
||||||
|
'settings.dismiss.6000': '6s',
|
||||||
|
'settings.dismiss.10000': '10s',
|
||||||
|
'settings.dismiss.30000': '30s',
|
||||||
|
'settings.dismiss.0': 'Never',
|
||||||
'config.section.kinds': 'What to notify about',
|
'config.section.kinds': 'What to notify about',
|
||||||
|
'config.section.popup': 'Popup',
|
||||||
'config.section.permission': 'System notification permission',
|
'config.section.permission': 'System notification permission',
|
||||||
'config.section.status': 'Status',
|
'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.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.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.',
|
||||||
|
'config.dismiss.note': 'Hovering a card pauses the countdown; with Never, close each one with ✕ or Esc.',
|
||||||
'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.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.testAny': 'Test alert',
|
||||||
'config.testSystem': 'Test system notification',
|
'config.testSystem': 'Test system notification',
|
||||||
'config.diag.window': 'Window',
|
'config.diag.window': 'Window',
|
||||||
|
'config.diag.watch': 'Session watch',
|
||||||
'config.diag.inFront': 'in front (popup is used)',
|
'config.diag.inFront': 'in front (popup is used)',
|
||||||
'config.diag.away': 'not in front (system notification is used)',
|
'config.diag.away': 'not in front (system notification is used)',
|
||||||
'config.diag.last': 'Last delivery',
|
'config.diag.last': 'Last delivery',
|
||||||
@@ -62,6 +68,7 @@ export const en = {
|
|||||||
'config.diag.replayDelivery': 'in-app popup (replayed when you came back)',
|
'config.diag.replayDelivery': 'in-app popup (replayed when you came back)',
|
||||||
'config.diag.popup': 'in-app popup',
|
'config.diag.popup': 'in-app popup',
|
||||||
'config.diag.refused': 'system channel refused, popup was used instead',
|
'config.diag.refused': 'system channel refused, popup was used instead',
|
||||||
'config.diag.replay': 'waiting for you to come back: {count}',
|
'config.diag.replay': 'Replay',
|
||||||
'config.diag.replayNone': 'nothing waiting to be replayed',
|
'config.diag.replayValue': '{count} waiting',
|
||||||
|
'config.diag.replayNone': 'None',
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -12,7 +12,6 @@ export const zh = {
|
|||||||
'notification.completion': '会话已完成',
|
'notification.completion': '会话已完成',
|
||||||
'notification.approval': '需要授权',
|
'notification.approval': '需要授权',
|
||||||
'notification.question': '需要回答',
|
'notification.question': '需要回答',
|
||||||
'body.completion': '{title} 已完成这一轮回答',
|
|
||||||
'body.approval': '{title}:工具 {tool} 正在等待你的授权',
|
'body.approval': '{title}:工具 {tool} 正在等待你的授权',
|
||||||
'body.approvalPlain': '{title}:有工具正在等待你的授权',
|
'body.approvalPlain': '{title}:有工具正在等待你的授权',
|
||||||
'body.question': '{title}:正在等待你的回答',
|
'body.question': '{title}:正在等待你的回答',
|
||||||
@@ -20,7 +19,6 @@ export const zh = {
|
|||||||
'body.untitled': '未命名会话',
|
'body.untitled': '未命名会话',
|
||||||
'toast.view': '查看',
|
'toast.view': '查看',
|
||||||
'toast.dismiss': '关闭',
|
'toast.dismiss': '关闭',
|
||||||
'settings.title': '会话通知',
|
|
||||||
'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。',
|
'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。',
|
||||||
'settings.permission.granted': '系统通知已开启',
|
'settings.permission.granted': '系统通知已开启',
|
||||||
'settings.permission.default': '尚未授予系统通知权限',
|
'settings.permission.default': '尚未授予系统通知权限',
|
||||||
@@ -39,19 +37,27 @@ export const zh = {
|
|||||||
'settings.health.noHooks': '未收到会话状态(通知不会触发)',
|
'settings.health.noHooks': '未收到会话状态(通知不会触发)',
|
||||||
'settings.health.error': '监听会话状态出错:{message}',
|
'settings.health.error': '监听会话状态出错:{message}',
|
||||||
'settings.health.starting': '正在等待会话列表',
|
'settings.health.starting': '正在等待会话列表',
|
||||||
'settings.kinds.label': '提醒内容',
|
|
||||||
'settings.kind.completion': '完成',
|
'settings.kind.completion': '完成',
|
||||||
'settings.kind.approval': '授权',
|
'settings.kind.approval': '授权',
|
||||||
'settings.kind.question': '提问',
|
'settings.kind.question': '提问',
|
||||||
|
'settings.dismiss.label': '自动关闭',
|
||||||
|
'settings.dismiss.3000': '3 秒',
|
||||||
|
'settings.dismiss.6000': '6 秒',
|
||||||
|
'settings.dismiss.10000': '10 秒',
|
||||||
|
'settings.dismiss.30000': '30 秒',
|
||||||
|
'settings.dismiss.0': '永不',
|
||||||
'config.section.kinds': '提醒内容',
|
'config.section.kinds': '提醒内容',
|
||||||
|
'config.section.popup': '轻弹窗',
|
||||||
'config.section.permission': '系统通知权限',
|
'config.section.permission': '系统通知权限',
|
||||||
'config.section.status': '运行状态',
|
'config.section.status': '运行状态',
|
||||||
'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。',
|
'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。',
|
||||||
'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。',
|
'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。',
|
||||||
|
'config.dismiss.note': '鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。',
|
||||||
'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。',
|
'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。',
|
||||||
'config.testAny': '测试提醒',
|
'config.testAny': '测试提醒',
|
||||||
'config.testSystem': '测试系统通知',
|
'config.testSystem': '测试系统通知',
|
||||||
'config.diag.window': '窗口状态',
|
'config.diag.window': '窗口状态',
|
||||||
|
'config.diag.watch': '会话监听',
|
||||||
'config.diag.inFront': '在前台(会走轻弹窗)',
|
'config.diag.inFront': '在前台(会走轻弹窗)',
|
||||||
'config.diag.away': '不在前台(会走系统通知)',
|
'config.diag.away': '不在前台(会走系统通知)',
|
||||||
'config.diag.last': '最近一次投递',
|
'config.diag.last': '最近一次投递',
|
||||||
@@ -62,6 +68,7 @@ export const zh = {
|
|||||||
'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)',
|
'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)',
|
||||||
'config.diag.popup': '应用内轻弹窗',
|
'config.diag.popup': '应用内轻弹窗',
|
||||||
'config.diag.refused': '系统通知被拒绝,改用了轻弹窗',
|
'config.diag.refused': '系统通知被拒绝,改用了轻弹窗',
|
||||||
'config.diag.replay': '等你回到窗口时补发:{count} 条',
|
'config.diag.replay': '待补发提醒',
|
||||||
'config.diag.replayNone': '没有待补发的提醒',
|
'config.diag.replayValue': '{count} 条',
|
||||||
|
'config.diag.replayNone': '没有',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { createTranslator, registerDictionaries } from './i18n/index.js'
|
|||||||
import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js'
|
import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js'
|
||||||
import { report, text } from './core/log.js'
|
import { report, text } from './core/log.js'
|
||||||
import { createStore } from './core/store.js'
|
import { createStore } from './core/store.js'
|
||||||
import { readStoredKinds } from './core/storage.js'
|
import { readStoredDismissMs, readStoredKinds } from './core/storage.js'
|
||||||
import { createCopy } from './core/copy.js'
|
import { createCopy } from './core/copy.js'
|
||||||
import { createToastStack } from './core/toasts.js'
|
import { createToastStack } from './core/toasts.js'
|
||||||
import { createReplay } from './core/replay.js'
|
import { createReplay } from './core/replay.js'
|
||||||
@@ -63,6 +63,7 @@ export function apply(ctx) {
|
|||||||
const store = createStore({
|
const store = createStore({
|
||||||
permission: notificationPermission(),
|
permission: notificationPermission(),
|
||||||
kinds: readStoredKinds(),
|
kinds: readStoredKinds(),
|
||||||
|
dismissAfterMs: readStoredDismissMs(),
|
||||||
toasts: [],
|
toasts: [],
|
||||||
promptError: '',
|
promptError: '',
|
||||||
health: { state: 'starting', message: '' },
|
health: { state: 'starting', message: '' },
|
||||||
@@ -194,6 +195,7 @@ export function apply(ctx) {
|
|||||||
testAny: actions.sendTest,
|
testAny: actions.sendTest,
|
||||||
testSystem: actions.sendTestSystem,
|
testSystem: actions.sendTestSystem,
|
||||||
setKind: actions.setKindEnabled,
|
setKind: actions.setKindEnabled,
|
||||||
|
setDismiss: actions.setDismissAfter,
|
||||||
}),
|
}),
|
||||||
}, ConfigSection))
|
}, ConfigSection))
|
||||||
|
|
||||||
|
|||||||
+105
-51
@@ -6,12 +6,19 @@
|
|||||||
* row's description) and `page` (the whole form). Everything visible here
|
* row's description) and `page` (the whole form). Everything visible here
|
||||||
* belongs to this package, including the platform-specific permission hint.
|
* 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
|
* @module @dsh-plugin/session-notify/client/ui/ConfigSection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { KINDS } from '../constants.js'
|
import { DISMISS_CHOICES, KINDS } from '../constants.js'
|
||||||
import { windowIsAway } from '../platform.js'
|
import { windowIsAway } from '../platform.js'
|
||||||
|
import { DismissRow } from './DismissRow.js'
|
||||||
import { KindRow } from './KindRow.js'
|
import { KindRow } from './KindRow.js'
|
||||||
import { seatTranslator, useOwnStore } from './hooks.js'
|
import { seatTranslator, useOwnStore } from './hooks.js'
|
||||||
import { CSS } from './styles.js'
|
import { CSS } from './styles.js'
|
||||||
@@ -22,6 +29,7 @@ export function ConfigSection(props) {
|
|||||||
const snapshot = useOwnStore(props.store)
|
const snapshot = useOwnStore(props.store)
|
||||||
const tr = seatTranslator(props, props.fallbackT)
|
const tr = seatTranslator(props, props.fallbackT)
|
||||||
if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description'))
|
if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description'))
|
||||||
|
|
||||||
const permission = snapshot.permission
|
const permission = snapshot.permission
|
||||||
const supported = permission !== 'unsupported'
|
const supported = permission !== 'unsupported'
|
||||||
const failed = permission === 'denied' || snapshot.promptError !== ''
|
const failed = permission === 'denied' || snapshot.promptError !== ''
|
||||||
@@ -32,9 +40,19 @@ export function ConfigSection(props) {
|
|||||||
: supported
|
: supported
|
||||||
? 'settings.permission.default'
|
? 'settings.permission.default'
|
||||||
: 'settings.permission.unsupported'
|
: 'settings.permission.unsupported'
|
||||||
const permissionText = snapshot.promptError === ''
|
const promptFailed = snapshot.promptError !== ''
|
||||||
? `${tr(statusKey)}${permission === 'denied' || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ''}`
|
const permissionText = promptFailed ? snapshot.promptError : tr(statusKey)
|
||||||
: snapshot.promptError
|
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 health = snapshot.health
|
||||||
const healthText = health.state === 'watching'
|
const healthText = health.state === 'watching'
|
||||||
? tr('settings.health.watching')
|
? tr('settings.health.watching')
|
||||||
@@ -44,55 +62,91 @@ export function ConfigSection(props) {
|
|||||||
? tr('settings.health.error', { message: health.message })
|
? tr('settings.health.error', { message: health.message })
|
||||||
: tr('settings.health.starting')
|
: tr('settings.health.starting')
|
||||||
const healthFailed = health.state !== 'watching' && health.state !== 'starting'
|
const healthFailed = health.state !== 'watching' && health.state !== 'starting'
|
||||||
|
|
||||||
|
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr)
|
||||||
|
|
||||||
return h(React.Fragment, null,
|
return h(React.Fragment, null,
|
||||||
h('style', null, CSS),
|
h('style', null, CSS),
|
||||||
h('div', { className: 'dsn-sections' },
|
h('div', { className: 'dsn-page' },
|
||||||
// The page header already shows this bundle's title and description, so
|
// The page header already shows this bundle's title and description, so this
|
||||||
// this section opens with what the header cannot say: how alerts are
|
// lede says what the header cannot: how an alert travels, and that the
|
||||||
// carried, and what this form changes.
|
// channel is chosen at delivery time.
|
||||||
h('section', { className: 'dsn-section' },
|
h('p', { className: 'dsn-lede' }, tr('config.intro')),
|
||||||
h('p', { className: 'dsn-section-desc' }, tr('config.intro'))),
|
|
||||||
h('section', { className: 'dsn-section' },
|
h('section', { className: 'dsn-group' },
|
||||||
h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')),
|
h('h3', { className: 'dsn-group-title' }, tr('config.section.kinds')),
|
||||||
KINDS.map((kind) => h(KindRow, {
|
h('div', { className: 'dsn-list' },
|
||||||
key: kind,
|
KINDS.map((kind) => h(KindRow, {
|
||||||
label: tr(`settings.kind.${kind}`),
|
key: kind,
|
||||||
checked: snapshot.kinds[kind] === true,
|
label: tr(`settings.kind.${kind}`),
|
||||||
disabled: false,
|
checked: snapshot.kinds[kind] === true,
|
||||||
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
|
disabled: false,
|
||||||
})),
|
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
|
||||||
h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))),
|
}))),
|
||||||
h('section', { className: 'dsn-section' },
|
h('p', { className: 'dsn-group-note' }, tr('config.saveNote'))),
|
||||||
h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')),
|
|
||||||
h('div', { className: 'dsn-row' },
|
h('section', { className: 'dsn-group' },
|
||||||
h('div', { className: 'dsn-row-text' },
|
h('h3', { className: 'dsn-group-title' }, tr('config.section.popup')),
|
||||||
|
h('div', { className: 'dsn-list' },
|
||||||
|
h(DismissRow, {
|
||||||
|
label: tr('settings.dismiss.label'),
|
||||||
|
options: DISMISS_CHOICES.map((value) => ({ value, label: tr(`settings.dismiss.${value}`) })),
|
||||||
|
value: snapshot.dismissAfterMs,
|
||||||
|
onChange: props.setDismiss,
|
||||||
|
})),
|
||||||
|
h('p', { className: 'dsn-group-note' }, tr('config.dismiss.note'))),
|
||||||
|
|
||||||
|
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', {
|
h('p', {
|
||||||
className: `dsn-status${failed ? ' dsn-status-error' : ''}`,
|
className: `dsn-fact-value${healthFailed ? ' is-error' : ''}`,
|
||||||
role: failed ? 'alert' : 'status',
|
|
||||||
}, permissionText)),
|
|
||||||
h('button', {
|
|
||||||
type: 'button',
|
|
||||||
className: 'dsn-button',
|
|
||||||
disabled: !supported,
|
|
||||||
onClick: permission === 'granted' ? props.testSystem : props.ask,
|
|
||||||
}, tr(!supported
|
|
||||||
? 'settings.action.unsupported'
|
|
||||||
: permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))),
|
|
||||||
h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))),
|
|
||||||
h('section', { className: 'dsn-section' },
|
|
||||||
h('h3', { className: 'dsn-section-title' }, tr('config.section.status')),
|
|
||||||
h('div', { className: 'dsn-row' },
|
|
||||||
h('div', { className: 'dsn-row-text' },
|
|
||||||
h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`),
|
|
||||||
h('p', {
|
|
||||||
className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`,
|
|
||||||
role: healthFailed ? 'alert' : 'status',
|
role: healthFailed ? 'alert' : 'status',
|
||||||
}, healthText),
|
}, healthText)),
|
||||||
h('p', { className: 'dsn-status' }, props.copy.describeDelivery(snapshot.lastDelivery, tr)),
|
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.last')),
|
||||||
h('p', { className: 'dsn-status' }, props.copy.describeReplay(props.replayCount(), tr))),
|
h('dd', { className: 'dsn-fact' },
|
||||||
h('button', {
|
delivery === null
|
||||||
type: 'button',
|
? h('p', { className: 'dsn-fact-value' }, tr('config.diag.none'))
|
||||||
className: 'dsn-button',
|
: [
|
||||||
onClick: props.testAny,
|
h('p', { key: 'line', className: 'dsn-fact-value' }, `${delivery.at} · ${delivery.outcome}`),
|
||||||
}, tr('settings.action.testAny'))))))
|
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')))))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* One popup-lifetime row: the choices are the values they set, so the row needs
|
||||||
|
* no second control and no free-form input.
|
||||||
|
*
|
||||||
|
* @module @dsh-plugin/session-notify/client/ui/DismissRow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const h = React.createElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param props - `label` (the row's own label and the group's accessible name),
|
||||||
|
* `options` (`{ value, label }[]`, in display order), `value` (the chosen one),
|
||||||
|
* and `onChange(value)`.
|
||||||
|
*/
|
||||||
|
export function DismissRow(props) {
|
||||||
|
return h('div', { className: 'dsn-row' },
|
||||||
|
h('div', { className: 'dsn-row-text' },
|
||||||
|
h('div', { className: 'dsn-row-title' }, props.label)),
|
||||||
|
h('div', { className: 'dsn-chips', role: 'radiogroup', 'aria-label': props.label },
|
||||||
|
props.options.map((option) => h('button', {
|
||||||
|
key: String(option.value),
|
||||||
|
type: 'button',
|
||||||
|
role: 'radio',
|
||||||
|
className: 'dsn-chip',
|
||||||
|
'aria-checked': option.value === props.value,
|
||||||
|
onClick: () => props.onChange(option.value),
|
||||||
|
}, option.label))))
|
||||||
|
}
|
||||||
+57
-25
@@ -1,16 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Every style declaration this package owns.
|
* Every style declaration this package owns.
|
||||||
*
|
*
|
||||||
* The popup copies the shipped toast surface (a fixed entry in the frame-wide
|
* Both surfaces are built out of the theme's own vocabulary and nothing else, so
|
||||||
* overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair,
|
* they follow the shell's light and dark modes without a branch of their own (the
|
||||||
* `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text;
|
* theme swaps every `--dsw-alias-*` under `body[data-ds-dark-theme]`):
|
||||||
* the switch copies `Switch.module.css` behavior and proportion; the
|
*
|
||||||
* configuration sections follow the Plugin Manager page's own section rhythm
|
* - The popup is a floating card, using the same recipe the shipped menus and
|
||||||
* (a column, 32px apart). Class names are renamed under `dsn-`; only the popup
|
* modals use — `--dsw-alias-bg-layer-2` as the surface, the
|
||||||
* glyphs carry their own artwork colors.
|
* `--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
|
* A plugin must not import a Harness Client package, so the declarations are
|
||||||
* this package's own text and reference theme tokens only.
|
* 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
|
* @module @dsh-plugin/session-notify/client/ui/styles
|
||||||
*/
|
*/
|
||||||
@@ -18,33 +31,50 @@
|
|||||||
export const CSS = `
|
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}
|
.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}}
|
@media (max-width:520px){.dsn-stack{right:12px}}
|
||||||
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out}
|
.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)}}
|
@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}}
|
@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{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-completion{color:var(--dsw-alias-state-success-primary)}
|
||||||
.dsn-toast-icon.is-question{color:var(--dsw-alias-brand-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-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||||
.dsn-toast-title{font-weight:500;font-size:14px;line-height:22px}
|
.dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
|
||||||
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word}
|
.dsn-toast-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-actions{display:flex;align-items:center;gap:2px;flex:none}
|
||||||
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-static-deepseek-400);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
|
.dsn-toast-action{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: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-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer}
|
.dsn-toast-close{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);opacity:1}
|
.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-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
.dsn-sections{display:flex;flex-direction:column;gap:24px}
|
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
|
||||||
.dsn-section{display:flex;flex-direction:column;gap:10px}
|
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
||||||
.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
|
.dsn-group{display:flex;flex-direction:column;gap:2px}
|
||||||
.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
|
||||||
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
||||||
.dsn-row:last-child{border-bottom:0}
|
.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-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-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
|
||||||
.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
.dsn-chips{display:flex;align-items:center;gap:6px;flex:none}
|
||||||
.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
.dsn-chip{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:28px;min-width:46px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;line-height:18px;cursor:pointer}
|
||||||
.dsn-status-error{color:var(--dsw-alias-state-error-primary)}
|
.dsn-chip:hover:not([aria-checked='true']){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsn-chip[aria-checked='true']{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);cursor:default}
|
||||||
|
.dsn-chip:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
|
||||||
|
.dsn-row-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{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[aria-checked='true']{background:var(--dsw-alias-brand-primary)}
|
||||||
.dsn-switch:disabled{cursor:default;opacity:.5}
|
.dsn-switch:disabled{cursor:default;opacity:.5}
|
||||||
@@ -52,8 +82,10 @@ export const CSS = `
|
|||||||
.dsn-thumb{display:block;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-foreground);transition:transform 120ms ease}
|
.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='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)}
|
||||||
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
|
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
|
||||||
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
|
.dsn-button{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:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
||||||
.dsn-button:disabled{cursor:not-allowed;opacity:.4}
|
.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: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}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -137,10 +137,13 @@ test('applying the plugin registers its three seats without touching the DOM', (
|
|||||||
assert.equal(typeof configProps.testAny, 'function')
|
assert.equal(typeof configProps.testAny, 'function')
|
||||||
assert.equal(typeof configProps.testSystem, 'function')
|
assert.equal(typeof configProps.testSystem, 'function')
|
||||||
assert.equal(typeof configProps.setKind, 'function')
|
assert.equal(typeof configProps.setKind, 'function')
|
||||||
|
assert.equal(typeof configProps.setDismiss, 'function')
|
||||||
|
assert.equal(configProps.store.getSnapshot().dismissAfterMs, 6000, 'a browser with no stored choice gets the shipped lifetime')
|
||||||
assert.equal(configProps.replayCount(), 0)
|
assert.equal(configProps.replayCount(), 0)
|
||||||
// No locale service in this fake context, so the plugin's own translator falls
|
// No locale service in this fake context, so the plugin's own translator falls
|
||||||
// back to its English dictionary.
|
// back to its English dictionary.
|
||||||
assert.equal(configProps.copy.describeDelivery(null, configProps.fallbackT), 'nothing delivered yet')
|
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) => {
|
test('the committed artifact is exactly what src/client builds', async (t) => {
|
||||||
|
|||||||
+117
-22
@@ -1,10 +1,10 @@
|
|||||||
// The components, rendered through a tiny React double.
|
// The components, rendered through a tiny React double.
|
||||||
//
|
//
|
||||||
// Why a double instead of React: this repository ships no runtime dependency at
|
// Why a double instead of React: this repository ships no runtime dependency at
|
||||||
// all, and the whole point of these tests is the wiring the refactor introduced —
|
// all, and the whole point of these tests is the wiring — which component reads
|
||||||
// which component reads which prop through which seat. The double implements the
|
// which prop through which seat. The double implements the four React entry
|
||||||
// four React entry points this package uses, so a component render is exercised
|
// points this package uses, so a component render is exercised end to end (hooks
|
||||||
// end to end (hooks included) without a DOM or a real React.
|
// included) without a DOM or a real React.
|
||||||
//
|
//
|
||||||
// It runs against the BUILT artifact, because that is what the page loads.
|
// It runs against the BUILT artifact, because that is what the page loads.
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
@@ -98,11 +98,23 @@ function walk(node, collected = { text: [], types: [], elements: [] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flatten = (node) => walk(node).text.join(' ')
|
const flatten = (node) => walk(node).text.join(' ')
|
||||||
|
const classesOf = (rendered) => rendered.elements.map((element) => element.props.className ?? '')
|
||||||
|
|
||||||
test('the plugin page renders every section with copy, not raw keys', () => {
|
/**
|
||||||
|
* 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 { config } = mount()
|
||||||
const props = { ...config.definition.inject(), view: 'page' }
|
const rendered = walk(config.component({ ...config.definition.inject(), view: 'page' }))
|
||||||
const rendered = walk(config.component(props))
|
|
||||||
const text = rendered.text.join(' ')
|
const text = rendered.text.join(' ')
|
||||||
|
|
||||||
// No locale service in this context, so the seat falls back to English.
|
// No locale service in this context, so the seat falls back to English.
|
||||||
@@ -110,11 +122,20 @@ test('the plugin page renders every section with copy, not raw keys', () => {
|
|||||||
'Every alert comes from this page',
|
'Every alert comes from this page',
|
||||||
'What to notify about',
|
'What to notify about',
|
||||||
'The switches are stored in this browser',
|
'The switches are stored in this browser',
|
||||||
|
'Popup',
|
||||||
|
'Auto-close',
|
||||||
|
'6s',
|
||||||
|
'Never',
|
||||||
|
'Hovering a card pauses the countdown',
|
||||||
'System notification permission',
|
'System notification permission',
|
||||||
'The buttons here go straight to the system channel',
|
'This environment cannot show system notifications',
|
||||||
|
platformHint,
|
||||||
'Status',
|
'Status',
|
||||||
|
'Window',
|
||||||
|
'Session watch',
|
||||||
|
'Last delivery',
|
||||||
'nothing delivered yet',
|
'nothing delivered yet',
|
||||||
'nothing waiting to be replayed',
|
'Replay',
|
||||||
'Test alert',
|
'Test alert',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
|
assert.ok(text.includes(copy), `the page is missing: ${copy}`)
|
||||||
@@ -122,30 +143,104 @@ test('the plugin page renders every section with copy, not raw keys', () => {
|
|||||||
assert.ok(!text.includes('config.'), 'a raw translation key reached the page')
|
assert.ok(!text.includes('config.'), 'a raw translation key reached the page')
|
||||||
assert.ok(!text.includes('settings.'), 'a raw translation key reached the page')
|
assert.ok(!text.includes('settings.'), 'a raw translation key reached the page')
|
||||||
|
|
||||||
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
const classes = classesOf(rendered)
|
||||||
for (const className of ['dsn-sections', 'dsn-section', 'dsn-section-title', 'dsn-row', 'dsn-button', 'dsn-status']) {
|
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-chips',
|
||||||
|
'dsn-chip',
|
||||||
|
'dsn-facts',
|
||||||
|
'dsn-fact-key',
|
||||||
|
'dsn-fact-value',
|
||||||
|
'dsn-button',
|
||||||
|
]) {
|
||||||
assert.ok(classes.includes(className), `the page is missing .${className}`)
|
assert.ok(classes.includes(className), `the page is missing .${className}`)
|
||||||
}
|
}
|
||||||
|
// Three switches, five lifetime choices, 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-switch').length, 3)
|
||||||
|
assert.equal(classes.filter((className) => className === 'dsn-chip').length, 5)
|
||||||
// The live window fact is rendered, not remembered.
|
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1)
|
||||||
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
|
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 reads the store: switches, permission and diagnostics follow it', () => {
|
test('the popup lifetime is a real choice: the page reads it and writes it back', () => {
|
||||||
const { config } = mount()
|
const { config } = mount()
|
||||||
const props = config.definition.inject()
|
const props = config.definition.inject()
|
||||||
|
|
||||||
|
const chips = (componentProps) => walk(config.component({ ...componentProps, view: 'page' }))
|
||||||
|
.elements.filter((element) => element.props.className === 'dsn-chip')
|
||||||
|
const labelOf = (chip) => walk(chip).text.join('')
|
||||||
|
const checkedOf = (list) => list.filter((chip) => chip.props['aria-checked'] === true).map(labelOf)
|
||||||
|
|
||||||
|
const before = chips(props)
|
||||||
|
assert.deepEqual(before.map(labelOf), ['3s', '6s', '10s', '30s', 'Never'])
|
||||||
|
assert.deepEqual(checkedOf(before), ['6s'], 'the shipped default is preselected')
|
||||||
|
assert.equal(before[0].props.role, 'radio')
|
||||||
|
assert.equal(walk(config.component({ ...props, view: 'page' })).elements.find((element) => element.props.className === 'dsn-chips').props.role, 'radiogroup')
|
||||||
|
|
||||||
|
// "Never" is the zero choice, and choosing it goes through the plugin's own
|
||||||
|
// action: the store the popup stack reads is updated, not just the button.
|
||||||
|
before[4].props.onClick()
|
||||||
|
assert.equal(props.store.getSnapshot().dismissAfterMs, 0)
|
||||||
|
assert.deepEqual(checkedOf(chips(props)), ['Never'])
|
||||||
|
|
||||||
|
before[0].props.onClick()
|
||||||
|
assert.equal(props.store.getSnapshot().dismissAfterMs, 3000)
|
||||||
|
assert.deepEqual(checkedOf(chips(props)), ['3s'])
|
||||||
|
})
|
||||||
|
|
||||||
|
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.set({
|
||||||
...props.store.getSnapshot(),
|
...props.store.getSnapshot(),
|
||||||
kinds: { completion: false, approval: true, question: true },
|
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' },
|
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
|
||||||
})
|
})
|
||||||
const rendered = walk(config.component({ ...props, view: 'page' }))
|
const granted = walk(config.component({ ...props, view: 'page' }))
|
||||||
const switches = rendered.elements
|
const grantedClasses = classesOf(granted)
|
||||||
.filter((element) => element.props.className === 'dsn-switch')
|
// A healthy permission has no warning colour and the button becomes the
|
||||||
.map((element) => element.props['aria-checked'])
|
// channel-specific test.
|
||||||
assert.deepEqual(switches, [false, true, true])
|
assert.equal(grantedClasses.includes('dsn-dot is-muted'), false)
|
||||||
assert.match(rendered.text.join(' '), /Last delivery: 17:52:30 · system notification \(confirmed on screen\) · Conversation finished/)
|
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', () => {
|
test('the summary view is the one-line description the plugin row shows', () => {
|
||||||
@@ -169,7 +264,7 @@ test('the popup stack renders nothing while empty, and one card per alert', () =
|
|||||||
assert.ok(text.includes('Bash is waiting for your approval'))
|
assert.ok(text.includes('Bash is waiting for your approval'))
|
||||||
assert.ok(text.includes('View'))
|
assert.ok(text.includes('View'))
|
||||||
|
|
||||||
const classes = rendered.elements.map((element) => element.props.className ?? '')
|
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']) {
|
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}`)
|
assert.ok(classes.includes(className), `the popup is missing .${className}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-38
@@ -8,23 +8,24 @@ import { createCopy } from '../../src/client/core/copy.js'
|
|||||||
import { createTranslator } from '../../src/client/i18n/index.js'
|
import { createTranslator } from '../../src/client/i18n/index.js'
|
||||||
|
|
||||||
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
const t = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
||||||
const { copyFor, describeDelivery, describeReplay } = createCopy(t)
|
const { copyFor, deliveryFacts, replayValue } = createCopy(t)
|
||||||
|
|
||||||
/** One trigger candidate with every field a caller may leave empty. */
|
/** One trigger candidate with every field a caller may leave empty. */
|
||||||
function candidate(overrides) {
|
function candidate(overrides) {
|
||||||
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...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', () => {
|
test('a finished conversation says what happened in the heading, and the conversation in the description', () => {
|
||||||
|
// The heading already carries "finished", so the description is the title alone.
|
||||||
assert.deepEqual(copyFor(candidate()), {
|
assert.deepEqual(copyFor(candidate()), {
|
||||||
kind: 'completion',
|
kind: 'completion',
|
||||||
title: '会话已完成',
|
title: '会话已完成',
|
||||||
body: '标题 已完成这一轮回答',
|
body: '标题',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an empty title falls back to the untitled copy', () => {
|
test('an empty title falls back to the untitled copy', () => {
|
||||||
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答')
|
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an approval names the waiting tool when the client publishes one', () => {
|
test('an approval names the waiting tool when the client publishes one', () => {
|
||||||
@@ -41,47 +42,42 @@ test('an approval without a tool name still says what is happening', () => {
|
|||||||
assert.equal(notice.body, '标题:有工具正在等待你的授权')
|
assert.equal(notice.body, '标题:有工具正在等待你的授权')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a question carries the question text, and a plan review the plan\'s own text', () => {
|
test('a question carries the question text', () => {
|
||||||
const question = copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' }))
|
assert.deepEqual(
|
||||||
assert.deepEqual(question, { kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' })
|
copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' })),
|
||||||
|
{ kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' },
|
||||||
|
)
|
||||||
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
|
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
|
||||||
|
})
|
||||||
|
|
||||||
// v1.0.6 behavior, pinned on purpose: a plan review is queued with
|
test('a plan review says what it is instead of repeating the plan text', () => {
|
||||||
// `kind: 'question'`, so the question branch above answers it and the
|
// A plan review arrives as `kind: 'question'` with its own discriminator, so the
|
||||||
// `body.planReview` copy is unreachable. See the note in core/copy.js — fixing
|
// discriminator has to be read first — otherwise the plan-review copy is unreachable.
|
||||||
// it changes what a plan review says, so it is not part of this refactor.
|
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '计划正文…' }))
|
||||||
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '' }))
|
|
||||||
assert.equal(review.kind, 'question')
|
assert.equal(review.kind, 'question')
|
||||||
assert.equal(review.body, '标题:正在等待你的回答')
|
assert.equal(review.body, '标题:计划正在等待你确认')
|
||||||
assert.notEqual(review.body, t('body.planReview', { title: '标题' }))
|
assert.equal(review.title, '需要回答')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a delivery that never happened is described as such', () => {
|
test('a delivery that never happened has no facts to show', () => {
|
||||||
assert.equal(describeDelivery(null, t), '还没有投递过')
|
assert.equal(deliveryFacts(null, t), null)
|
||||||
assert.equal(describeDelivery(undefined, t), '还没有投递过')
|
assert.equal(deliveryFacts(undefined, t), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the newest delivery is described by channel, confirmation and local time', () => {
|
test('the newest delivery is split into time, channel and what it was', () => {
|
||||||
const system = describeDelivery({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t)
|
assert.deepEqual(
|
||||||
assert.equal(system, '最近一次投递: 17:52:30 · 系统通知(系统已确认弹出) · 会话已完成')
|
deliveryFacts({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t),
|
||||||
|
{ at: '17:52:30', outcome: '系统通知(系统已确认弹出)', title: '会话已完成' },
|
||||||
const unconfirmed = describeDelivery({ outcome: 'system', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
)
|
||||||
assert.match(unconfirmed, /系统没有回报/)
|
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, '系统通知没弹出来,回到窗口时补发轻弹窗')
|
||||||
const refused = describeDelivery({ outcome: 'system', shown: false, at: '17:52:30', title: 'X' }, t)
|
assert.equal(deliveryFacts({ outcome: 'system-refused-threw', at: '1:00:00', title: 'X' }, t).outcome, '系统通知被拒绝,改用了轻弹窗')
|
||||||
assert.match(refused, /没弹出来/)
|
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, '应用内轻弹窗')
|
||||||
const fellBack = describeDelivery({ outcome: 'system-refused-threw', shown: undefined, at: '17:52:30', title: 'X' }, t)
|
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: undefined }, t).title, '')
|
||||||
assert.match(fellBack, /系统通知被拒绝,改用了轻弹窗/)
|
|
||||||
|
|
||||||
const replayed = describeDelivery({ outcome: 'replay', at: '17:52:30', title: 'X' }, t)
|
|
||||||
assert.match(replayed, /应用内轻弹窗(回到窗口时补发)/)
|
|
||||||
|
|
||||||
const popup = describeDelivery({ outcome: 'popup', at: '17:52:30', title: 'X' }, t)
|
|
||||||
assert.match(popup, /· 应用内轻弹窗 ·/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the replay line counts what is still waiting', () => {
|
test('the replay value is short enough for one status line', () => {
|
||||||
assert.equal(describeReplay(0, t), '没有待补发的提醒')
|
assert.equal(replayValue(0, t), '没有')
|
||||||
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
|
assert.equal(replayValue(3, t), '3 条')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test('a locale service that throws on every call keeps the copy renderable', ()
|
|||||||
throw new Error('no snapshot')
|
throw new Error('no snapshot')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert.equal(t('settings.title'), 'Session notifications')
|
assert.equal(t('body.untitled'), 'Untitled conversation')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('registration prefers the typed form and falls back to one call per locale', () => {
|
test('registration prefers the typed form and falls back to one call per locale', () => {
|
||||||
|
|||||||
+39
-11
@@ -9,11 +9,14 @@ import {
|
|||||||
preview,
|
preview,
|
||||||
questionText,
|
questionText,
|
||||||
servedPendingKind,
|
servedPendingKind,
|
||||||
stillWorth,
|
stillOwed,
|
||||||
titleOf,
|
titleOf,
|
||||||
} from '../../src/client/core/session.js'
|
} from '../../src/client/core/session.js'
|
||||||
|
|
||||||
test('only the three interaction kinds the Harness renders are served', () => {
|
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: 'approval' }), 'approval')
|
||||||
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
|
assert.equal(servedPendingKind({ kind: 'question' }), 'question')
|
||||||
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
|
assert.equal(servedPendingKind({ kind: 'plan-review' }), 'plan-review')
|
||||||
@@ -58,17 +61,42 @@ test('the conversation on screen is the one the main view retains', () => {
|
|||||||
assert.equal(isOnScreen('a', undefined), false)
|
assert.equal(isOnScreen('a', undefined), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a queued alert is dropped when its conversation resumed inside the settle window', () => {
|
test('a pending interaction stays owed while it waits, even though the run is still running', () => {
|
||||||
const candidate = { kind: 'completion', sessionId: 'a', test: false }
|
// 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 })
|
const live = (byId, status) => ({ list: { byId }, status })
|
||||||
|
|
||||||
assert.equal(stillWorth(candidate, { list: undefined, status: undefined }), true)
|
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
|
||||||
assert.equal(stillWorth(candidate, live({ a: { running: false } })), true)
|
assert.equal(stillOwed(candidate, live({ a: { running: false } })), true)
|
||||||
assert.equal(stillWorth(candidate, live({})), true)
|
assert.equal(stillOwed(candidate, live({})), true)
|
||||||
assert.equal(stillWorth(candidate, live({ a: { running: true } })), false)
|
assert.equal(stillOwed(candidate, live({ a: { running: true } })), false)
|
||||||
// The per-session selector wins over the list summary, which still carries the
|
// The per-session selector wins over the list summary, which still carries the
|
||||||
// pre-transition flag.
|
// pre-transition flag.
|
||||||
assert.equal(stillWorth(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
|
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
|
||||||
// A test alert is owed no matter what the live state says.
|
})
|
||||||
assert.equal(stillWorth({ ...candidate, test: true }, live({ a: { running: true } })), 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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
// The browser-local trigger switches. Storage is foreign input: it can be
|
// The browser-local preferences: the three trigger switches and the popup
|
||||||
// absent, hold another version's shape, or refuse to write, and none of those
|
// lifetime. Storage is foreign input: it can be absent, hold another version's
|
||||||
// may stop the plugin from working with its defaults.
|
// shape, or refuse to write, and none of that may stop the plugin — nor may
|
||||||
|
// writing one preference ever clear another.
|
||||||
import { afterEach, test } from 'node:test'
|
import { afterEach, test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js'
|
import {
|
||||||
|
STORAGE_KEY,
|
||||||
|
readStoredDismissMs,
|
||||||
|
readStoredKinds,
|
||||||
|
writeStoredDismissMs,
|
||||||
|
writeStoredKinds,
|
||||||
|
} from '../../src/client/core/storage.js'
|
||||||
|
import { TOAST_DURATION_MS } from '../../src/client/constants.js'
|
||||||
|
|
||||||
/** Install a storage double, optionally one that throws. */
|
/** Install a storage double, optionally one that throws. */
|
||||||
function useStorage(behaviour) {
|
function useStorage(behaviour) {
|
||||||
@@ -26,20 +34,22 @@ afterEach(() => {
|
|||||||
delete globalThis.localStorage
|
delete globalThis.localStorage
|
||||||
})
|
})
|
||||||
|
|
||||||
test('everything is on when this browser has no stored choice', () => {
|
test('everything is on, with the shipped popup lifetime, when this browser has no stored choice', () => {
|
||||||
useStorage()
|
useStorage()
|
||||||
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('without storage at all the defaults still apply', () => {
|
test('without storage at all the defaults still apply', () => {
|
||||||
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
|
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
|
||||||
|
assert.doesNotThrow(() => writeStoredDismissMs(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
|
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
|
||||||
const data = useStorage()
|
const data = useStorage()
|
||||||
writeStoredKinds({ completion: false, approval: true, question: false })
|
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 })
|
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
|
||||||
|
|
||||||
data.set(STORAGE_KEY, '{ not json')
|
data.set(STORAGE_KEY, '{ not json')
|
||||||
@@ -52,10 +62,41 @@ test('a stored switch survives a round trip, and unknown or broken values do not
|
|||||||
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the popup lifetime survives a round trip, including "never"', () => {
|
||||||
|
const data = useStorage()
|
||||||
|
writeStoredDismissMs(30000)
|
||||||
|
assert.equal(readStoredDismissMs(), 30000)
|
||||||
|
|
||||||
|
writeStoredDismissMs(0)
|
||||||
|
assert.equal(readStoredDismissMs(), 0, 'zero means the card waits for the user')
|
||||||
|
|
||||||
|
// A value this version does not offer is ignored rather than trusted.
|
||||||
|
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: 1234 }))
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
|
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: -1 }))
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
|
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: '6000' }))
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('writing one preference keeps every other one', () => {
|
||||||
|
useStorage()
|
||||||
|
writeStoredKinds({ completion: false, approval: false, question: false })
|
||||||
|
writeStoredDismissMs(0)
|
||||||
|
assert.deepEqual(readStoredKinds(), { completion: false, approval: false, question: false })
|
||||||
|
assert.equal(readStoredDismissMs(), 0)
|
||||||
|
|
||||||
|
// …and the other way round: the switches do not reset the lifetime.
|
||||||
|
writeStoredKinds({ completion: true, approval: true, question: true })
|
||||||
|
assert.equal(readStoredDismissMs(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('storage that refuses to be read or written never throws', () => {
|
test('storage that refuses to be read or written never throws', () => {
|
||||||
useStorage('read-throws')
|
useStorage('read-throws')
|
||||||
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
|
||||||
|
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
|
||||||
|
|
||||||
useStorage('write-throws')
|
useStorage('write-throws')
|
||||||
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
|
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
|
||||||
|
assert.doesNotThrow(() => writeStoredDismissMs(3000))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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-chips', 'dsn-chip', '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}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// The popup stack's lifetime: the user's choice decides it, and "never" really
|
||||||
|
// means the card waits for the user rather than for a timer.
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { createStore } from '../../src/client/core/store.js'
|
||||||
|
import { createToastStack } from '../../src/client/core/toasts.js'
|
||||||
|
import { createCopy } from '../../src/client/core/copy.js'
|
||||||
|
import { createTranslator } from '../../src/client/i18n/index.js'
|
||||||
|
import { TOAST_DURATION_MS } from '../../src/client/constants.js'
|
||||||
|
|
||||||
|
const tr = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
||||||
|
const candidate = { kind: 'completion', sessionId: 's1', title: '标题', detail: '', pendingKind: '' }
|
||||||
|
|
||||||
|
/** A stack over a store whose only interesting field is the popup lifetime. */
|
||||||
|
function stack(dismissAfterMs) {
|
||||||
|
const store = createStore({ dismissAfterMs, toasts: [] })
|
||||||
|
return { store, toasts: createToastStack({ store, copy: createCopy(tr) }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (store) => store.getSnapshot().toasts.map((toast) => toast.id)
|
||||||
|
|
||||||
|
test('a card closes itself after the configured lifetime', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(6000)
|
||||||
|
toasts.showToast(candidate)
|
||||||
|
|
||||||
|
assert.equal(ids(store).length, 1)
|
||||||
|
context.mock.timers.tick(5999)
|
||||||
|
assert.equal(ids(store).length, 1, 'the card must outlive its countdown')
|
||||||
|
context.mock.timers.tick(1)
|
||||||
|
assert.deepEqual(ids(store), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lifetime of zero leaves the card until it is dismissed', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(0)
|
||||||
|
toasts.showToast(candidate)
|
||||||
|
|
||||||
|
context.mock.timers.tick(60 * 60 * 1000)
|
||||||
|
assert.equal(ids(store).length, 1, 'nothing may close a "never" card')
|
||||||
|
|
||||||
|
toasts.closeToast(ids(store)[0])
|
||||||
|
assert.deepEqual(ids(store), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hovering holds the card, and the lifetime chosen while held applies afterwards', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(3000)
|
||||||
|
toasts.showToast(candidate)
|
||||||
|
const id = ids(store)[0]
|
||||||
|
|
||||||
|
toasts.holdToast(id)
|
||||||
|
context.mock.timers.tick(30 * 1000)
|
||||||
|
assert.equal(ids(store).length, 1, 'a held card has no timer left')
|
||||||
|
|
||||||
|
// The settings page switches to "never" while the pointer is still on the card.
|
||||||
|
store.set({ ...store.getSnapshot(), dismissAfterMs: 0 })
|
||||||
|
toasts.armToast(id)
|
||||||
|
context.mock.timers.tick(60 * 60 * 1000)
|
||||||
|
assert.equal(ids(store).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lifetime change applies to the cards already on screen, but never to a held one', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(30000)
|
||||||
|
toasts.showToast(candidate)
|
||||||
|
toasts.showToast({ ...candidate, sessionId: 's2' })
|
||||||
|
const [newest, oldest] = ids(store)
|
||||||
|
toasts.holdToast(oldest)
|
||||||
|
|
||||||
|
// The settings page shortens the lifetime while both cards are visible.
|
||||||
|
store.set({ ...store.getSnapshot(), dismissAfterMs: 3000 })
|
||||||
|
toasts.rearmAll()
|
||||||
|
context.mock.timers.tick(3000)
|
||||||
|
assert.deepEqual(ids(store), [oldest], 'the visible card followed the new choice, the held one did not')
|
||||||
|
|
||||||
|
toasts.armToast(oldest)
|
||||||
|
context.mock.timers.tick(3000)
|
||||||
|
assert.deepEqual(ids(store), [], 'once the pointer leaves, the held card counts down again')
|
||||||
|
void newest
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a lifetime this version cannot use falls back to the shipped default', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(undefined)
|
||||||
|
toasts.showToast(candidate)
|
||||||
|
|
||||||
|
context.mock.timers.tick(TOAST_DURATION_MS)
|
||||||
|
assert.deepEqual(ids(store), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the stack never grows past its bound, however long the cards live', (context) => {
|
||||||
|
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||||
|
const { store, toasts } = stack(0)
|
||||||
|
for (let index = 0; index < 5; index += 1) toasts.showToast({ ...candidate, sessionId: `s${index}` })
|
||||||
|
|
||||||
|
assert.equal(ids(store).length, 3)
|
||||||
|
assert.deepEqual(ids(store), ['dsn-5', 'dsn-4', 'dsn-3'], 'newest first')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user