6 Commits
22 changed files with 703 additions and 143 deletions
+22
View File
@@ -2,6 +2,28 @@
版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。 版本说明按倒序排列。提交信息遵循 [CONTRIBUTING.md](CONTRIBUTING.md) 的单行规范,因此每次改动"为什么这样改、影响面是什么"记在这里,而不是提交信息里。
## v1.0.10
- 新增:**应用内轻弹窗的自动关闭时间可以在插件页设置**(3 / 6 / 10 / 30 秒,或**永不**)。原来固定 6 秒,现在:
- 插件页多了一组「轻弹窗」,选项就是它要设的值,选中的那一个是主色按钮样式(`role=radiogroup`,键盘可聚焦);
- 「永不」就是 `0`:卡片不再自己消失,要自己点 ✕ 或按 `Esc`。鼠标停在卡片上暂停计时的行为不变,同屏上限仍是 3 条,所以「永不」也不会越堆越多;
- 改这个值时,**屏幕上已有的卡片会按新值重新计时**(鼠标正停在上面那张除外,它要等你移开才开始计时),不会留着旧时间表继续走;
- 这个值与三个开关存在**同一份**浏览器本地记录里,各自的写入是合并式的:只存过开关的旧记录能正常升级,改开关也不会把关闭时间重置回去;取值只接受上列选项,别的值一律退回默认 6000ms。
- 调整:**「会话已完成」的提醒,描述行只显示会话题目**。标题已经写着「会话已完成」,描述再写一遍「已完成这一轮回答」只是重复;现在描述就是会话标题本身(没有标题的会话仍显示「未命名会话」)。字典里的 `body.completion` 随之删除。
- 顺带清掉两个死键:`settings.title`、`settings.kinds.label`(插件页早就改用 `config.section.*` 与 `settings.kind.*`,这两个键没有任何地方读)。现在中英各 62 个键,一一对应、没有死键。
- 测试从 44 条增加到 52 条:弹窗生命周期的 5 条(按时关闭 / 永不由时间关闭 / 悬停暂停后按新值续期 / 非法值退回默认 / 同屏上限),存储的合并与校验 3 条,插件页新选项的渲染与回写 1 条。
## v1.0.9
- 轻弹窗改成 **DSH 的浮层卡片**,跟随 DSH 的深色 / 浅色模式。原来用的是 DSH 的 `--dsw-alias-toast-bg`,而那个令牌在**两种模式下都是深灰**(浅色模式 `#353638`、深色模式 `#43454a`),所以在浅色页面里浮窗是一块接近纯黑的板子,和周围的卡片不是一种东西。现在照 DSH 自己的做法来(下面这些都是 shell 主题里真实存在、并且会随 `body[data-ds-dark-theme]` 一起切换的令牌):
- 底色 `--dsw-alias-bg-layer-2`:浅色 `#fff`、深色 `#2c2c2e`,和浮动菜单 / 弹窗用的是同一层;
- 发丝线与大阴影用 `--dsw-elevation-stroke-color` + `--dsw-elevation-prominent`(DSH 菜单的做法,发丝线取 `--dsw-alias-border-l1`:浅色 `#0000000a`、深色 `#ffffff0f`);
- 文字改 `--dsw-alias-label-primary` / `-secondary` / `-tertiary`,不再是写死的白字加 `opacity`;
- 入场动画用 shell 自己的 `--ds-transition-duration` 与 `--ds-ease-in-out`。
- 「查看」按钮改成 `--dsw-alias-state-business-primary`(浅色 `#4176e6`、深色 `#7aaaff`);原来是写死的 `--dsw-static-deepseek-400`,在白底上对比度不够、也不随主题变化。关闭按钮用 `--dsw-alias-label-tertiary`,hover 升到主文字色。三类图标仍是状态令牌:完成绿 `#22c55e`、授权琥珀 `#dd8629`、提问蓝(随主题)。
- 新增 `tests/unit/styles.test.js`(5 条)把两条不变量钉住:**样式表里不出现字面颜色、也不用 `--dsw-static-*` 这类不随主题切换的令牌**;弹窗必须用上面那套浮层配方,且**不许再出现 `--dsw-alias-toast-bg`**。以后谁把颜色写死、或又退回那个深色板子,测试会直接失败。
- 行为未改:位置(右上角)、尺寸、停留时间、hover 暂停、`Esc` / ✕ 关闭、同屏 3 条都不变,只换皮。
## v1.0.8 ## v1.0.8
- 修复:**授权与提问两类提醒从来不会弹**(只有「会话已完成」会弹)。原因不是判据写错了,而是投递前的「是否还值得提醒」那道闸门用错了事实: - 修复:**授权与提问两类提醒从来不会弹**(只有「会话已完成」会弹)。原因不是判据写错了,而是投递前的「是否还值得提醒」那道闸门用错了事实:
+6 -4
View File
@@ -35,9 +35,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
轻弹窗的用法: 轻弹窗的用法:
- 点「查看」跳到该会话; - 点「查看」跳到该会话;
- 6 秒后自动消失;鼠标停在上面期间不消失,移开后重新计时; - **默认 6 秒**后自动消失(可在插件页改成 3 / 10 / 30 秒,或设成**永不关闭**);鼠标停在上面期间都不消失,移开后重新计时;
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条; - 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
- 同屏最多叠 3 条,更早的会被挤掉。 - 同屏最多叠 3 条,更早的会被挤掉(即使设成永不关闭也一样,不会越堆越多);
- 轻弹窗是 DSH 的浮层卡片样式(和菜单、弹窗同一层底色与阴影),**跟随 DSH 的深色 / 浅色模式**。
## 设置 ## 设置
@@ -46,6 +47,7 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
| 分组 | 内容 | | 分组 | 内容 |
| --- | --- | | --- | --- |
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 | | 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
| 轻弹窗 | **自动关闭时间**:3 / 6 / 10 / 30 秒,或**永不**(永不时要自己点 ✕ 或按 `Esc`);鼠标停在卡片上时都不会消失 |
| 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 | | 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 |
| 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 | | 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 |
@@ -59,10 +61,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
在 DSH 的 **插件 → 添加插件** 的上方输入框里填: 在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
```text ```text
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.8 https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.10
``` ```
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.8`。仓库是公开的,不需要凭据,也不用改「安装源」。 `#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.10`。仓库是公开的,不需要凭据,也不用改「安装源」。
### 本地路径安装 ### 本地路径安装
+178 -76
View File
@@ -54,6 +54,7 @@ var THROTTLE_MS = 1500;
var SETTLE_MS = 400; var SETTLE_MS = 400;
var TOAST_DURATION_MS = 6e3; var TOAST_DURATION_MS = 6e3;
var TOAST_LIMIT = 3; var TOAST_LIMIT = 3;
var DISMISS_CHOICES = [3e3, 6e3, 1e4, 3e4, 0];
var RAISED_LIMIT = 8; var RAISED_LIMIT = 8;
var REPLAY_LIMIT = 8; var REPLAY_LIMIT = 8;
var REPLAY_TTL_MS = 30 * 60 * 1e3; var REPLAY_TTL_MS = 30 * 60 * 1e3;
@@ -64,7 +65,6 @@ var zh = {
"notification.completion": "会话已完成", "notification.completion": "会话已完成",
"notification.approval": "需要授权", "notification.approval": "需要授权",
"notification.question": "需要回答", "notification.question": "需要回答",
"body.completion": "{title} 已完成这一轮回答",
"body.approval": "{title}:工具 {tool} 正在等待你的授权", "body.approval": "{title}:工具 {tool} 正在等待你的授权",
"body.approvalPlain": "{title}:有工具正在等待你的授权", "body.approvalPlain": "{title}:有工具正在等待你的授权",
"body.question": "{title}:正在等待你的回答", "body.question": "{title}:正在等待你的回答",
@@ -72,7 +72,6 @@ var zh = {
"body.untitled": "未命名会话", "body.untitled": "未命名会话",
"toast.view": "查看", "toast.view": "查看",
"toast.dismiss": "关闭", "toast.dismiss": "关闭",
"settings.title": "会话通知",
"settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。", "settings.description": "窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。",
"settings.permission.granted": "系统通知已开启", "settings.permission.granted": "系统通知已开启",
"settings.permission.default": "尚未授予系统通知权限", "settings.permission.default": "尚未授予系统通知权限",
@@ -91,15 +90,22 @@ var zh = {
"settings.health.noHooks": "未收到会话状态(通知不会触发)", "settings.health.noHooks": "未收到会话状态(通知不会触发)",
"settings.health.error": "监听会话状态出错:{message}", "settings.health.error": "监听会话状态出错:{message}",
"settings.health.starting": "正在等待会话列表", "settings.health.starting": "正在等待会话列表",
"settings.kinds.label": "提醒内容",
"settings.kind.completion": "完成", "settings.kind.completion": "完成",
"settings.kind.approval": "授权", "settings.kind.approval": "授权",
"settings.kind.question": "提问", "settings.kind.question": "提问",
"settings.dismiss.label": "自动关闭",
"settings.dismiss.3000": "3 秒",
"settings.dismiss.6000": "6 秒",
"settings.dismiss.10000": "10 秒",
"settings.dismiss.30000": "30 秒",
"settings.dismiss.0": "永不",
"config.section.kinds": "提醒内容", "config.section.kinds": "提醒内容",
"config.section.popup": "轻弹窗",
"config.section.permission": "系统通知权限", "config.section.permission": "系统通知权限",
"config.section.status": "运行状态", "config.section.status": "运行状态",
"config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。", "config.intro": "提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。",
"config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。", "config.saveNote": "开关保存在浏览器本地,重装插件不会丢失。",
"config.dismiss.note": "鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。",
"config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。", "config.testHint": "这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。",
"config.testAny": "测试提醒", "config.testAny": "测试提醒",
"config.testSystem": "测试系统通知", "config.testSystem": "测试系统通知",
@@ -125,7 +131,6 @@ var en = {
"notification.completion": "Conversation finished", "notification.completion": "Conversation finished",
"notification.approval": "Approval required", "notification.approval": "Approval required",
"notification.question": "Answer needed", "notification.question": "Answer needed",
"body.completion": "{title} finished this round",
"body.approval": "{title}: {tool} is waiting for your approval", "body.approval": "{title}: {tool} is waiting for your approval",
"body.approvalPlain": "{title}: a tool is waiting for your approval", "body.approvalPlain": "{title}: a tool is waiting for your approval",
"body.question": "{title}: waiting for your answer", "body.question": "{title}: waiting for your answer",
@@ -133,7 +138,6 @@ var en = {
"body.untitled": "Untitled conversation", "body.untitled": "Untitled conversation",
"toast.view": "View", "toast.view": "View",
"toast.dismiss": "Dismiss", "toast.dismiss": "Dismiss",
"settings.title": "Session notifications",
"settings.description": "A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.", "settings.description": "A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.",
"settings.permission.granted": "System notifications are on", "settings.permission.granted": "System notifications are on",
"settings.permission.default": "System notification permission has not been granted", "settings.permission.default": "System notification permission has not been granted",
@@ -152,15 +156,22 @@ var en = {
"settings.health.noHooks": "No session status received (notifications will not fire)", "settings.health.noHooks": "No session status received (notifications will not fire)",
"settings.health.error": "Watching session status failed: {message}", "settings.health.error": "Watching session status failed: {message}",
"settings.health.starting": "Waiting for the session list", "settings.health.starting": "Waiting for the session list",
"settings.kinds.label": "Notify about",
"settings.kind.completion": "Finished", "settings.kind.completion": "Finished",
"settings.kind.approval": "Approval", "settings.kind.approval": "Approval",
"settings.kind.question": "Questions", "settings.kind.question": "Questions",
"settings.dismiss.label": "Auto-close",
"settings.dismiss.3000": "3s",
"settings.dismiss.6000": "6s",
"settings.dismiss.10000": "10s",
"settings.dismiss.30000": "30s",
"settings.dismiss.0": "Never",
"config.section.kinds": "What to notify about", "config.section.kinds": "What to notify about",
"config.section.popup": "Popup",
"config.section.permission": "System notification permission", "config.section.permission": "System notification permission",
"config.section.status": "Status", "config.section.status": "Status",
"config.intro": "Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.", "config.intro": "Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.",
"config.saveNote": "The switches are stored in this browser and survive reinstalling the plugin.", "config.saveNote": "The switches are stored in this browser and survive reinstalling the plugin.",
"config.dismiss.note": "Hovering a card pauses the countdown; with Never, close each one with ✕ or Esc.",
"config.testHint": "The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).", "config.testHint": "The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).",
"config.testAny": "Test alert", "config.testAny": "Test alert",
"config.testSystem": "Test system notification", "config.testSystem": "Test system notification",
@@ -308,24 +319,41 @@ function createStore(initial) {
// src/client/core/storage.js // src/client/core/storage.js
var STORAGE_KEY = "dsh-plugin/session-notify"; var STORAGE_KEY = "dsh-plugin/session-notify";
function readStoredKinds() { function defaults() {
const kinds = { completion: true, approval: true, question: true }; return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS };
}
function readStored() {
const preferences = defaults();
try { try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY); const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
if (typeof raw !== "string" || raw === "") return kinds; if (typeof raw !== "string" || raw === "") return preferences;
const stored = JSON.parse(raw); const stored = JSON.parse(raw);
for (const kind of KINDS) { for (const kind of KINDS) {
if (typeof stored?.[kind] === "boolean") kinds[kind] = stored[kind]; if (typeof stored?.[kind] === "boolean") preferences[kind] = stored[kind];
} }
if (DISMISS_CHOICES.includes(stored?.dismissAfterMs)) preferences.dismissAfterMs = stored.dismissAfterMs;
} catch { } catch {
} }
return kinds; return preferences;
}
function writeStored(patch) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify({ ...readStored(), ...patch }));
} catch {
}
}
function readStoredKinds() {
const { completion, approval, question } = readStored();
return { completion, approval, question };
} }
function writeStoredKinds(kinds) { function writeStoredKinds(kinds) {
try { writeStored(kinds);
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds)); }
} catch { function readStoredDismissMs() {
} return readStored().dismissAfterMs;
}
function writeStoredDismissMs(ms) {
writeStored({ dismissAfterMs: ms });
} }
// src/client/core/copy.js // src/client/core/copy.js
@@ -333,7 +361,7 @@ function createCopy(t) {
const copyFor = (candidate) => { const copyFor = (candidate) => {
const title = candidate.title === "" ? t("body.untitled") : candidate.title; const title = candidate.title === "" ? t("body.untitled") : candidate.title;
if (candidate.kind === "completion") { if (candidate.kind === "completion") {
return { kind: "completion", title: t("notification.completion"), body: t("body.completion", { title }) }; return { kind: "completion", title: t("notification.completion"), body: title };
} }
if (candidate.pendingKind === "plan-review") { if (candidate.pendingKind === "plan-review") {
return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) }; return { kind: "question", title: t("notification.question"), body: t("body.planReview", { title }) };
@@ -365,8 +393,14 @@ function createCopy(t) {
// src/client/core/toasts.js // src/client/core/toasts.js
function createToastStack({ store, copy }) { function createToastStack({ store, copy }) {
const timers = /* @__PURE__ */ new Map(); const timers = /* @__PURE__ */ new Map();
const held = /* @__PURE__ */ new Set();
let seq = 0; let seq = 0;
const lifetime = () => {
const configured = store.getSnapshot().dismissAfterMs;
return typeof configured === "number" && configured >= 0 ? configured : TOAST_DURATION_MS;
};
const closeToast = (id) => { const closeToast = (id) => {
held.delete(id);
const timer = timers.get(id); const timer = timers.get(id);
if (timer !== void 0) { if (timer !== void 0) {
clearTimeout(timer); clearTimeout(timer);
@@ -377,16 +411,26 @@ function createToastStack({ store, copy }) {
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }); store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) });
}; };
const armToast = (id) => { const armToast = (id) => {
held.delete(id);
const timer = timers.get(id); const timer = timers.get(id);
if (timer !== void 0) clearTimeout(timer); if (timer !== void 0) clearTimeout(timer);
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS)); timers.delete(id);
const duration = lifetime();
if (duration === 0) return;
timers.set(id, setTimeout(() => closeToast(id), duration));
}; };
const holdToast = (id) => { const holdToast = (id) => {
held.add(id);
const timer = timers.get(id); const timer = timers.get(id);
if (timer === void 0) return; if (timer === void 0) return;
clearTimeout(timer); clearTimeout(timer);
timers.delete(id); timers.delete(id);
}; };
const rearmAll = () => {
for (const toast of store.getSnapshot().toasts) {
if (!held.has(toast.id)) armToast(toast.id);
}
};
const showToast = (candidate) => { const showToast = (candidate) => {
const notice = copy.copyFor(candidate); const notice = copy.copyFor(candidate);
seq += 1; seq += 1;
@@ -405,6 +449,7 @@ function createToastStack({ store, copy }) {
clearTimeout(timer); clearTimeout(timer);
timers.delete(dropped.id); timers.delete(dropped.id);
} }
held.delete(dropped.id);
} }
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }); store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) });
armToast(toast.id); armToast(toast.id);
@@ -412,8 +457,9 @@ function createToastStack({ store, copy }) {
const dispose = () => { const dispose = () => {
for (const timer of timers.values()) clearTimeout(timer); for (const timer of timers.values()) clearTimeout(timer);
timers.clear(); timers.clear();
held.clear();
}; };
return { showToast, closeToast, armToast, holdToast, dispose }; return { showToast, closeToast, armToast, holdToast, rearmAll, dispose };
} }
// src/client/core/replay.js // src/client/core/replay.js
@@ -758,7 +804,13 @@ function createActions({ store, t, delivery, system, toasts, publish }) {
writeStoredKinds(kinds); writeStoredKinds(kinds);
store.set({ ...snapshot, kinds }); store.set({ ...snapshot, kinds });
}; };
return { sendTest, sendTestSystem, requestPermission, setKindEnabled }; const setDismissAfter = (ms) => {
writeStoredDismissMs(ms);
const snapshot = store.getSnapshot();
store.set({ ...snapshot, dismissAfterMs: ms });
toasts.rearmAll();
};
return { sendTest, sendTestSystem, requestPermission, setKindEnabled, setDismissAfter };
} }
// src/client/ui/ToastLayer.js // src/client/ui/ToastLayer.js
@@ -891,21 +943,21 @@ function seatTranslator(props, fallback) {
var CSS = ` var CSS = `
.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none} .dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
@media (max-width:520px){.dsn-stack{right:12px}} @media (max-width:520px){.dsn-stack{right:12px}}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out} .dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;animation:dsn-toast-in var(--ds-transition-duration,.2s) var(--ds-ease-in-out,ease-out)}
@keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}} @keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}}
@media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}} @media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}}
.dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)} .dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)}
.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)} .dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}
.dsn-toast-icon.is-question{color:var(--dsw-alias-brand-primary)} .dsn-toast-icon.is-question{color:var(--dsw-alias-state-business-primary)}
.dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.dsn-toast-title{font-weight:500;font-size:14px;line-height:22px} .dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word} .dsn-toast-desc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none} .dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none}
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-static-deepseek-400);font:inherit;font-size:13px;line-height:20px;cursor:pointer} .dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-alias-state-business-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
.dsn-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)} .dsn-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)}
.dsn-toast-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} .dsn-toast-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer} .dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} .dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} .dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px} .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-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
@@ -918,6 +970,11 @@ var CSS = `
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0} .dsn-row.is-action{justify-content:flex-end;padding:12px 0 0}
.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0} .dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} .dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-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-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-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{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)}
@@ -1013,12 +1070,12 @@ function ToastLayer(props) {
} }
// src/client/ui/ConfigSection.js // src/client/ui/ConfigSection.js
var React5 = __toESM(require("react"), 1); var React6 = __toESM(require("react"), 1);
// src/client/ui/KindRow.js // src/client/ui/DismissRow.js
var React4 = __toESM(require("react"), 1); var React4 = __toESM(require("react"), 1);
var h3 = React4.createElement; var h3 = React4.createElement;
function KindRow(props) { function DismissRow(props) {
return h3( return h3(
"div", "div",
{ className: "dsn-row" }, { className: "dsn-row" },
@@ -1027,7 +1084,34 @@ function KindRow(props) {
{ className: "dsn-row-text" }, { className: "dsn-row-text" },
h3("div", { className: "dsn-row-title" }, props.label) h3("div", { className: "dsn-row-title" }, props.label)
), ),
h3("button", { h3(
"div",
{ className: "dsn-chips", role: "radiogroup", "aria-label": props.label },
props.options.map((option) => h3("button", {
key: String(option.value),
type: "button",
role: "radio",
className: "dsn-chip",
"aria-checked": option.value === props.value,
onClick: () => props.onChange(option.value)
}, option.label))
)
);
}
// src/client/ui/KindRow.js
var React5 = __toESM(require("react"), 1);
var h4 = React5.createElement;
function KindRow(props) {
return h4(
"div",
{ className: "dsn-row" },
h4(
"div",
{ className: "dsn-row-text" },
h4("div", { className: "dsn-row-title" }, props.label)
),
h4("button", {
type: "button", type: "button",
role: "switch", role: "switch",
className: "dsn-switch", className: "dsn-switch",
@@ -1035,16 +1119,16 @@ function KindRow(props) {
"aria-label": props.label, "aria-label": props.label,
disabled: props.disabled, disabled: props.disabled,
onClick: props.onToggle onClick: props.onToggle
}, h3("span", { className: "dsn-thumb" })) }, h4("span", { className: "dsn-thumb" }))
); );
} }
// src/client/ui/ConfigSection.js // src/client/ui/ConfigSection.js
var h4 = React5.createElement; var h5 = React6.createElement;
function ConfigSection(props) { function ConfigSection(props) {
const snapshot = useOwnStore(props.store); const snapshot = useOwnStore(props.store);
const tr = seatTranslator(props, props.fallbackT); const tr = seatTranslator(props, props.fallbackT);
if (props.view === "summary") return h4(React5.Fragment, null, tr("settings.description")); if (props.view === "summary") return h5(React6.Fragment, null, tr("settings.description"));
const permission = snapshot.permission; const permission = snapshot.permission;
const supported = permission !== "unsupported"; const supported = permission !== "unsupported";
const failed = permission === "denied" || snapshot.promptError !== ""; const failed = permission === "denied" || snapshot.promptError !== "";
@@ -1057,25 +1141,25 @@ function ConfigSection(props) {
const healthText = health.state === "watching" ? tr("settings.health.watching") : health.state === "noHooks" ? tr("settings.health.noHooks") : health.state === "error" ? tr("settings.health.error", { message: health.message }) : tr("settings.health.starting"); const healthText = health.state === "watching" ? tr("settings.health.watching") : health.state === "noHooks" ? tr("settings.health.noHooks") : health.state === "error" ? tr("settings.health.error", { message: health.message }) : tr("settings.health.starting");
const healthFailed = health.state !== "watching" && health.state !== "starting"; const healthFailed = health.state !== "watching" && health.state !== "starting";
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr); const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr);
return h4( return h5(
React5.Fragment, React6.Fragment,
null, null,
h4("style", null, CSS), h5("style", null, CSS),
h4( h5(
"div", "div",
{ className: "dsn-page" }, { className: "dsn-page" },
// The page header already shows this bundle's title and description, so this // 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 // lede says what the header cannot: how an alert travels, and that the
// channel is chosen at delivery time. // channel is chosen at delivery time.
h4("p", { className: "dsn-lede" }, tr("config.intro")), h5("p", { className: "dsn-lede" }, tr("config.intro")),
h4( h5(
"section", "section",
{ className: "dsn-group" }, { className: "dsn-group" },
h4("h3", { className: "dsn-group-title" }, tr("config.section.kinds")), h5("h3", { className: "dsn-group-title" }, tr("config.section.kinds")),
h4( h5(
"div", "div",
{ className: "dsn-list" }, { className: "dsn-list" },
KINDS.map((kind) => h4(KindRow, { KINDS.map((kind) => h5(KindRow, {
key: kind, key: kind,
label: tr(`settings.kind.${kind}`), label: tr(`settings.kind.${kind}`),
checked: snapshot.kinds[kind] === true, checked: snapshot.kinds[kind] === true,
@@ -1083,33 +1167,49 @@ function ConfigSection(props) {
onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true) onToggle: () => props.setKind(kind, snapshot.kinds[kind] !== true)
})) }))
), ),
h4("p", { className: "dsn-group-note" }, tr("config.saveNote")) h5("p", { className: "dsn-group-note" }, tr("config.saveNote"))
), ),
h4( h5(
"section", "section",
{ className: "dsn-group" }, { className: "dsn-group" },
h4("h3", { className: "dsn-group-title" }, tr("config.section.permission")), h5("h3", { className: "dsn-group-title" }, tr("config.section.popup")),
h4( h5(
"div", "div",
{ className: "dsn-list" }, { className: "dsn-list" },
h4( h5(DismissRow, {
label: tr("settings.dismiss.label"),
options: DISMISS_CHOICES.map((value) => ({ value, label: tr(`settings.dismiss.${value}`) })),
value: snapshot.dismissAfterMs,
onChange: props.setDismiss
})
),
h5("p", { className: "dsn-group-note" }, tr("config.dismiss.note"))
),
h5(
"section",
{ className: "dsn-group" },
h5("h3", { className: "dsn-group-title" }, tr("config.section.permission")),
h5(
"div",
{ className: "dsn-list" },
h5(
"div", "div",
{ className: "dsn-row" }, { className: "dsn-row" },
h4( h5(
"div", "div",
{ className: "dsn-row-text" }, { className: "dsn-row-text" },
h4( h5(
"span", "span",
{ {
className: "dsn-state", className: "dsn-state",
role: failed ? "alert" : "status" role: failed ? "alert" : "status"
}, },
h4("span", { className: `dsn-dot${dotClass}` }), h5("span", { className: `dsn-dot${dotClass}` }),
permissionText permissionText
), ),
h4("p", { className: "dsn-row-sub" }, permissionNote) h5("p", { className: "dsn-row-sub" }, permissionNote)
), ),
h4("button", { h5("button", {
type: "button", type: "button",
className: `dsn-button${supported && permission !== "granted" ? " is-primary" : ""}`, className: `dsn-button${supported && permission !== "granted" ? " is-primary" : ""}`,
disabled: !supported, disabled: !supported,
@@ -1118,51 +1218,51 @@ function ConfigSection(props) {
) )
) )
), ),
h4( h5(
"section", "section",
{ className: "dsn-group" }, { className: "dsn-group" },
h4("h3", { className: "dsn-group-title" }, tr("config.section.status")), h5("h3", { className: "dsn-group-title" }, tr("config.section.status")),
h4( h5(
"dl", "dl",
{ className: "dsn-facts" }, { className: "dsn-facts" },
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.window")), h5("dt", { className: "dsn-fact-key" }, tr("config.diag.window")),
h4( h5(
"dd", "dd",
{ className: "dsn-fact" }, { className: "dsn-fact" },
h4("p", { className: "dsn-fact-value" }, tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront")) h5("p", { className: "dsn-fact-value" }, tr(windowIsAway() ? "config.diag.away" : "config.diag.inFront"))
), ),
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")), h5("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")),
h4( h5(
"dd", "dd",
{ className: "dsn-fact" }, { className: "dsn-fact" },
h4("p", { h5("p", {
className: `dsn-fact-value${healthFailed ? " is-error" : ""}`, className: `dsn-fact-value${healthFailed ? " is-error" : ""}`,
role: healthFailed ? "alert" : "status" role: healthFailed ? "alert" : "status"
}, healthText) }, healthText)
), ),
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.last")), h5("dt", { className: "dsn-fact-key" }, tr("config.diag.last")),
h4( h5(
"dd", "dd",
{ className: "dsn-fact" }, { className: "dsn-fact" },
delivery === null ? h4("p", { className: "dsn-fact-value" }, tr("config.diag.none")) : [ delivery === null ? h5("p", { className: "dsn-fact-value" }, tr("config.diag.none")) : [
h4("p", { key: "line", className: "dsn-fact-value" }, `${delivery.at} · ${delivery.outcome}`), h5("p", { key: "line", className: "dsn-fact-value" }, `${delivery.at} · ${delivery.outcome}`),
delivery.title === "" ? null : h4("p", { key: "title", className: "dsn-fact-title" }, delivery.title) delivery.title === "" ? null : h5("p", { key: "title", className: "dsn-fact-title" }, delivery.title)
] ]
), ),
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")), h5("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")),
h4( h5(
"dd", "dd",
{ className: "dsn-fact" }, { className: "dsn-fact" },
h4("p", { className: "dsn-fact-value" }, props.copy.replayValue(props.replayCount(), tr)) h5("p", { className: "dsn-fact-value" }, props.copy.replayValue(props.replayCount(), tr))
) )
), ),
h4( h5(
"div", "div",
{ className: "dsn-list" }, { className: "dsn-list" },
h4( h5(
"div", "div",
{ className: "dsn-row is-action" }, { className: "dsn-row is-action" },
h4("button", { h5("button", {
type: "button", type: "button",
className: "dsn-button", className: "dsn-button",
onClick: props.testAny onClick: props.testAny
@@ -1175,15 +1275,15 @@ function ConfigSection(props) {
} }
// src/client/ui/NotifyObserver.js // src/client/ui/NotifyObserver.js
var React6 = __toESM(require("react"), 1); var React7 = __toESM(require("react"), 1);
function NotifyObserver(props) { function NotifyObserver(props) {
useWindowState(); useWindowState();
const useSessions = props.useSessions; const useSessions = props.useSessions;
const useSessionStatus = props.useSessionStatus; const useSessionStatus = props.useSessionStatus;
const select = React6.useCallback((snapshot) => snapshot, []); const select = React7.useCallback((snapshot) => snapshot, []);
const list = typeof useSessions === "function" ? useSessions(select) : void 0; const list = typeof useSessions === "function" ? useSessions(select) : void 0;
const status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0; const status = typeof useSessionStatus === "function" ? useSessionStatus(select) : void 0;
React6.useEffect(() => { React7.useEffect(() => {
try { try {
if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") { if (typeof props.useSessions !== "function" || typeof props.useSessionStatus !== "function") {
props.observation.reportHealth({ state: "noHooks", message: "" }); props.observation.reportHealth({ state: "noHooks", message: "" });
@@ -1210,6 +1310,7 @@ function apply(ctx) {
const store = createStore({ const store = createStore({
permission: notificationPermission(), permission: notificationPermission(),
kinds: readStoredKinds(), kinds: readStoredKinds(),
dismissAfterMs: readStoredDismissMs(),
toasts: [], toasts: [],
promptError: "", promptError: "",
health: { state: "starting", message: "" }, health: { state: "starting", message: "" },
@@ -1312,7 +1413,8 @@ function apply(ctx) {
ask: actions.requestPermission, ask: actions.requestPermission,
testAny: actions.sendTest, testAny: actions.sendTest,
testSystem: actions.sendTestSystem, testSystem: actions.sendTestSystem,
setKind: actions.setKindEnabled setKind: actions.setKindEnabled,
setDismiss: actions.setDismissAfter
}) })
}, ConfigSection)); }, ConfigSection));
ctx.slots.inject("shell.overlay", () => ctx.slots.register({ ctx.slots.inject("shell.overlay", () => ctx.slots.register({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dsh-plugin/session-notify", "name": "@dsh-plugin/session-notify",
"version": "1.0.8", "version": "1.0.10",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。", "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. */ /** Settle delay before a completion is delivered, so a resumed run stays silent. */
export const SETTLE_MS = 400 export const SETTLE_MS = 400
/** In-app popup lifetime, and how many may stack. */ /** Default in-app popup lifetime, and how many may stack. */
export const TOAST_DURATION_MS = 6000 export const TOAST_DURATION_MS = 6000
export const TOAST_LIMIT = 3 export const TOAST_LIMIT = 3
/**
* The popup lifetimes the settings page offers, in milliseconds, in the order it
* shows them. `0` is the "never auto-close" choice: such a card stays until the
* user dismisses it (✕, `Esc`, or a newer card pushing it out).
*/
export const DISMISS_CHOICES = [3000, 6000, 10000, 30000, 0]
/** Notifications kept referenced, so a collection can never cancel a pending display. */ /** Notifications kept referenced, so a collection can never cancel a pending display. */
export const RAISED_LIMIT = 8 export const RAISED_LIMIT = 8
+17 -2
View File
@@ -6,7 +6,7 @@
*/ */
import { notificationApi, notificationPermission } from '../platform.js' import { notificationApi, notificationPermission } from '../platform.js'
import { writeStoredKinds } from './storage.js' import { writeStoredDismissMs, writeStoredKinds } from './storage.js'
import { text } from './log.js' import { text } from './log.js'
/** /**
@@ -83,5 +83,20 @@ export function createActions({ store, t, delivery, system, toasts, publish }) {
store.set({ ...snapshot, kinds }) store.set({ ...snapshot, kinds })
} }
return { sendTest, sendTestSystem, requestPermission, setKindEnabled } /**
* Remember how long an in-app popup lives.
*
* The stack reads this at the moment it arms a card, and the cards already on
* screen are re-armed so the new choice applies to them too — except a card the
* pointer is resting on, which nothing may close.
* @param ms - milliseconds, or `0` for "never auto-close".
*/
const setDismissAfter = (ms) => {
writeStoredDismissMs(ms)
const snapshot = store.getSnapshot()
store.set({ ...snapshot, dismissAfterMs: ms })
toasts.rearmAll()
}
return { sendTest, sendTestSystem, requestPermission, setKindEnabled, setDismissAfter }
} }
+4 -1
View File
@@ -16,7 +16,10 @@ export function createCopy(t) {
const copyFor = (candidate) => { const copyFor = (candidate) => {
const title = candidate.title === '' ? t('body.untitled') : candidate.title const title = candidate.title === '' ? t('body.untitled') : candidate.title
if (candidate.kind === 'completion') { if (candidate.kind === 'completion') {
return { kind: 'completion', title: t('notification.completion'), body: t('body.completion', { title }) } // 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 // 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, // answered before the question branch below — a plan review says what it is,
+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 * @module @dsh-plugin/session-notify/client/core/storage
*/ */
import { KINDS } from '../constants.js' import { DISMISS_CHOICES, KINDS, TOAST_DURATION_MS } from '../constants.js'
/** Storage key for this package's own preferences. */ /** Storage key for this package's own preferences. */
export const STORAGE_KEY = 'dsh-plugin/session-notify' export const STORAGE_KEY = 'dsh-plugin/session-notify'
/** Every preference, at the value a browser that has never seen this plugin uses. */
function defaults() {
return { completion: true, approval: true, question: true, dismissAfterMs: TOAST_DURATION_MS }
}
/** /**
* Read this package's stored trigger switches. * Read the stored preferences, one field at a time.
* *
* These are browser preferences rather than cordis configuration, so they live * A field is only taken when it has the shape this version understands: a
* in this origin's own storage instead of the profile's patch file: the plugin * boolean per trigger, and a popup lifetime from {@link DISMISS_CHOICES}. Anything
* writes nothing into DSH's configuration, and every read is defensive because * else keeps the default, so a foreign or older value can never produce a state
* storage can be unavailable or hold something an older version wrote. * the plugin cannot render.
* @returns the stored switches, defaulting to everything on. * @returns the effective preferences.
*/ */
export function readStoredKinds() { function readStored() {
const kinds = { completion: true, approval: true, question: true } const preferences = defaults()
try { try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY) const raw = globalThis.localStorage?.getItem(STORAGE_KEY)
if (typeof raw !== 'string' || raw === '') return kinds if (typeof raw !== 'string' || raw === '') return preferences
const stored = JSON.parse(raw) const stored = JSON.parse(raw)
for (const kind of KINDS) { for (const kind of KINDS) {
if (typeof stored?.[kind] === 'boolean') kinds[kind] = stored[kind] if (typeof stored?.[kind] === 'boolean') preferences[kind] = stored[kind]
} }
if (DISMISS_CHOICES.includes(stored?.dismissAfterMs)) preferences.dismissAfterMs = stored.dismissAfterMs
} catch { } catch {
/* unreadable or foreign storage keeps the defaults */ /* unreadable or foreign storage keeps the defaults */
} }
return kinds return preferences
}
/** Write a patch over the stored preferences, keeping every field the patch does not name. */
function writeStored(patch) {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify({ ...readStored(), ...patch }))
} catch {
/* a refused write leaves the preference live for this page only */
}
}
/**
* Read this package's stored trigger switches.
* @returns the stored switches, defaulting to everything on.
*/
export function readStoredKinds() {
const { completion, approval, question } = readStored()
return { completion, approval, question }
} }
/** Store one trigger switch, ignoring storage that refuses to write. */ /** Store one trigger switch, ignoring storage that refuses to write. */
export function writeStoredKinds(kinds) { export function writeStoredKinds(kinds) {
try { writeStored(kinds)
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(kinds)) }
} catch {
/* a refused write leaves the switches live for this page only */ /**
} * Read the stored in-app popup lifetime.
* @returns milliseconds before a card closes itself, or `0` for "never".
*/
export function readStoredDismissMs() {
return readStored().dismissAfterMs
}
/**
* Store the popup lifetime the user picked.
* @param ms - milliseconds, or `0` for "never auto-close".
*/
export function writeStoredDismissMs(ms) {
writeStored({ dismissAfterMs: ms })
} }
+40 -3
View File
@@ -1,6 +1,11 @@
/** /**
* The light in-app popup stack: its lifetime, its ordering, and its bound. * The light in-app popup stack: its lifetime, its ordering, and its bound.
* *
* The lifetime is the user's, read from the store at the moment a card is armed:
* the settings page offers a few delays plus "never", and re-arming after a hover
* picks the current choice up. `0` means the card waits for the user instead of a
* timer.
*
* @module @dsh-plugin/session-notify/client/core/toasts * @module @dsh-plugin/session-notify/client/core/toasts
*/ */
@@ -13,10 +18,19 @@ import { TOAST_DURATION_MS, TOAST_LIMIT } from '../constants.js'
*/ */
export function createToastStack({ store, copy }) { export function createToastStack({ store, copy }) {
const timers = new Map() const timers = new Map()
/** Cards the pointer is resting on: they carry no timer until it leaves. */
const held = new Set()
let seq = 0 let seq = 0
/** The configured lifetime, falling back to the default for a value this version cannot use. */
const lifetime = () => {
const configured = store.getSnapshot().dismissAfterMs
return typeof configured === 'number' && configured >= 0 ? configured : TOAST_DURATION_MS
}
/** Drop one popup and cancel its lifetime timer. */ /** Drop one popup and cancel its lifetime timer. */
const closeToast = (id) => { const closeToast = (id) => {
held.delete(id)
const timer = timers.get(id) const timer = timers.get(id)
if (timer !== undefined) { if (timer !== undefined) {
clearTimeout(timer) clearTimeout(timer)
@@ -27,21 +41,42 @@ export function createToastStack({ store, copy }) {
store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) }) store.set({ ...snapshot, toasts: snapshot.toasts.filter((toast) => toast.id !== id) })
} }
/** Arm one popup's lifetime; hovering calls `hold` first, then this again. */ /**
* Arm one popup's lifetime; hovering calls `hold` first, then this again.
* A lifetime of `0` arms nothing: the card stays until it is dismissed.
*/
const armToast = (id) => { const armToast = (id) => {
held.delete(id)
const timer = timers.get(id) const timer = timers.get(id)
if (timer !== undefined) clearTimeout(timer) if (timer !== undefined) clearTimeout(timer)
timers.set(id, setTimeout(() => closeToast(id), TOAST_DURATION_MS)) timers.delete(id)
const duration = lifetime()
if (duration === 0) return
timers.set(id, setTimeout(() => closeToast(id), duration))
} }
/** Pause one popup's lifetime while the pointer rests on it. */ /** Pause one popup's lifetime while the pointer rests on it. */
const holdToast = (id) => { const holdToast = (id) => {
held.add(id)
const timer = timers.get(id) const timer = timers.get(id)
if (timer === undefined) return if (timer === undefined) return
clearTimeout(timer) clearTimeout(timer)
timers.delete(id) timers.delete(id)
} }
/**
* Apply the current lifetime to the cards already on screen.
*
* Changing the setting restarts the countdown for what is visible — otherwise
* a card raised under the old lifetime would still close on the old schedule.
* A held card is left alone: the pointer is on it, so nothing may close it.
*/
const rearmAll = () => {
for (const toast of store.getSnapshot().toasts) {
if (!held.has(toast.id)) armToast(toast.id)
}
}
/** /**
* Show one light in-app popup: the trigger as the title, the conversation * Show one light in-app popup: the trigger as the title, the conversation
* and its detail as the description, so the heading says what happened and * and its detail as the description, so the heading says what happened and
@@ -65,6 +100,7 @@ export function createToastStack({ store, copy }) {
clearTimeout(timer) clearTimeout(timer)
timers.delete(dropped.id) timers.delete(dropped.id)
} }
held.delete(dropped.id)
} }
store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) }) store.set({ ...snapshot, toasts: next.slice(0, TOAST_LIMIT) })
armToast(toast.id) armToast(toast.id)
@@ -74,7 +110,8 @@ export function createToastStack({ store, copy }) {
const dispose = () => { const dispose = () => {
for (const timer of timers.values()) clearTimeout(timer) for (const timer of timers.values()) clearTimeout(timer)
timers.clear() timers.clear()
held.clear()
} }
return { showToast, closeToast, armToast, holdToast, dispose } return { showToast, closeToast, armToast, holdToast, rearmAll, dispose }
} }
+8 -3
View File
@@ -12,7 +12,6 @@ export const en = {
'notification.completion': 'Conversation finished', 'notification.completion': 'Conversation finished',
'notification.approval': 'Approval required', 'notification.approval': 'Approval required',
'notification.question': 'Answer needed', 'notification.question': 'Answer needed',
'body.completion': '{title} finished this round',
'body.approval': '{title}: {tool} is waiting for your approval', 'body.approval': '{title}: {tool} is waiting for your approval',
'body.approvalPlain': '{title}: a tool is waiting for your approval', 'body.approvalPlain': '{title}: a tool is waiting for your approval',
'body.question': '{title}: waiting for your answer', 'body.question': '{title}: waiting for your answer',
@@ -20,7 +19,6 @@ export const en = {
'body.untitled': 'Untitled conversation', 'body.untitled': 'Untitled conversation',
'toast.view': 'View', 'toast.view': 'View',
'toast.dismiss': 'Dismiss', 'toast.dismiss': 'Dismiss',
'settings.title': 'Session notifications',
'settings.description': 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.', 'settings.description': 'A system notification while the window is in the background, a light in-app popup while it is in the foreground; the conversation on screen is never interrupted.',
'settings.permission.granted': 'System notifications are on', 'settings.permission.granted': 'System notifications are on',
'settings.permission.default': 'System notification permission has not been granted', 'settings.permission.default': 'System notification permission has not been granted',
@@ -39,15 +37,22 @@ export const en = {
'settings.health.noHooks': 'No session status received (notifications will not fire)', 'settings.health.noHooks': 'No session status received (notifications will not fire)',
'settings.health.error': 'Watching session status failed: {message}', 'settings.health.error': 'Watching session status failed: {message}',
'settings.health.starting': 'Waiting for the session list', 'settings.health.starting': 'Waiting for the session list',
'settings.kinds.label': 'Notify about',
'settings.kind.completion': 'Finished', 'settings.kind.completion': 'Finished',
'settings.kind.approval': 'Approval', 'settings.kind.approval': 'Approval',
'settings.kind.question': 'Questions', 'settings.kind.question': 'Questions',
'settings.dismiss.label': 'Auto-close',
'settings.dismiss.3000': '3s',
'settings.dismiss.6000': '6s',
'settings.dismiss.10000': '10s',
'settings.dismiss.30000': '30s',
'settings.dismiss.0': 'Never',
'config.section.kinds': 'What to notify about', 'config.section.kinds': 'What to notify about',
'config.section.popup': 'Popup',
'config.section.permission': 'System notification permission', 'config.section.permission': 'System notification permission',
'config.section.status': 'Status', 'config.section.status': 'Status',
'config.intro': 'Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.', 'config.intro': 'Every alert comes from this page: a system notification while the window is in the background, the light popup here while it is in the foreground.',
'config.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.', 'config.saveNote': 'The switches are stored in this browser and survive reinstalling the plugin.',
'config.dismiss.note': 'Hovering a card pauses the countdown; with Never, close each one with ✕ or Esc.',
'config.testHint': 'The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).', 'config.testHint': 'The buttons here go straight to the system channel, so you can verify it on its own (they fire even while the window is in front).',
'config.testAny': 'Test alert', 'config.testAny': 'Test alert',
'config.testSystem': 'Test system notification', 'config.testSystem': 'Test system notification',
+8 -3
View File
@@ -12,7 +12,6 @@ export const zh = {
'notification.completion': '会话已完成', 'notification.completion': '会话已完成',
'notification.approval': '需要授权', 'notification.approval': '需要授权',
'notification.question': '需要回答', 'notification.question': '需要回答',
'body.completion': '{title} 已完成这一轮回答',
'body.approval': '{title}:工具 {tool} 正在等待你的授权', 'body.approval': '{title}:工具 {tool} 正在等待你的授权',
'body.approvalPlain': '{title}:有工具正在等待你的授权', 'body.approvalPlain': '{title}:有工具正在等待你的授权',
'body.question': '{title}:正在等待你的回答', 'body.question': '{title}:正在等待你的回答',
@@ -20,7 +19,6 @@ export const zh = {
'body.untitled': '未命名会话', 'body.untitled': '未命名会话',
'toast.view': '查看', 'toast.view': '查看',
'toast.dismiss': '关闭', 'toast.dismiss': '关闭',
'settings.title': '会话通知',
'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。', 'settings.description': '窗口不在前台时用系统通知,窗口在前台时用应用内轻弹窗;正在看的那个会话完成后不打扰。',
'settings.permission.granted': '系统通知已开启', 'settings.permission.granted': '系统通知已开启',
'settings.permission.default': '尚未授予系统通知权限', 'settings.permission.default': '尚未授予系统通知权限',
@@ -39,15 +37,22 @@ export const zh = {
'settings.health.noHooks': '未收到会话状态(通知不会触发)', 'settings.health.noHooks': '未收到会话状态(通知不会触发)',
'settings.health.error': '监听会话状态出错:{message}', 'settings.health.error': '监听会话状态出错:{message}',
'settings.health.starting': '正在等待会话列表', 'settings.health.starting': '正在等待会话列表',
'settings.kinds.label': '提醒内容',
'settings.kind.completion': '完成', 'settings.kind.completion': '完成',
'settings.kind.approval': '授权', 'settings.kind.approval': '授权',
'settings.kind.question': '提问', 'settings.kind.question': '提问',
'settings.dismiss.label': '自动关闭',
'settings.dismiss.3000': '3 秒',
'settings.dismiss.6000': '6 秒',
'settings.dismiss.10000': '10 秒',
'settings.dismiss.30000': '30 秒',
'settings.dismiss.0': '永不',
'config.section.kinds': '提醒内容', 'config.section.kinds': '提醒内容',
'config.section.popup': '轻弹窗',
'config.section.permission': '系统通知权限', 'config.section.permission': '系统通知权限',
'config.section.status': '运行状态', 'config.section.status': '运行状态',
'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。', 'config.intro': '提醒都从当前页面发出:窗口不在前台时是系统通知,窗口在前台时是这里的轻弹窗。',
'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。', 'config.saveNote': '开关保存在浏览器本地,重装插件不会丢失。',
'config.dismiss.note': '鼠标停在上面时不会消失;选「永不」时,每条要自己点 ✕ 或按 Esc 关掉。',
'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。', 'config.testHint': '这里的按钮直接走系统通知通道,用来验证系统通知本身是否可用(窗口在前台也照发)。',
'config.testAny': '测试提醒', 'config.testAny': '测试提醒',
'config.testSystem': '测试系统通知', 'config.testSystem': '测试系统通知',
+3 -1
View File
@@ -37,7 +37,7 @@ import { createTranslator, registerDictionaries } from './i18n/index.js'
import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js' import { focusWindow, notificationPermission, platformKey, windowIsAway } from './platform.js'
import { report, text } from './core/log.js' import { report, text } from './core/log.js'
import { createStore } from './core/store.js' import { createStore } from './core/store.js'
import { readStoredKinds } from './core/storage.js' import { readStoredDismissMs, readStoredKinds } from './core/storage.js'
import { createCopy } from './core/copy.js' import { createCopy } from './core/copy.js'
import { createToastStack } from './core/toasts.js' import { createToastStack } from './core/toasts.js'
import { createReplay } from './core/replay.js' import { createReplay } from './core/replay.js'
@@ -63,6 +63,7 @@ export function apply(ctx) {
const store = createStore({ const store = createStore({
permission: notificationPermission(), permission: notificationPermission(),
kinds: readStoredKinds(), kinds: readStoredKinds(),
dismissAfterMs: readStoredDismissMs(),
toasts: [], toasts: [],
promptError: '', promptError: '',
health: { state: 'starting', message: '' }, health: { state: 'starting', message: '' },
@@ -194,6 +195,7 @@ export function apply(ctx) {
testAny: actions.sendTest, testAny: actions.sendTest,
testSystem: actions.sendTestSystem, testSystem: actions.sendTestSystem,
setKind: actions.setKindEnabled, setKind: actions.setKindEnabled,
setDismiss: actions.setDismissAfter,
}), }),
}, ConfigSection)) }, ConfigSection))
+13 -1
View File
@@ -16,8 +16,9 @@
*/ */
import * as React from 'react' import * as React from 'react'
import { KINDS } from '../constants.js' import { DISMISS_CHOICES, KINDS } from '../constants.js'
import { windowIsAway } from '../platform.js' import { windowIsAway } from '../platform.js'
import { DismissRow } from './DismissRow.js'
import { KindRow } from './KindRow.js' import { KindRow } from './KindRow.js'
import { seatTranslator, useOwnStore } from './hooks.js' import { seatTranslator, useOwnStore } from './hooks.js'
import { CSS } from './styles.js' import { CSS } from './styles.js'
@@ -84,6 +85,17 @@ export function ConfigSection(props) {
}))), }))),
h('p', { className: 'dsn-group-note' }, tr('config.saveNote'))), 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('section', { className: 'dsn-group' },
h('h3', { className: 'dsn-group-title' }, tr('config.section.permission')), h('h3', { className: 'dsn-group-title' }, tr('config.section.permission')),
h('div', { className: 'dsn-list' }, h('div', { className: 'dsn-list' },
+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))))
}
+32 -18
View File
@@ -1,20 +1,29 @@
/** /**
* Every style declaration this package owns. * Every style declaration this package owns.
* *
* The popup copies the shipped toast surface (a fixed entry in the frame-wide * Both surfaces are built out of the theme's own vocabulary and nothing else, so
* overlay, the `--dsw-alias-toast-bg` / `--dsw-alias-toast-label` pair, * they follow the shell's light and dark modes without a branch of their own (the
* `--dsw-radius-lg`, `--dsw-shadow-lv3`) and adds a title line above the text. * theme swaps every `--dsw-alias-*` under `body[data-ds-dark-theme]`):
* *
* The settings page is a reading order, not a form dump: a lede that says how * - The popup is a floating card, using the same recipe the shipped menus and
* alerts travel, then one group per concern. Inside a group a title introduces a * modals use — `--dsw-alias-bg-layer-2` as the surface, the
* list whose rows put the subject on the left and the single control on the * `--dsw-elevation-stroke-color` / `--dsw-elevation-prominent` pair for its
* right, with hairlines only between rows; the status group is a two-column fact * hairline and shadow, `--dsw-alias-label-*` for text, and the shell's motion
* list, because four sentences stacked in one cell are not scannable. Class * tokens. It deliberately does NOT use `--dsw-alias-toast-bg`, which is a fixed
* names are renamed under `dsn-`; only the popup glyphs carry their own artwork * dark slate (#353638 in light mode) and would read as a black slab on a light
* colors. * page.
* - The settings page is a reading order, not a form dump: a lede that says how
* alerts travel, then one group per concern. Inside a group a title introduces
* a list whose rows put the subject on the left and the single control on the
* right, with hairlines only between rows; the status group is a two-column
* fact list, because four sentences stacked in one cell are not scannable.
*
* Class names are renamed under `dsn-`; only the popup glyphs carry their own
* artwork colors — and those are theme-aware state tokens too.
* *
* A plugin must not import a Harness Client package, so the declarations are * A plugin must not import a Harness Client package, so the declarations are
* this package's own text and reference theme tokens only. * this package's own text. `tests/unit/styles.test.js` holds both invariants:
* tokens only, and no theme-fixed color.
* *
* @module @dsh-plugin/session-notify/client/ui/styles * @module @dsh-plugin/session-notify/client/ui/styles
*/ */
@@ -22,21 +31,21 @@
export const CSS = ` export const CSS = `
.dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none} .dsn-stack{position:fixed;top:40px;right:24px;z-index:1100;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
@media (max-width:520px){.dsn-stack{right:12px}} @media (max-width:520px){.dsn-stack{right:12px}}
.dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-toast-bg);color:var(--dsw-alias-toast-label);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3);animation:dsn-toast-in 160ms ease-out} .dsn-toast{box-sizing:border-box;pointer-events:auto;display:flex;align-items:flex-start;gap:10px;width:max-content;max-width:min(420px,calc(100vw - 48px));padding:12px 12px 12px 16px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;animation:dsn-toast-in var(--ds-transition-duration,.2s) var(--ds-ease-in-out,ease-out)}
@keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}} @keyframes dsn-toast-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:translateX(0)}}
@media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}} @media (prefers-reduced-motion: reduce){.dsn-toast{animation:none}}
.dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)} .dsn-toast-icon{display:grid;place-items:center;flex:none;height:22px;color:var(--dsw-alias-state-warn-label)}
.dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)} .dsn-toast-icon.is-completion{color:var(--dsw-alias-state-success-primary)}
.dsn-toast-icon.is-question{color:var(--dsw-alias-brand-primary)} .dsn-toast-icon.is-question{color:var(--dsw-alias-state-business-primary)}
.dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .dsn-toast-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.dsn-toast-title{font-weight:500;font-size:14px;line-height:22px} .dsn-toast-title{color:var(--dsw-alias-label-primary);font-weight:500;font-size:14px;line-height:22px}
.dsn-toast-desc{color:var(--dsw-alias-toast-label);opacity:.8;font-size:13px;line-height:20px;overflow-wrap:break-word} .dsn-toast-desc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;overflow-wrap:break-word}
.dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none} .dsn-toast-actions{display:flex;align-items:center;gap:2px;flex:none}
.dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-static-deepseek-400);font:inherit;font-size:13px;line-height:20px;cursor:pointer} .dsn-toast-action{display:inline-flex;align-items:center;height:28px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:none;color:var(--dsw-alias-state-business-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
.dsn-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)} .dsn-toast-action:hover{background:var(--dsw-alias-interactive-bg-hover)}
.dsn-toast-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} .dsn-toast-action:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-toast-label);opacity:.8;cursor:pointer} .dsn-toast-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}
.dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} .dsn-toast-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px} .dsn-toast-close:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
.dsn-page{display:flex;flex-direction:column;gap:28px;max-width:560px} .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-lede{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
@@ -49,6 +58,11 @@ export const CSS = `
.dsn-row.is-action{justify-content:flex-end;padding:12px 0 0} .dsn-row.is-action{justify-content:flex-end;padding:12px 0 0}
.dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0} .dsn-row-text{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px} .dsn-row-title{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
.dsn-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-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-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{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-success-primary)}
+2
View File
@@ -137,6 +137,8 @@ test('applying the plugin registers its three seats without touching the DOM', (
assert.equal(typeof configProps.testAny, 'function') assert.equal(typeof configProps.testAny, 'function')
assert.equal(typeof configProps.testSystem, 'function') assert.equal(typeof configProps.testSystem, 'function')
assert.equal(typeof configProps.setKind, 'function') assert.equal(typeof configProps.setKind, 'function')
assert.equal(typeof configProps.setDismiss, 'function')
assert.equal(configProps.store.getSnapshot().dismissAfterMs, 6000, 'a browser with no stored choice gets the shipped lifetime')
assert.equal(configProps.replayCount(), 0) assert.equal(configProps.replayCount(), 0)
// No locale service in this fake context, so the plugin's own translator falls // No locale service in this fake context, so the plugin's own translator falls
// back to its English dictionary. // back to its English dictionary.
+36 -1
View File
@@ -122,6 +122,11 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
'Every alert comes from this page', 'Every alert comes from this page',
'What to notify about', 'What to notify about',
'The switches are stored in this browser', 'The switches are stored in this browser',
'Popup',
'Auto-close',
'6s',
'Never',
'Hovering a card pauses the countdown',
'System notification permission', 'System notification permission',
'This environment cannot show system notifications', 'This environment cannot show system notifications',
platformHint, platformHint,
@@ -149,6 +154,8 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
'dsn-row', 'dsn-row',
'dsn-row-title', 'dsn-row-title',
'dsn-state', 'dsn-state',
'dsn-chips',
'dsn-chip',
'dsn-facts', 'dsn-facts',
'dsn-fact-key', 'dsn-fact-key',
'dsn-fact-value', 'dsn-fact-value',
@@ -156,13 +163,41 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
]) { ]) {
assert.ok(classes.includes(className), `the page is missing .${className}`) assert.ok(classes.includes(className), `the page is missing .${className}`)
} }
// Three switches, one status dot, and the readings as key/value pairs. // Three switches, five lifetime choices, one status dot, and the readings as
// key/value pairs.
assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3) assert.equal(classes.filter((className) => className === 'dsn-switch').length, 3)
assert.equal(classes.filter((className) => className === 'dsn-chip').length, 5)
assert.equal(classes.filter((className) => className === 'dsn-dot is-muted').length, 1) 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-key').length, 4)
assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4) assert.equal(classes.filter((className) => className === 'dsn-fact-value').length, 4)
}) })
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', () => { test('the page follows the store: switches, permission state and delivery facts', () => {
const { config } = mount() const { config } = mount()
const props = config.definition.inject() const props = config.definition.inject()
+4 -3
View File
@@ -15,16 +15,17 @@ function candidate(overrides) {
return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides } return { kind: 'completion', sessionId: 'session-1', title: '标题', detail: '', pendingKind: '', ...overrides }
} }
test('a finished conversation names the trigger as the title and the conversation as the body', () => { test('a finished conversation says what happened in the heading, and the conversation in the description', () => {
// The heading already carries "finished", so the description is the title alone.
assert.deepEqual(copyFor(candidate()), { assert.deepEqual(copyFor(candidate()), {
kind: 'completion', kind: 'completion',
title: '会话已完成', title: '会话已完成',
body: '标题 已完成这一轮回答', body: '标题',
}) })
}) })
test('an empty title falls back to the untitled copy', () => { test('an empty title falls back to the untitled copy', () => {
assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话 已完成这一轮回答') assert.equal(copyFor(candidate({ title: '' })).body, '未命名会话')
}) })
test('an approval names the waiting tool when the client publishes one', () => { test('an approval names the waiting tool when the client publishes one', () => {
+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') throw new Error('no snapshot')
}, },
}) })
assert.equal(t('settings.title'), 'Session notifications') assert.equal(t('body.untitled'), 'Untitled conversation')
}) })
test('registration prefers the typed form and falls back to one call per locale', () => { test('registration prefers the typed form and falls back to one call per locale', () => {
+47 -6
View File
@@ -1,10 +1,18 @@
// The browser-local trigger switches. Storage is foreign input: it can be // The browser-local preferences: the three trigger switches and the popup
// absent, hold another version's shape, or refuse to write, and none of those // lifetime. Storage is foreign input: it can be absent, hold another version's
// may stop the plugin from working with its defaults. // shape, or refuse to write, and none of that may stop the plugin — nor may
// writing one preference ever clear another.
import { afterEach, test } from 'node:test' import { afterEach, test } from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { STORAGE_KEY, readStoredKinds, writeStoredKinds } from '../../src/client/core/storage.js' import {
STORAGE_KEY,
readStoredDismissMs,
readStoredKinds,
writeStoredDismissMs,
writeStoredKinds,
} from '../../src/client/core/storage.js'
import { TOAST_DURATION_MS } from '../../src/client/constants.js'
/** Install a storage double, optionally one that throws. */ /** Install a storage double, optionally one that throws. */
function useStorage(behaviour) { function useStorage(behaviour) {
@@ -26,20 +34,22 @@ afterEach(() => {
delete globalThis.localStorage delete globalThis.localStorage
}) })
test('everything is on when this browser has no stored choice', () => { test('everything is on, with the shipped popup lifetime, when this browser has no stored choice', () => {
useStorage() useStorage()
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
}) })
test('without storage at all the defaults still apply', () => { test('without storage at all the defaults still apply', () => {
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true })) assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: true, question: true }))
assert.doesNotThrow(() => writeStoredDismissMs(0))
}) })
test('a stored switch survives a round trip, and unknown or broken values do not', () => { test('a stored switch survives a round trip, and unknown or broken values do not', () => {
const data = useStorage() const data = useStorage()
writeStoredKinds({ completion: false, approval: true, question: false }) writeStoredKinds({ completion: false, approval: true, question: false })
assert.equal(data.get(STORAGE_KEY), JSON.stringify({ completion: false, approval: true, question: false }))
assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false }) assert.deepEqual(readStoredKinds(), { completion: false, approval: true, question: false })
data.set(STORAGE_KEY, '{ not json') data.set(STORAGE_KEY, '{ not json')
@@ -52,10 +62,41 @@ test('a stored switch survives a round trip, and unknown or broken values do not
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
}) })
test('the popup lifetime survives a round trip, including "never"', () => {
const data = useStorage()
writeStoredDismissMs(30000)
assert.equal(readStoredDismissMs(), 30000)
writeStoredDismissMs(0)
assert.equal(readStoredDismissMs(), 0, 'zero means the card waits for the user')
// A value this version does not offer is ignored rather than trusted.
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: 1234 }))
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: -1 }))
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
data.set(STORAGE_KEY, JSON.stringify({ dismissAfterMs: '6000' }))
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
})
test('writing one preference keeps every other one', () => {
useStorage()
writeStoredKinds({ completion: false, approval: false, question: false })
writeStoredDismissMs(0)
assert.deepEqual(readStoredKinds(), { completion: false, approval: false, question: false })
assert.equal(readStoredDismissMs(), 0)
// …and the other way round: the switches do not reset the lifetime.
writeStoredKinds({ completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), 0)
})
test('storage that refuses to be read or written never throws', () => { test('storage that refuses to be read or written never throws', () => {
useStorage('read-throws') useStorage('read-throws')
assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true }) assert.deepEqual(readStoredKinds(), { completion: true, approval: true, question: true })
assert.equal(readStoredDismissMs(), TOAST_DURATION_MS)
useStorage('write-throws') useStorage('write-throws')
assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false })) assert.doesNotThrow(() => writeStoredKinds({ completion: false, approval: false, question: false }))
assert.doesNotThrow(() => writeStoredDismissMs(3000))
}) })
+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')
})