10 Commits
25 changed files with 1197 additions and 369 deletions
+38
View File
@@ -2,6 +2,44 @@
版本说明按倒序排列。提交信息遵循 [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
- 重构:**客户端源码拆成模块,`client.js` 从「手写文件」变成「构建产物」**。原来的 `client.js` 是 1452 行单文件,通知引擎、轻弹窗、插件页、观察器、字典、样式、图标全塞在一个 `apply` 里,改任何一处都要在同一个文件里上下翻。现在:
+9 -7
View File
@@ -35,9 +35,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
轻弹窗的用法:
- 点「查看」跳到该会话;
- 6 秒后自动消失;鼠标停在上面期间不消失,移开后重新计时;
- **默认 6 秒**后自动消失(可在插件页改成 3 / 10 / 30 秒,或设成**永不关闭**);鼠标停在上面期间都不消失,移开后重新计时;
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
- 同屏最多叠 3 条,更早的会被挤掉。
- 同屏最多叠 3 条,更早的会被挤掉(即使设成永不关闭也一样,不会越堆越多);
- 轻弹窗是 DSH 的浮层卡片样式(和菜单、弹窗同一层底色与阴影),**跟随 DSH 的深色 / 浅色模式**。
## 设置
@@ -46,8 +47,9 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
| 分组 | 内容 |
| --- | --- |
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
| 系统通知权限 | 当前权限状态;尚未授权时点「允许通知」请求授权(浏览器要求必须由你手动点击),授权成功后会自动发一条测试提醒。权限被系统层关闭时,会按当前平台告诉你去哪里打开 |
| 运行状态 | 窗口现在是前台还是后台(即会走哪条通道)、插件是否真的在读会话状态、**最近一次提醒走的通道、系统是否确认弹出、时间**(本机时间),以及还有几条提醒在等你回到窗口时补发;旁边「测试提醒」按钮发一条测试提醒 |
| 轻弹窗 | **自动关闭时间**:3 / 6 / 10 / 30 秒,或**永不**(永不时要自己点 ✕ 或按 `Esc`);鼠标停在卡片上时都不会消失 |
| 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 |
| 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 |
- 已经授权时,权限行的按钮变成「测试系统通知」:它直接走系统通知通道(窗口在前台也照发),专门用来验证系统通知本身是否可用。
- 开关只影响提醒,不改 DSH 本身的任何设置。
@@ -59,10 +61,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
```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 秒内只投递一条,多个会话同时完成不会刷屏。
- **完成提醒有 400ms 的确认窗口**:如果那一轮马上又跑起来,就不报了。
- **同一会话的完成提醒会覆盖上一条**(通知带 `tag`),不叠加堆积。
+303 -134
View File
@@ -54,6 +54,7 @@ var THROTTLE_MS = 1500;
var SETTLE_MS = 400;
var TOAST_DURATION_MS = 6e3;
var TOAST_LIMIT = 3;
var DISMISS_CHOICES = [3e3, 6e3, 1e4, 3e4, 0];
var RAISED_LIMIT = 8;
var REPLAY_LIMIT = 8;
var REPLAY_TTL_MS = 30 * 60 * 1e3;
@@ -64,7 +65,6 @@ var zh = {
"notification.completion": "会话已完成",
"notification.approval": "需要授权",
"notification.question": "需要回答",
"body.completion": "{title} 已完成这一轮回答",
"body.approval": "{title}:工具 {tool} 正在等待你的授权",
"body.approvalPlain": "{title}:有工具正在等待你的授权",
"body.question": "{title}:正在等待你的回答",
@@ -72,7 +72,6 @@ var zh = {
"body.untitled": "未命名会话",
"toast.view": "查看",
"toast.dismiss": "关闭",
"settings.title": "会话通知",
"settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。",
"settings.permission.granted": "系统通知已开启",
"settings.permission.default": "尚未授予系统通知权限",
@@ -91,19 +90,27 @@ var zh = {
"settings.health.noHooks": "未收到会话状态(通知不会触发)",
"settings.health.error": "监听会话状态出错:{message}",
"settings.health.starting": "正在等待会话列表",
"settings.kinds.label": "提醒内容",
"settings.kind.completion": "完成",
"settings.kind.approval": "授权",
"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.popup": "轻弹窗",
"config.section.permission": "系统通知权限",
"config.section.status": "运行状态",
"config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。",
"config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。",
"config.dismiss.note": "鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。",
"config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。",
"config.testAny": "测试提醒",
"config.testSystem": "测试系统通知",
"config.diag.window": "窗口状态",
"config.diag.watch": "会话监听",
"config.diag.inFront": "在前台(会走轻弹窗)",
"config.diag.away": "不在前台(会走系统通知)",
"config.diag.last": "最近一次投递",
@@ -114,8 +121,9 @@ var zh = {
"config.diag.replayDelivery": "应用内轻弹窗(回到窗口时补发)",
"config.diag.popup": "应用内轻弹窗",
"config.diag.refused": "系统通知被拒绝,改用了轻弹窗",
"config.diag.replay": "等你回到窗口时补发:{count} 条",
"config.diag.replayNone": "没有待补发的提醒"
"config.diag.replay": "待补发提醒",
"config.diag.replayValue": "{count} 条",
"config.diag.replayNone": "没有"
};
// src/client/i18n/en.js
@@ -123,7 +131,6 @@ var en = {
"notification.completion": "Conversation finished",
"notification.approval": "Approval required",
"notification.question": "Answer needed",
"body.completion": "{title} finished this round",
"body.approval": "{title}: {tool} is waiting for your approval",
"body.approvalPlain": "{title}: a tool is waiting for your approval",
"body.question": "{title}: waiting for your answer",
@@ -131,7 +138,6 @@ var en = {
"body.untitled": "Untitled conversation",
"toast.view": "View",
"toast.dismiss": "Dismiss",
"settings.title": "Session notifications",
"settings.description": "A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.",
"settings.permission.granted": "System notifications are on",
"settings.permission.default": "System notification permission has not been granted",
@@ -150,19 +156,27 @@ var en = {
"settings.health.noHooks": "No session status received (notifications will not fire)",
"settings.health.error": "Watching session status failed: {message}",
"settings.health.starting": "Waiting for the session list",
"settings.kinds.label": "Notify about",
"settings.kind.completion": "Finished",
"settings.kind.approval": "Approval",
"settings.kind.question": "Questions",
"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.popup": "Popup",
"config.section.permission": "System notification permission",
"config.section.status": "Status",
"config.intro": "Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.",
"config.saveNote": "The switches are stored in this browser and survive reinstalling the plugin.",
"config.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.testAny": "Test alert",
"config.testSystem": "Test system notification",
"config.diag.window": "Window",
"config.diag.watch": "Session watch",
"config.diag.inFront": "in front (popup is used)",
"config.diag.away": "not in front (system notification is used)",
"config.diag.last": "Last delivery",
@@ -173,8 +187,9 @@ var en = {
"config.diag.replayDelivery": "in-app popup (replayed when you came back)",
"config.diag.popup": "in-app popup",
"config.diag.refused": "system channel refused, popup was used instead",
"config.diag.replay": "waiting for you to come back: {count}",
"config.diag.replayNone": "nothing waiting to be replayed"
"config.diag.replay": "Replay",
"config.diag.replayValue": "{count} waiting",
"config.diag.replayNone": "None"
};
// src/client/i18n/index.js
@@ -304,24 +319,41 @@ function createStore(initial) {
// src/client/core/storage.js
var STORAGE_KEY = "dsh-plugin/session-notify";
function readStoredKinds() {
const kinds = { completion: true, approval: true, question: true };
function defaults() {
return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS };
}
function readStored() {
const preferences = defaults();
try {
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);
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 {
}
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) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds));
} catch {
}
writeStored(kinds);
}
function readStoredDismissMs() {
return readStored().dismissAfterMs;
}
function writeStoredDismissMs(ms) {
writeStored({ dismissAfterMs: ms });
}
// src/client/core/copy.js
@@ -329,7 +361,10 @@ function createCopy(t) {
const copyFor = (candidate) => {
const title = candidate.title === "" ? t("body.untitled") : candidate.title;
if (candidate.kind === "completion") {
return { kind: "completion", title: t("notification.completion"), body: t("body.completion", { title }) };
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") {
const question = candidate.detail;
@@ -339,9 +374,6 @@ function createCopy(t) {
body: question === "" ? t("body.question", { title }) : `${title} · ${question}`
};
}
if (candidate.pendingKind === "plan-review") {
return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) };
}
const tool = candidate.detail;
return {
kind: "approval",
@@ -349,20 +381,26 @@ function createCopy(t) {
body: tool === "" ? t("body.approvalPlain", { title }) : t("body.approval", { title, tool })
};
};
const describeDelivery = (last, tr) => {
if (last === null || last === void 0) return tr("config.diag.none");
const deliveryFacts = (last, tr) => {
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");
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 });
return { copyFor, describeDelivery, describeReplay };
const replayValue = (count, tr) => count === 0 ? tr("config.diag.replayNone") : tr("config.diag.replayValue", { count });
return { copyFor, deliveryFacts, replayValue };
}
// src/client/core/toasts.js
function createToastStack({ store, copy }) {
const timers = /* @__PURE__ */ new Map();
const held = /* @__PURE__ */ new Set();
let seq = 0;
const lifetime = () => {
const configured = store.getSnapshot().dismissAfterMs;
return typeof configured === "number" && configured >= 0 ? configured : TOAST_DURATION_MS;
};
const closeToast = (id) => {
held.delete(id);
const timer = timers.get(id);
if (timer !== void 0) {
clearTimeout(timer);
@@ -373,16 +411,26 @@ function createToastStack({ store, copy }) {
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) });
};
const armToast = (id) => {
held.delete(id);
const timer = timers.get(id);
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) => {
held.add(id);
const timer = timers.get(id);
if (timer === void 0) return;
clearTimeout(timer);
timers.delete(id);
};
const rearmAll = () => {
for (const toast of store.getSnapshot().toasts) {
if (!held.has(toast.id)) armToast(toast.id);
}
};
const showToast = (candidate) => {
const notice = copy.copyFor(candidate);
seq += 1;
@@ -401,6 +449,7 @@ function createToastStack({ store, copy }) {
clearTimeout(timer);
timers.delete(dropped.id);
}
held.delete(dropped.id);
}
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) });
armToast(toast.id);
@@ -408,8 +457,9 @@ function createToastStack({ store, copy }) {
const dispose = () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
held.clear();
};
return { showToast, closeToast, armToast, holdToast, dispose };
return { showToast, closeToast, armToast, holdToast, rearmAll, dispose };
}
// src/client/core/replay.js
@@ -548,13 +598,18 @@ function isOnScreen(sessionId, list) {
}
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;
if (candidate.test === true || list === void 0) return true;
if (list === void 0) return true;
const summary = list.byId?.[candidate.sessionId];
if (summary === void 0) return true;
const status = live.status;
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running;
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running;
return running !== true;
}
@@ -582,28 +637,32 @@ function createDelivery({ store, copy, live, toasts, system, replay }) {
const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) {
const now = Date.now();
if (now - lastDelivery < THROTTLE_MS) return;
if (now - lastDelivery < THROTTLE_MS) return false;
lastDelivery = now;
}
if (!windowIsAway()) {
if (isOnScreen(candidate.sessionId, live.list)) return;
if (isOnScreen(candidate.sessionId, live.list)) return true;
toasts.showToast(candidate);
recordDelivery("popup", candidate);
return;
return true;
}
const result = system.notifySystem(candidate, candidate.test === true);
if (result.outcome === "raised") {
recordDelivery("system", candidate, result);
if (candidate.test !== true) replay.holdForReplay(candidate, result);
return;
return true;
}
toasts.showToast(candidate);
recordDelivery(`system-refused-${result.outcome}`, candidate);
return true;
};
const flushSettle = () => {
settleTimer = 0;
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);
};
const queue = (candidate) => {
@@ -745,7 +804,13 @@ function createActions({ store, t, delivery, system, toasts, publish }) {
writeStoredKinds(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
@@ -878,33 +943,50 @@ function seatTranslator(props, fallback) {
var CSS = `
.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
@media (max-width:520px){.dsn-stack{right:12px}}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;animation:dsn-toast-in var(--ds-transition-duration,.2s) var(--ds-ease-in-out,ease-out)}
@keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}}
@media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}}
.dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)}
.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}
.dsn-toast-icon.is-question{color:var(--dsw-alias-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-title{font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none}
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-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:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-sections{display:flex;flex-direction:column;gap:24px}
.dsn-section{display:flex;flex-direction:column;gap:10px}
.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
.dsn-row:last-child{border-bottom:0}
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsn-group{display:flex;flex-direction:column;gap:2px}
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-list{display:flex;flex-direction:column}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)}
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0}
.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
.dsn-status-error{color:var(--dsw-alias-state-error-primary)}
.dsn-chips{display:flex;align-items:center;gap:6px;flex:none}
.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-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[aria-checked='true']{background:var(--dsw-alias-brand-primary)}
.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-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)}
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsn-button:disabled{cursor:not-allowed;opacity:.4}
.dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}
.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88}
`;
// src/client/ui/ToastLayer.js
@@ -986,12 +1070,12 @@ function ToastLayer(props) {
}
// 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 h3 = React4.createElement;
function KindRow(props) {
function DismissRow(props) {
return h3(
"div",
{ className: "dsn-row" },
@@ -1000,7 +1084,34 @@ function KindRow(props) {
{ className: "dsn-row-text" },
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",
role: "switch",
className: "dsn-switch",
@@ -1008,99 +1119,155 @@ function KindRow(props) {
"aria-label": props.label,
disabled: props.disabled,
onClick: props.onToggle
}, h3("span", { className: "dsn-thumb" }))
}, h4("span", { className: "dsn-thumb" }))
);
}
// src/client/ui/ConfigSection.js
var h4 = React5.createElement;
var h5 = React6.createElement;
function ConfigSection(props) {
const snapshot = useOwnStore(props.store);
const tr = seatTranslator(props, props.fallbackT);
if (props.view === "summary") return h4(React5.Fragment, null, tr("settings.description"));
if (props.view === "summary") return h5(React6.Fragment, null, tr("settings.description"));
const permission = snapshot.permission;
const supported = permission !== "unsupported";
const failed = permission === "denied" || snapshot.promptError !== "";
const statusKey = permission === "granted" ? "settings.permission.granted" : permission === "denied" ? "settings.permission.denied" : supported ? "settings.permission.default" : "settings.permission.unsupported";
const permissionText = snapshot.promptError === "" ? `${tr(statusKey)}${permission === "denied" || !supported ? ` · ${tr(`settings.permission.hint.${props.platform}`)}` : ""}` : snapshot.promptError;
const promptFailed = snapshot.promptError !== "";
const permissionText = promptFailed ? snapshot.promptError : tr(statusKey);
const dotClass = permission === "granted" ? "" : failed ? " is-error" : supported ? " is-warn" : " is-muted";
const permissionNote = promptFailed ? "" : permission === "denied" || !supported ? tr(`settings.permission.hint.${props.platform}`) : permission === "granted" ? tr("config.testHint") : tr("settings.permission.hint.plain");
const health = snapshot.health;
const healthText = health.state === "watching" ? tr("settings.health.watching") : health.state === "noHooks" ? tr("settings.health.noHooks") : health.state === "error" ? tr("settings.health.error", { message: health.message }) : tr("settings.health.starting");
const healthFailed = health.state !== "watching" && health.state !== "starting";
return h4(
React5.Fragment,
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr);
return h5(
React6.Fragment,
null,
h4("style", null, CSS),
h4(
h5("style", null, CSS),
h5(
"div",
{ className: "dsn-sections" },
// The page header already shows this bundle's title and description, so
// this section opens with what the header cannot say: how alerts are
// carried, and what this form changes.
h4(
{ className: "dsn-page" },
// The page header already shows this bundle's title and description, so this
// lede says what the header cannot: how an alert travels, and that the
// channel is chosen at delivery time.
h5("p", { className: "dsn-lede" }, tr("config.intro")),
h5(
"section",
{ className: "dsn-section" },
h4("p", { className: "dsn-section-desc" }, tr("config.intro"))
),
h4(
"section",
{ className: "dsn-section" },
h4("h3", { className: "dsn-section-title" }, tr("config.section.kinds")),
KINDS.map((kind) => h4(KindRow, {
key: kind,
label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true,
disabled: false,
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true)
})),
h4("p", { className: "dsn-section-desc" }, tr("config.saveNote"))
),
h4(
"section",
{ className: "dsn-section" },
h4("h3", { className: "dsn-section-title" }, tr("config.section.permission")),
h4(
{ className: "dsn-group" },
h5("h3", { className: "dsn-group-title" }, tr("config.section.kinds")),
h5(
"div",
{ className: "dsn-row" },
h4(
"div",
{ className: "dsn-row-text" },
h4("p", {
className: `dsn-status${failed ? " dsn-status-error" : ""}`,
role: failed ? "alert" : "status"
}, permissionText)
),
h4("button", {
type: "button",
className: "dsn-button",
disabled: !supported,
onClick: permission === "granted" ? props.testSystem : props.ask
}, tr(!supported ? "settings.action.unsupported" : permission === "granted" ? "settings.action.testSystem" : "settings.action.allow"))
{ className: "dsn-list" },
KINDS.map((kind) => h5(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.testHint"))
h5("p", { className: "dsn-group-note" }, tr("config.saveNote"))
),
h4(
h5(
"section",
{ className: "dsn-section" },
h4("h3", { className: "dsn-section-title" }, tr("config.section.status")),
h4(
{ className: "dsn-group" },
h5("h3", { className: "dsn-group-title" }, tr("config.section.popup")),
h5(
"div",
{ className: "dsn-row" },
h4(
{ className: "dsn-list" },
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",
{ className: "dsn-row-text" },
h4("p", { className: "dsn-status" }, `${tr("config.diag.window")}: ${tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")}`),
h4("p", {
className: `dsn-status${healthFailed ? " dsn-status-error" : ""}`,
role: healthFailed ? "alert" : "status"
}, healthText),
h4("p", { className: "dsn-status" }, props.copy.describeDelivery(snapshot.lastDelivery, tr)),
h4("p", { className: "dsn-status" }, props.copy.describeReplay(props.replayCount(), tr))
{ className: "dsn-row" },
h5(
"div",
{ className: "dsn-row-text" },
h5(
"span",
{
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", {
type: "button",
className: "dsn-button",
onClick: props.testAny
}, tr("settings.action.testAny"))
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")),
h5(
"dd",
{ className: "dsn-fact" },
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
var React6 = __toESM(require("react"), 1);
var React7 = __toESM(require("react"), 1);
function NotifyObserver(props) {
useWindowState();
const useSessions = props.useSessions;
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 status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0;
React6.useEffect(() => {
React7.useEffect(() => {
try {
if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") {
props.observation.reportHealth({ state: "noHooks", message: "" });
@@ -1143,6 +1310,7 @@ function apply(ctx) {
const store = createStore({
permission: notificationPermission(),
kinds: readStoredKinds(),
dismissAfterMs: readStoredDismissMs(),
toasts: [],
promptError: "",
health: { state: "starting", message: "" },
@@ -1245,7 +1413,8 @@ function apply(ctx) {
ask: actions.requestPermission,
testAny: actions.sendTest,
testSystem: actions.sendTestSystem,
setKind: actions.setKindEnabled
setKind: actions.setKindEnabled,
setDismiss: actions.setDismissAfter
})
}, ConfigSection));
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dsh-plugin/session-notify",
"version": "1.0.7",
"version": "1.0.10",
"private": true,
"type": "module",
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
+8 -1
View File
@@ -28,10 +28,17 @@ export const THROTTLE_MS = 1500
/** Settle delay before a completion is delivered, so a resumed run stays silent. */
export const SETTLE_MS = 400
/** In-app popup lifetime, and how many may stack. */
/** Default in-app popup lifetime, and how many may stack. */
export const TOAST_DURATION_MS = 6000
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. */
export const RAISED_LIMIT = 8
+17 -2
View File
@@ -6,7 +6,7 @@
*/
import { notificationApi, notificationPermission } from '../platform.js'
import { writeStoredKinds } from './storage.js'
import { writeStoredDismissMs, writeStoredKinds } from './storage.js'
import { text } from './log.js'
/**
@@ -83,5 +83,20 @@ export function createActions({ store, t, delivery, system, toasts, publish }) {
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
View File
@@ -8,14 +8,24 @@
/**
* Build the copy table for one translate function.
* @param t - the translate function every string here goes through.
* @returns `copyFor` (candidate → notice) and the two diagnosis renderers.
* @returns `copyFor` (candidate → notice), `deliveryFacts` and `replayValue`
* (the two status values the settings page shows).
*/
export function createCopy(t) {
/** Build the delivered copy for one candidate. */
const copyFor = (candidate) => {
const title = candidate.title === '' ? t('body.untitled') : candidate.title
if (candidate.kind === 'completion') {
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) }
// 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') {
const question = candidate.detail
@@ -25,15 +35,6 @@ export function createCopy(t) {
body: question === '' ? t('body.question', { title }) : `${title} · ${question}`,
}
}
// Kept in v1.0.6's order, which makes this branch unreachable today: the
// observer queues a plan review as `kind: 'question'`, so the branch above
// already answered with the plan's own text. Restoring the documented copy
// (`body.planReview`, "计划正在等待你确认") means testing `pendingKind`
// before `kind` — a user-visible behavior change, so it is deliberately NOT
// part of this refactor. `tests/unit/copy.test.js` pins today's output.
if (candidate.pendingKind === 'plan-review') {
return { kind: 'question', title: t('notification.question'), body: t('body.planReview', { title }) }
}
const tool = candidate.detail
return {
kind: 'approval',
@@ -42,9 +43,14 @@ export function createCopy(t) {
}
}
/** Describe the newest alert's fate in one line. */
const describeDelivery = (last, tr) => {
if (last === null || last === undefined) return tr('config.diag.none')
/**
* Split the newest delivery into the three facts the status list shows.
* @param last - `store.lastDelivery`.
* @param tr - the translate function the outcome label is rendered with.
* @returns `{ at, outcome, title }`, or null when nothing was ever delivered.
*/
const deliveryFacts = (last, tr) => {
if (last === null || last === undefined) return null
const outcome = last.outcome.startsWith('system-refused')
? tr('config.diag.refused')
: last.outcome === 'system'
@@ -56,13 +62,13 @@ export function createCopy(t) {
: 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 ?? '' }
}
/** Say how many away-channel alerts are still waiting to be replayed in-app. */
const describeReplay = (count, tr) => (
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replay', { count })
/** How many away-channel alerts are still waiting for the user to come back. */
const replayValue = (count, tr) => (
count === 0 ? tr('config.diag.replayNone') : tr('config.diag.replayValue', { count })
)
return { copyFor, describeDelivery, describeReplay }
return { copyFor, deliveryFacts, replayValue }
}
+18 -6
View File
@@ -7,7 +7,7 @@
import { SETTLE_MS, THROTTLE_MS } from '../constants.js'
import { windowIsAway } from '../platform.js'
import { isOnScreen, stillWorth } from './session.js'
import { isOnScreen, stillOwed } from './session.js'
/**
* Build the delivery path.
@@ -67,29 +67,33 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
* never actually receives.
* @param candidate - the alert to deliver.
* @param ignoreThrottle - deliver immediately, bypassing the flood guard.
* @returns `false` when the flood guard suppressed this alert, `true` when the
* delivery path itself ran (including a deliberate skip for the conversation
* on screen).
*/
const deliver = (candidate, ignoreThrottle) => {
if (ignoreThrottle !== true) {
const now = Date.now()
if (now - lastDelivery < THROTTLE_MS) return
if (now - lastDelivery < THROTTLE_MS) return false
lastDelivery = now
}
if (!windowIsAway()) {
if (isOnScreen(candidate.sessionId, live.list)) return
if (isOnScreen(candidate.sessionId, live.list)) return true
toasts.showToast(candidate)
recordDelivery('popup', candidate)
return
return true
}
const result = system.notifySystem(candidate, candidate.test === true)
if (result.outcome === 'raised') {
recordDelivery('system', candidate, result)
if (candidate.test !== true) replay.holdForReplay(candidate, result)
return
return true
}
// A test alert still has to reach the user, and so does a real one when
// the system channel refuses: the popup is the channel that remains.
toasts.showToast(candidate)
recordDelivery(`system-refused-${result.outcome}`, candidate)
return true
}
/**
@@ -98,11 +102,19 @@ export function createDelivery({ store, copy, live, toasts, system, replay }) {
* One candidate per tick keeps the throttle meaningful, and anything still
* queued arms its own follow-up tick — a burst used to leave every candidate
* after the first stranded in the queue with no timer to flush it.
*
* A pending interaction the flood guard holds back is put back in the queue
* instead of being dropped: only the user can clear it, so it stays owed, and
* the next tick retries it. Flood control still drops the completions it was
* written for.
*/
const flushSettle = () => {
settleTimer = 0
const candidate = pending.shift()
if (candidate !== undefined && 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)
}
+34 -12
View File
@@ -8,9 +8,13 @@
import { PENDING_KINDS } from '../constants.js'
/**
* Discriminate a pending interaction the client published. Only the three
* domains the Harness itself renders are served; anything else is ignored
* rather than guessed at.
* Discriminate a pending interaction the client published.
*
* The domains that can ask the user something are `@deepseek-ai/dsh-client-ui-approval`
* (a tool authorisation) and `@deepseek-ai/dsh-client-ui-user-questions` (a
* question batch, or the plan review its `planReviewOf` marks); each publishes a
* literal `kind` on its pending value. Anything else is ignored rather than
* guessed at.
* @param value - `status.pendingInteraction`.
* @returns a served kind, or undefined.
*/
@@ -55,21 +59,39 @@ export function isOnScreen(sessionId, list) {
}
/**
* Whether a queued candidate is still worth delivering. Read from the LIVE
* snapshot rather than the one captured when the transition was seen: a
* conversation that resumed in the settle window owes no alert, and the
* snapshot the transition was derived from still carries the pre-transition
* `running` flag of the session record.
* Whether a queued candidate is still owed, read from the LIVE snapshot rather
* than the one captured when the transition was seen.
*
* The two triggers are owed for different reasons, so they are checked for
* different things:
*
* - A **pending interaction** (approval, question, plan review) is owed until
* the user answers it. It is deliberately NOT gated on the run state: the
* session is `running` while it waits — the client's own `observeRunning`
* only mirrors `api-session/status` and says nothing about a pending request —
* so a running gate would silently drop every approval and question alert.
* - A **completion** is owed only while the conversation stayed idle. A
* conversation that resumed inside the settle window owes no alert, and the
* snapshot the transition was derived from still carries the pre-transition
* `running` flag of the session record.
* @param candidate - the alert waiting for its settle tick.
* @param live - the freshest `{ list, status }` the observer has seen.
* @returns whether the alert is still owed.
*/
export function 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
if (candidate.test === true || list === undefined) return true
if (list === undefined) return true
const summary = list.byId?.[candidate.sessionId]
if (summary === undefined) return true
const status = live.status
const running = status?.get?.(candidate.sessionId)?.running ?? summary.running
const running = live.status?.get?.(candidate.sessionId)?.running ?? summary.running
return running !== true
}
+62 -18
View File
@@ -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
*/
import { KINDS } from '../constants.js'
import { DISMISS_CHOICES, KINDS, TOAST_DURATION_MS } from '../constants.js'
/** Storage key for this package's own preferences. */
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
* in this origin's own storage instead of the profile's patch file: the plugin
* writes nothing into DSH's configuration, and every read is defensive because
* storage can be unavailable or hold something an older version wrote.
* @returns the stored switches, defaulting to everything on.
* A field is only taken when it has the shape this version understands: a
* boolean per trigger, and a popup lifetime from {@link DISMISS_CHOICES}. Anything
* else keeps the default, so a foreign or older value can never produce a state
* the plugin cannot render.
* @returns the effective preferences.
*/
export function readStoredKinds() {
const kinds = { completion: true, approval: true, question: true }
function readStored() {
const preferences = defaults()
try {
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)
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 {
/* 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. */
export function writeStoredKinds(kinds) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds))
} catch {
/* a refused write leaves the switches live for this page only */
}
writeStored(kinds)
}
/**
* 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 })
}
+40 -3
View File
@@ -1,6 +1,11 @@
/**
* 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
*/
@@ -13,10 +18,19 @@ import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js'
*/
export function createToastStack({ store, copy }) {
const timers = new Map()
/** Cards the pointer is resting on: they carry no timer until it leaves. */
const held = new Set()
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. */
const closeToast = (id) => {
held.delete(id)
const timer = timers.get(id)
if (timer !== undefined) {
clearTimeout(timer)
@@ -27,21 +41,42 @@ export function createToastStack({ store, copy }) {
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) => {
held.delete(id)
const timer = timers.get(id)
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. */
const holdToast = (id) => {
held.add(id)
const timer = timers.get(id)
if (timer === undefined) return
clearTimeout(timer)
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
* and its detail as the description, so the heading says what happened and
@@ -65,6 +100,7 @@ export function createToastStack({ store, copy }) {
clearTimeout(timer)
timers.delete(dropped.id)
}
held.delete(dropped.id)
}
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) })
armToast(toast.id)
@@ -74,7 +110,8 @@ export function createToastStack({ store, copy }) {
const dispose = () => {
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
held.clear()
}
return { showToast, closeToast, armToast, holdToast, dispose }
return { showToast, closeToast, armToast, holdToast, rearmAll, dispose }
}
+12 -5
View File
@@ -12,7 +12,6 @@ export const en = {
'notification.completion': 'Conversation finished',
'notification.approval': 'Approval required',
'notification.question': 'Answer needed',
'body.completion': '{title} finished this round',
'body.approval': '{title}: {tool} is waiting for your approval',
'body.approvalPlain': '{title}: a tool is waiting for your approval',
'body.question': '{title}: waiting for your answer',
@@ -20,7 +19,6 @@ export const en = {
'body.untitled': 'Untitled conversation',
'toast.view': 'View',
'toast.dismiss': 'Dismiss',
'settings.title': 'Session notifications',
'settings.description': 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.',
'settings.permission.granted': 'System notifications are on',
'settings.permission.default': 'System notification permission has not been granted',
@@ -39,19 +37,27 @@ export const en = {
'settings.health.noHooks': 'No session status received (notifications will not fire)',
'settings.health.error': 'Watching session status failed: {message}',
'settings.health.starting': 'Waiting for the session list',
'settings.kinds.label': 'Notify about',
'settings.kind.completion': 'Finished',
'settings.kind.approval': 'Approval',
'settings.kind.question': 'Questions',
'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.popup': 'Popup',
'config.section.permission': 'System notification permission',
'config.section.status': 'Status',
'config.intro': 'Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.',
'config.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.',
'config.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.testAny': 'Test alert',
'config.testSystem': 'Test system notification',
'config.diag.window': 'Window',
'config.diag.watch': 'Session watch',
'config.diag.inFront': 'in front (popup is used)',
'config.diag.away': 'not in front (system notification is used)',
'config.diag.last': 'Last delivery',
@@ -62,6 +68,7 @@ export const en = {
'config.diag.replayDelivery': 'in-app popup (replayed when you came back)',
'config.diag.popup': 'in-app popup',
'config.diag.refused': 'system channel refused, popup was used instead',
'config.diag.replay': 'waiting for you to come back: {count}',
'config.diag.replayNone': 'nothing waiting to be replayed',
'config.diag.replay': 'Replay',
'config.diag.replayValue': '{count} waiting',
'config.diag.replayNone': 'None',
}
+12 -5
View File
@@ -12,7 +12,6 @@ export const zh = {
'notification.completion': '会话已完成',
'notification.approval': '需要授权',
'notification.question': '需要回答',
'body.completion': '{title} 已完成这一轮回答',
'body.approval': '{title}:工具 {tool} 正在等待你的授权',
'body.approvalPlain': '{title}:有工具正在等待你的授权',
'body.question': '{title}:正在等待你的回答',
@@ -20,7 +19,6 @@ export const zh = {
'body.untitled': '未命名会话',
'toast.view': '查看',
'toast.dismiss': '关闭',
'settings.title': '会话通知',
'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。',
'settings.permission.granted': '系统通知已开启',
'settings.permission.default': '尚未授予系统通知权限',
@@ -39,19 +37,27 @@ export const zh = {
'settings.health.noHooks': '未收到会话状态(通知不会触发)',
'settings.health.error': '监听会话状态出错:{message}',
'settings.health.starting': '正在等待会话列表',
'settings.kinds.label': '提醒内容',
'settings.kind.completion': '完成',
'settings.kind.approval': '授权',
'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.popup': '轻弹窗',
'config.section.permission': '系统通知权限',
'config.section.status': '运行状态',
'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。',
'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。',
'config.dismiss.note': '鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。',
'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。',
'config.testAny': '测试提醒',
'config.testSystem': '测试系统通知',
'config.diag.window': '窗口状态',
'config.diag.watch': '会话监听',
'config.diag.inFront': '在前台(会走轻弹窗)',
'config.diag.away': '不在前台(会走系统通知)',
'config.diag.last': '最近一次投递',
@@ -62,6 +68,7 @@ export const zh = {
'config.diag.replayDelivery': '应用内轻弹窗(回到窗口时补发)',
'config.diag.popup': '应用内轻弹窗',
'config.diag.refused': '系统通知被拒绝,改用了轻弹窗',
'config.diag.replay': '等你回到窗口时补发:{count} 条',
'config.diag.replayNone': '没有待补发的提醒',
'config.diag.replay': '待补发提醒',
'config.diag.replayValue': '{count} 条',
'config.diag.replayNone': '没有',
}
+3 -1
View File
@@ -37,7 +37,7 @@ import { createTranslator, registerDictionaries } from './i18n/index.js'
import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js'
import { report, text } from './core/log.js'
import { createStore } from './core/store.js'
import { readStoredKinds } from './core/storage.js'
import { readStoredDismissMs, readStoredKinds } from './core/storage.js'
import { createCopy } from './core/copy.js'
import { createToastStack } from './core/toasts.js'
import { createReplay } from './core/replay.js'
@@ -63,6 +63,7 @@ export function apply(ctx) {
const store = createStore({
permission: notificationPermission(),
kinds: readStoredKinds(),
dismissAfterMs: readStoredDismissMs(),
toasts: [],
promptError: '',
health: { state: 'starting', message: '' },
@@ -194,6 +195,7 @@ export function apply(ctx) {
testAny: actions.sendTest,
testSystem: actions.sendTestSystem,
setKind: actions.setKindEnabled,
setDismiss: actions.setDismissAfter,
}),
}, ConfigSection))
+105 -51
View File
@@ -6,12 +6,19 @@
* row's description) and `page` (the whole form). Everything visible here
* belongs to this package, including the platform-specific permission hint.
*
* The page is laid out as three groups — what to notify about, the system
* channel's own state, and what the plugin is doing right now. Each group keeps
* its controls in one place: a list whose rows carry the subject on the left and
* the control on the right, and a fact list for the readings, so the status
* block is scannable instead of being four sentences in one cell.
*
* @module @dsh-plugin/session-notify/client/ui/ConfigSection
*/
import * as React from 'react'
import { KINDS } from '../constants.js'
import { DISMISS_CHOICES, KINDS } from '../constants.js'
import { windowIsAway } from '../platform.js'
import { DismissRow } from './DismissRow.js'
import { KindRow } from './KindRow.js'
import { seatTranslator, useOwnStore } from './hooks.js'
import { CSS } from './styles.js'
@@ -22,6 +29,7 @@ export function ConfigSection(props) {
const snapshot = useOwnStore(props.store)
const tr = seatTranslator(props, props.fallbackT)
if (props.view === 'summary') return h(React.Fragment, null, tr('settings.description'))
const permission = snapshot.permission
const supported = permission !== 'unsupported'
const failed = permission === 'denied' || snapshot.promptError !== ''
@@ -32,9 +40,19 @@ export function ConfigSection(props) {
: 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 healthText = health.state === 'watching'
? tr('settings.health.watching')
@@ -44,55 +62,91 @@ export function ConfigSection(props) {
? tr('settings.health.error', { message: health.message })
: tr('settings.health.starting')
const healthFailed = health.state !== 'watching' && health.state !== 'starting'
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr)
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsn-sections' },
// The page header already shows this bundle's title and description, so
// this section opens with what the header cannot say: how alerts are
// carried, and what this form changes.
h('section', { className: 'dsn-section' },
h('p', { className: 'dsn-section-desc' }, tr('config.intro'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.kinds')),
KINDS.map((kind) => h(KindRow, {
key: kind,
label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true,
disabled: false,
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
})),
h('p', { className: 'dsn-section-desc' }, tr('config.saveNote'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.permission')),
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('div', { className: 'dsn-page' },
// The page header already shows this bundle's title and description, so this
// lede says what the header cannot: how an alert travels, and that the
// channel is chosen at delivery time.
h('p', { className: 'dsn-lede' }, tr('config.intro')),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.kinds')),
h('div', { className: 'dsn-list' },
KINDS.map((kind) => h(KindRow, {
key: kind,
label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true,
disabled: false,
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true),
}))),
h('p', { className: 'dsn-group-note' }, tr('config.saveNote'))),
h('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.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', {
className: `dsn-status${failed ? ' dsn-status-error' : ''}`,
role: failed ? 'alert' : 'status',
}, permissionText)),
h('button', {
type: 'button',
className: 'dsn-button',
disabled: !supported,
onClick: permission === 'granted' ? props.testSystem : props.ask,
}, tr(!supported
? 'settings.action.unsupported'
: permission === 'granted' ? 'settings.action.testSystem' : 'settings.action.allow'))),
h('p', { className: 'dsn-section-desc' }, tr('config.testHint'))),
h('section', { className: 'dsn-section' },
h('h3', { className: 'dsn-section-title' }, tr('config.section.status')),
h('div', { className: 'dsn-row' },
h('div', { className: 'dsn-row-text' },
h('p', { className: 'dsn-status' }, `${tr('config.diag.window')}: ${tr(windowIsAway() ? 'config.diag.away' : 'config.diag.inFront')}`),
h('p', {
className: `dsn-status${healthFailed ? ' dsn-status-error' : ''}`,
className: `dsn-fact-value${healthFailed ? ' is-error' : ''}`,
role: healthFailed ? 'alert' : 'status',
}, healthText),
h('p', { className: 'dsn-status' }, props.copy.describeDelivery(snapshot.lastDelivery, tr)),
h('p', { className: 'dsn-status' }, props.copy.describeReplay(props.replayCount(), tr))),
h('button', {
type: 'button',
className: 'dsn-button',
onClick: props.testAny,
}, tr('settings.action.testAny'))))))
}, healthText)),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.last')),
h('dd', { className: 'dsn-fact' },
delivery === null
? h('p', { className: 'dsn-fact-value' }, tr('config.diag.none'))
: [
h('p', { key: 'line', className: 'dsn-fact-value' }, `${delivery.at} · ${delivery.outcome}`),
delivery.title === ''
? null
: h('p', { key: 'title', className: 'dsn-fact-title' }, delivery.title),
]),
h('dt', { className: 'dsn-fact-key' }, tr('config.diag.replay')),
h('dd', { className: 'dsn-fact' },
h('p', { className: 'dsn-fact-value' }, props.copy.replayValue(props.replayCount(), tr)))),
h('div', { className: 'dsn-list' },
h('div', { className: 'dsn-row is-action' },
h('button', {
type: 'button',
className: 'dsn-button',
onClick: props.testAny,
}, tr('settings.action.testAny')))))))
}
+30
View File
@@ -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
View File
@@ -1,16 +1,29 @@
/**
* Every style declaration this package owns.
*
* The popup copies the shipped toast surface (a fixed entry in the frame-wide
* overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair,
* `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text;
* the switch copies `Switch.module.css` behavior and proportion; the
* configuration sections follow the Plugin Manager page's own section rhythm
* (a column, 32px apart). Class names are renamed under `dsn-`; only the popup
* glyphs carry their own artwork colors.
* Both surfaces are built out of the theme's own vocabulary and nothing else, so
* they follow the shell's light and dark modes without a branch of their own (the
* theme swaps every `--dsw-alias-*` under `body[data-ds-dark-theme]`):
*
* - The popup is a floating card, using the same recipe the shipped menus and
* modals use — `--dsw-alias-bg-layer-2` as the surface, the
* `--dsw-elevation-stroke-color` / `--dsw-elevation-prominent` pair for its
* hairline and shadow, `--dsw-alias-label-*` for text, and the shell's motion
* tokens. It deliberately does NOT use `--dsw-alias-toast-bg`, which is a fixed
* dark slate (#353638 in light mode) and would read as a black slab on a light
* page.
* - The settings page is a reading order, not a form dump: a lede that says how
* alerts travel, then one group per concern. Inside a group a title introduces
* a list whose rows put the subject on the left and the single control on the
* right, with hairlines only between rows; the status group is a two-column
* fact list, because four sentences stacked in one cell are not scannable.
*
* Class names are renamed under `dsn-`; only the popup glyphs carry their own
* artwork colors — and those are theme-aware state tokens too.
*
* A plugin must not import a Harness Client package, so the declarations are
* this package's own text 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
*/
@@ -18,33 +31,50 @@
export const CSS = `
.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
@media (max-width:520px){.dsn-stack{right:12px}}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;animation:dsn-toast-in var(--ds-transition-duration,.2s) var(--ds-ease-in-out,ease-out)}
@keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}}
@media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}}
.dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)}
.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}
.dsn-toast-icon.is-question{color:var(--dsw-alias-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-title{font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none}
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-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:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-sections{display:flex;flex-direction:column;gap:24px}
.dsn-section{display:flex;flex-direction:column;gap:10px}
.dsn-section-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:22px}
.dsn-section-desc{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
.dsn-row:last-child{border-bottom:0}
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px}
.dsn-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsn-group{display:flex;flex-direction:column;gap:2px}
.dsn-group-title{margin:0 0 8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px}
.dsn-group-note{margin:8px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-list{display:flex;flex-direction:column}
.dsn-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
.dsn-row+.dsn-row{border-top:.5px solid var(--dsw-alias-border-l2)}
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0}
.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-row-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
.dsn-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
.dsn-status-error{color:var(--dsw-alias-state-error-primary)}
.dsn-chips{display:flex;align-items:center;gap:6px;flex:none}
.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-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[aria-checked='true']{background:var(--dsw-alias-brand-primary)}
.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-switch[aria-checked='false'] .dsn-thumb{background:var(--dsw-alias-switch-thumb)}
.dsn-switch[aria-checked='true'] .dsn-thumb{transform:translateX(16px)}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);background:transparent;flex:none}
.dsn-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsn-button:disabled{cursor:not-allowed;opacity:.4}
.dsn-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-button.is-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground)}
.dsn-button.is-primary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);opacity:.88}
`
+4 -1
View File
@@ -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.testSystem, '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)
// No locale service in this fake context, so the plugin's own translator falls
// 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) => {
+117 -22
View File
@@ -1,10 +1,10 @@
// The components, rendered through a tiny React double.
//
// Why a double instead of React: this repository ships no runtime dependency at
// all, and the whole point of these tests is the wiring the refactor introduced —
// which component reads which prop through which seat. The double implements the
// four React entry points this package uses, so a component render is exercised
// end to end (hooks included) without a DOM or a real React.
// all, and the whole point of these tests is the wiring — which component reads
// which prop through which seat. The double implements the four React entry
// points this package uses, so a component render is exercised end to end (hooks
// included) without a DOM or a real React.
//
// It runs against the BUILT artifact, because that is what the page loads.
import { test } from 'node:test'
@@ -98,11 +98,23 @@ function walk(node, collected = { text: [], types: [], elements: [] }) {
}
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 props = { ...config.definition.inject(), view: 'page' }
const rendered = walk(config.component(props))
const rendered = walk(config.component({ ...config.definition.inject(), view: 'page' }))
const text = rendered.text.join(' ')
// No locale service in this context, so the seat falls back to English.
@@ -110,11 +122,20 @@ test('the plugin page renders every section with copy, not raw keys', () => {
'Every alert comes from this page',
'What to notify about',
'The switches are stored in this browser',
'Popup',
'Auto-close',
'6s',
'Never',
'Hovering a card pauses the countdown',
'System notification permission',
'The buttons here go straight to the system channel',
'This environment cannot show system notifications',
platformHint,
'Status',
'Window',
'Session watch',
'Last delivery',
'nothing delivered yet',
'nothing waiting to be replayed',
'Replay',
'Test alert',
]) {
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('settings.'), 'a raw translation key reached the page')
const classes = rendered.elements.map((element) => element.props.className ?? '')
for (const className of ['dsn-sections', 'dsn-section', 'dsn-section-title', 'dsn-row', 'dsn-button', 'dsn-status']) {
const classes = classesOf(rendered)
for (const className of [
'dsn-page',
'dsn-lede',
'dsn-group',
'dsn-group-title',
'dsn-group-note',
'dsn-list',
'dsn-row',
'dsn-row-title',
'dsn-state',
'dsn-chips',
'dsn-chip',
'dsn-facts',
'dsn-fact-key',
'dsn-fact-value',
'dsn-button',
]) {
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)
// The live window fact is rendered, not remembered.
assert.match(text, /Window: (in front \(popup is used\)|not in front \(system notification is used\))/)
assert.equal(classes.filter((className) => className === 'dsn-chip').length, 5)
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1)
assert.equal(classes.filter((className) => className === 'dsn-fact-key').length, 4)
assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4)
})
test('the page 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 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.getSnapshot(),
kinds: { completion: false, approval: true, question: true },
})
const switched = walk(config.component({ ...props, view: 'page' }))
assert.deepEqual(
switched.elements
.filter((element) => element.props.className === 'dsn-switch')
.map((element) => element.props['aria-checked']),
[false, true, true],
)
props.store.set({
...props.store.getSnapshot(),
permission: 'granted',
lastDelivery: { outcome: 'system', shown: true, at: '17:52:30', title: 'Conversation finished' },
})
const rendered = walk(config.component({ ...props, view: 'page' }))
const switches = rendered.elements
.filter((element) => element.props.className === 'dsn-switch')
.map((element) => element.props['aria-checked'])
assert.deepEqual(switches, [false, true, true])
assert.match(rendered.text.join(' '), /Last delivery: 17:52:30 · system notification \(confirmed on screen\) · Conversation finished/)
const granted = walk(config.component({ ...props, view: 'page' }))
const grantedClasses = classesOf(granted)
// A healthy permission has no warning colour and the button becomes the
// channel-specific test.
assert.equal(grantedClasses.includes('dsn-dot is-muted'), false)
assert.equal(grantedClasses.includes('dsn-dot is-error'), false)
assert.ok(grantedClasses.includes('dsn-dot'))
assert.ok(granted.text.join(' ').includes('Test system notification'))
assert.ok(granted.text.join(' ').includes('17:52:30 · system notification (confirmed on screen)'))
assert.ok(grantedClasses.includes('dsn-fact-title'))
props.store.set({
...props.store.getSnapshot(),
permission: 'denied',
lastDelivery: null,
})
const denied = walk(config.component({ ...props, view: 'page' }))
const deniedText = denied.text.join(' ')
assert.ok(classesOf(denied).includes('dsn-dot is-error'))
assert.ok(deniedText.includes('switched off in system settings'))
assert.ok(deniedText.includes(platformHint))
assert.ok(deniedText.includes('nothing delivered yet'))
})
test('the summary view is the one-line description the plugin row shows', () => {
@@ -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('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']) {
assert.ok(classes.includes(className), `the popup is missing .${className}`)
}
+34 -38
View File
@@ -8,23 +8,24 @@ import { createCopy } from '../../src/client/core/copy.js'
import { createTranslator } from '../../src/client/i18n/index.js'
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. */
function candidate(overrides) {
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides }
}
test('a finished conversation names the trigger as the title and the conversation as the body', () => {
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()), {
kind: 'completion',
title: '会话已完成',
body: '标题 已完成这一轮回答',
body: '标题',
})
})
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', () => {
@@ -41,47 +42,42 @@ test('an approval without a tool name still says what is happening', () => {
assert.equal(notice.body, '标题:有工具正在等待你的授权')
})
test('a question carries the question text, and a plan review the plan\'s own text', () => {
const question = copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' }))
assert.deepEqual(question, { kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' })
test('a question carries the question text', () => {
assert.deepEqual(
copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '要继续吗?' })),
{ kind: 'question', title: '需要回答', body: '标题 · 要继续吗?' },
)
assert.equal(copyFor(candidate({ kind: 'question', pendingKind: 'question', detail: '' })).body, '标题:正在等待你的回答')
})
// v1.0.6 behavior, pinned on purpose: a plan review is queued with
// `kind: 'question'`, so the question branch above answers it and the
// `body.planReview` copy is unreachable. See the note in core/copy.js — fixing
// 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: '' }))
test('a plan review says what it is instead of repeating the plan text', () => {
// A plan review arrives as `kind: 'question'` with its own discriminator, so the
// discriminator has to be read first — otherwise the plan-review copy is unreachable.
const review = copyFor(candidate({ kind: 'question', pendingKind: 'plan-review', detail: '计划正文…' }))
assert.equal(review.kind, 'question')
assert.equal(review.body, '标题:正在等待你的回答')
assert.notEqual(review.body, t('body.planReview', { title: '标题' }))
assert.equal(review.body, '标题:计划正在等待你确认')
assert.equal(review.title, '需要回答')
})
test('a delivery that never happened is described as such', () => {
assert.equal(describeDelivery(null, t), '还没有投递过')
assert.equal(describeDelivery(undefined, t), '还没有投递过')
test('a delivery that never happened has no facts to show', () => {
assert.equal(deliveryFacts(null, t), null)
assert.equal(deliveryFacts(undefined, t), null)
})
test('the newest delivery is described by channel, confirmation and local time', () => {
const system = describeDelivery({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t)
assert.equal(system, '最近一次投递: 17:52:30 · 系统通知(系统已确认弹出) · 会话已完成')
const unconfirmed = describeDelivery({ outcome: 'system', shown: undefined, at: '17:52:30', title: 'X' }, t)
assert.match(unconfirmed, /系统没有回报/)
const refused = describeDelivery({ outcome: 'system', shown: false, at: '17:52:30', title: 'X' }, t)
assert.match(refused, /没弹出来/)
const fellBack = describeDelivery({ outcome: 'system-refused-threw', shown: undefined, at: '17:52:30', title: 'X' }, t)
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 newest delivery is split into time, channel and what it was', () => {
assert.deepEqual(
deliveryFacts({ outcome: 'system', shown: true, at: '17:52:30', title: '会话已完成' }, t),
{ at: '17:52:30', outcome: '系统通知(系统已确认弹出)', title: '会话已完成' },
)
assert.equal(deliveryFacts({ outcome: 'system', shown: undefined, at: '1:00:00', title: 'X' }, t).outcome, '系统通知(系统没有回报,回到窗口时补发轻弹窗)')
assert.equal(deliveryFacts({ outcome: 'system', shown: false, at: '1:00:00', title: 'X' }, t).outcome, '系统通知没弹出来,回到窗口时补发轻弹窗')
assert.equal(deliveryFacts({ outcome: 'system-refused-threw', at: '1:00:00', title: 'X' }, t).outcome, '系统通知被拒绝,改用了轻弹窗')
assert.equal(deliveryFacts({ outcome: 'replay', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗(回到窗口时补发)')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: 'X' }, t).outcome, '应用内轻弹窗')
assert.equal(deliveryFacts({ outcome: 'popup', at: '1:00:00', title: undefined }, t).title, '')
})
test('the replay line counts what is still waiting', () => {
assert.equal(describeReplay(0, t), '没有待补发的提醒')
assert.equal(describeReplay(3, t), '等你回到窗口时补发:3 条')
test('the replay value is short enough for one status line', () => {
assert.equal(replayValue(0, t), '没有')
assert.equal(replayValue(3, t), '3 条')
})
+1 -1
View File
@@ -63,7 +63,7 @@ test('a locale service that throws on every call keeps the copy renderable', ()
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', () => {
+39 -11
View File
@@ -9,11 +9,14 @@ import {
preview,
questionText,
servedPendingKind,
stillWorth,
stillOwed,
titleOf,
} 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: 'question' }), 'question')
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)
})
test('a queued alert is dropped when its conversation resumed inside the settle window', () => {
const candidate = { kind: 'completion', sessionId: 'a', test: false }
test('a pending interaction stays owed while it waits, even though the run is still running', () => {
// The session is `running` whenever the agent loop is active, and a pending
// approval or question is exactly that: the loop is waiting for the human. The
// run state must therefore not gate these alerts — it used to drop every one
// of them, which is why only completions ever reached the user.
const candidate = { kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: false }
const status = (pendingInteraction, running) => new Map([['a', { pendingInteraction, running }]])
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status({ kind: 'approval' }, true) }), true)
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: false } } }, status: status({ kind: 'approval' }, false) }), true)
// Answered inside the settle window: the request is gone, so nothing is owed.
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: status(undefined, true) }), false)
// Replaced by a different request of the same kind: a new request is a new alert.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'question' }, { list: { byId: {} }, status: status({ kind: 'approval' }, true) }), false)
// A plan review is owed on its own discriminator.
assert.equal(stillOwed({ ...candidate, kind: 'question', pendingKind: 'plan-review' }, { list: { byId: {} }, status: status({ kind: 'plan-review' }, true) }), true)
// Nothing contradicts the request, so it is still the user's move.
assert.equal(stillOwed(candidate, { list: { byId: {} }, status: new Map() }), true)
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
})
test('a completion is owed only while the conversation stayed idle', () => {
const candidate = { kind: 'completion', sessionId: 'a', pendingKind: '', test: false }
const live = (byId, status) => ({ list: { byId }, status })
assert.equal(stillWorth(candidate, { list: undefined, status: undefined }), true)
assert.equal(stillWorth(candidate, live({ a: { running: false } })), true)
assert.equal(stillWorth(candidate, live({})), true)
assert.equal(stillWorth(candidate, live({ a: { running: true } })), false)
assert.equal(stillOwed(candidate, { list: undefined, status: undefined }), true)
assert.equal(stillOwed(candidate, live({ a: { running: false } })), true)
assert.equal(stillOwed(candidate, live({})), true)
assert.equal(stillOwed(candidate, live({ a: { running: true } })), false)
// The per-session selector wins over the list summary, which still carries the
// pre-transition flag.
assert.equal(stillWorth(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)
assert.equal(stillOwed(candidate, { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: false }]]) }), true)
})
test('a test alert is always owed', () => {
const live = { list: { byId: { a: { running: true } } }, status: new Map([['a', { running: true }]]) }
assert.equal(stillOwed({ kind: 'completion', sessionId: 'a', pendingKind: '', test: true }, live), true)
assert.equal(stillOwed({ kind: 'approval', sessionId: 'a', pendingKind: 'approval', test: true }, live), true)
})
+47 -6
View File
@@ -1,10 +1,18 @@
// The browser-local trigger switches. Storage is foreign input: it can be
// absent, hold another version's shape, or refuse to write, and none of those
// may stop the plugin from working with its defaults.
// The browser-local preferences: the three trigger switches and the popup
// lifetime. Storage is foreign input: it can be absent, hold another version's
// 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 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. */
function useStorage(behaviour) {
@@ -26,20 +34,22 @@ afterEach(() => {
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()
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
})
test('without storage at all the defaults still apply', () => {
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(() => writeStoredDismissMs(0))
})
test('a stored switch survives a round trip, and unknown or broken values do not', () => {
const data = useStorage()
writeStoredKinds({ completion: false, approval: true, question: false })
assert.equal(data.get(STORAGE_KEY), JSON.stringify({ completion: false, approval: true, question: false }))
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
data.set(STORAGE_KEY, '{ not json')
@@ -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 })
})
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', () => {
useStorage('read-throws')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
useStorage('write-throws')
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
assert.doesNotThrow(() => writeStoredDismissMs(3000))
})
+81
View File
@@ -0,0 +1,81 @@
// The stylesheet's two contracts, both of which the popup broke once:
//
// 1. it may only paint with theme tokens — a literal color cannot follow the
// shell's light/dark switch, and a `--dsw-static-*` token is fixed by design;
// 2. the popup must use the same floating-surface recipe as the shipped menus and
// modals (surface + hairline + elevation + label tokens), not the fixed dark
// `--dsw-alias-toast-bg` that made it a black slab on a light page.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { CSS } from '../../src/client/ui/styles.js'
/** Every `property:value` declaration in the sheet, selector-less. */
function declarations(sheet) {
return [...sheet.matchAll(/([a-z-]+)\s*:\s*([^;{}]+);/g)].map((match) => ({ property: match[1], value: match[2].trim() }))
}
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(|\b(?:white|black|red|green|blue|gray|grey)\b/
test('the stylesheet paints with tokens and never with a literal color', () => {
const properties = ['color', 'background', 'background-color', 'border', 'border-top', 'border-color', 'box-shadow', 'outline']
const painted = declarations(CSS).filter(({ property }) => properties.includes(property))
assert.ok(painted.length > 20, 'expected the sheet to carry its painted declarations')
for (const { property, value } of painted) {
// Keyword values carry no color of their own.
if (/^(none|transparent|inherit|currentColor)$/.test(value)) continue
assert.ok(!COLOR_LITERAL.test(value), `${property}: ${value} hardcodes a color instead of using a theme token`)
assert.ok(!value.includes('--dsw-static-'), `${property}: ${value} uses a theme-fixed static token that cannot follow dark mode`)
}
})
test('the popup is the shipped floating-card recipe, in both themes', () => {
const toast = CSS.match(/\.dsn-toast\{([^}]*)\}/)?.[1]
assert.ok(toast !== undefined, 'the popup rule is missing')
// The surface, the hairline and the shadow are what DSH's menus and modals use.
assert.ok(toast.includes('background:var(--dsw-alias-bg-layer-2)'), 'the popup must sit on the elevated layer surface')
assert.ok(toast.includes('--dsw-elevation-stroke-color:var(--dsw-alias-border-l1)'), 'the popup must take the menu hairline')
assert.ok(toast.includes('box-shadow:var(--dsw-elevation-prominent)'), 'the popup must take the prominent elevation')
assert.ok(toast.includes('color:var(--dsw-alias-label-primary)'), 'the popup text must be the primary label token')
assert.ok(toast.includes('border-radius:var(--dsw-radius-lg)'), 'the popup must take the large radius')
assert.ok(toast.includes('var(--ds-transition-duration') && toast.includes('var(--ds-ease-in-out'), 'the entrance must use the shell motion tokens')
// The one thing it must not do: the fixed dark toast slab, or any of its labels.
assert.ok(!toast.includes('--dsw-alias-toast-'), 'the popup must not use the theme-fixed toast surface')
assert.ok(!CSS.includes('--dsw-alias-toast-'), 'no rule may fall back to the fixed toast surface')
})
test('the popup reads as one card: title, body and both controls use label tokens', () => {
const rule = (name) => CSS.match(new RegExp(`\\.${name}\\{([^}]*)\\}`))?.[1] ?? ''
assert.ok(rule('dsn-toast-title').includes('var(--dsw-alias-label-primary)'))
assert.ok(rule('dsn-toast-desc').includes('var(--dsw-alias-label-secondary)'))
assert.ok(rule('dsn-toast-close').includes('color:var(--dsw-alias-label-tertiary)'))
assert.ok(rule('dsn-toast-action').includes('var(--dsw-alias-state-business-primary)'))
// Hover feedback comes from the shared interactive token, not a hand-rolled color.
assert.ok(rule('dsn-toast-action:hover').includes('var(--dsw-alias-interactive-bg-hover)'))
assert.ok(rule('dsn-toast-close:hover').includes('var(--dsw-alias-interactive-bg-hover)'))
})
test('the three popup glyphs stay distinguishable through state tokens', () => {
assert.ok(CSS.includes('.dsn-toast-icon{') && CSS.includes('var(--dsw-alias-state-warn-label)'))
assert.ok(CSS.includes('.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}'))
assert.ok(CSS.includes('.dsn-toast-icon.is-question{color:var(--dsw-alias-state-business-primary)}'))
})
test('every class the components render has a rule, so nothing falls back to defaults', () => {
// Guards the redesign: a renamed class that the sheet no longer styles would
// render as an unstyled block instead of failing loudly.
const expected = [
'dsn-stack', 'dsn-toast', 'dsn-toast-icon', 'dsn-toast-icon is-completion', 'dsn-toast-icon is-question',
'dsn-toast-text', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-actions', 'dsn-toast-action', 'dsn-toast-close',
'dsn-page', 'dsn-lede', 'dsn-group', 'dsn-group-title', 'dsn-group-note', 'dsn-list', 'dsn-row', 'dsn-row-text',
'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-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}`)
}
})
+100
View File
@@ -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')
})