Compare commits
3
Commits
v1.0.9
...
2f89dd90e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f89dd90e5 | ||
|
|
5efb311194 | ||
|
|
faa3930ae8 |
@@ -2,6 +2,17 @@
|
||||
|
||||
版本说明按倒序排列。提交信息遵循 [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]` 一起切换的令牌):
|
||||
|
||||
@@ -35,9 +35,9 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
||||
轻弹窗的用法:
|
||||
|
||||
- 点「查看」跳到该会话;
|
||||
- 6 秒后自动消失;鼠标停在上面期间不消失,移开后重新计时;
|
||||
- **默认 6 秒**后自动消失(可在插件页改成 3 / 10 / 30 秒,或设成**永不关闭**);鼠标停在上面期间都不消失,移开后重新计时;
|
||||
- 按 `Esc` 关掉最新的一条,或点 ✕ 关掉这一条;
|
||||
- 同屏最多叠 3 条,更早的会被挤掉;
|
||||
- 同屏最多叠 3 条,更早的会被挤掉(即使设成永不关闭也一样,不会越堆越多);
|
||||
- 轻弹窗是 DSH 的浮层卡片样式(和菜单、弹窗同一层底色与阴影),**跟随 DSH 的深色 / 浅色模式**。
|
||||
|
||||
## 设置
|
||||
@@ -47,6 +47,7 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
||||
| 分组 | 内容 |
|
||||
| --- | --- |
|
||||
| 提醒内容 | **完成 / 授权 / 提问** 三个开关,分别控制三类提醒,默认全开;开关保存在浏览器本地,重开页面、重装插件都保留 |
|
||||
| 轻弹窗 | **自动关闭时间**:3 / 6 / 10 / 30 秒,或**永不**(永不时要自己点 ✕ 或按 `Esc`);鼠标停在卡片上时都不会消失 |
|
||||
| 系统通知权限 | 一行状态(前面的小圆点表示健康程度)+ 一个按钮;尚未授权时按钮是「允许通知」(浏览器要求必须由你手动点击),授权成功后自动发一条测试提醒,按钮随之变成「测试系统通知」;权限被系统层关闭或当前环境不支持时,状态下面按当前平台告诉你去哪里打开 |
|
||||
| 运行状态 | 四项读数:**窗口状态**(前台还是后台,即会走哪条通道)、**会话监听**(插件是否真的在读会话状态)、**最近一次投递**(时间、走的通道、系统是否确认弹出,下一行是提醒类型)、**待补发提醒**(还有几条在等你回到窗口);下面「测试提醒」按钮发一条测试提醒 |
|
||||
|
||||
@@ -60,10 +61,10 @@ DeepSeek Harness(DSH)插件:会话**需要你注意**时提醒你——窗
|
||||
在 DSH 的 **插件 → 添加插件** 的上方输入框里填:
|
||||
|
||||
```text
|
||||
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.9
|
||||
https://gitea.iwake.top/dsh-plugin/session-notify.git#v1.0.10
|
||||
```
|
||||
|
||||
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.9`。仓库是公开的,不需要凭据,也不用改「安装源」。
|
||||
`#` 后面跟标签或提交,用来锁定版本;不写则取默认分支。跟随 1.x 最新版可以写 `#semver:^1.0.10`。仓库是公开的,不需要凭据,也不用改「安装源」。
|
||||
|
||||
### 本地路径安装
|
||||
|
||||
|
||||
@@ -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,15 +90,22 @@ 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": "测试系统通知",
|
||||
@@ -125,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",
|
||||
@@ -133,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",
|
||||
@@ -152,15 +156,22 @@ 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",
|
||||
@@ -308,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
|
||||
@@ -333,7 +361,7 @@ 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 }) };
|
||||
@@ -365,8 +393,14 @@ function createCopy(t) {
|
||||
// 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);
|
||||
@@ -377,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;
|
||||
@@ -405,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);
|
||||
@@ -412,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
|
||||
@@ -758,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
|
||||
@@ -918,6 +970,11 @@ var CSS = `
|
||||
.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-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)}
|
||||
@@ -1013,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" },
|
||||
@@ -1027,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",
|
||||
@@ -1035,16 +1119,16 @@ 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 !== "";
|
||||
@@ -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 healthFailed = health.state !== "watching" && health.state !== "starting";
|
||||
const delivery = props.copy.deliveryFacts(snapshot.lastDelivery, tr);
|
||||
return h4(
|
||||
React5.Fragment,
|
||||
return h5(
|
||||
React6.Fragment,
|
||||
null,
|
||||
h4("style", null, CSS),
|
||||
h4(
|
||||
h5("style", null, CSS),
|
||||
h5(
|
||||
"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.
|
||||
h4("p", { className: "dsn-lede" }, tr("config.intro")),
|
||||
h4(
|
||||
h5("p", { className: "dsn-lede" }, tr("config.intro")),
|
||||
h5(
|
||||
"section",
|
||||
{ className: "dsn-group" },
|
||||
h4("h3", { className: "dsn-group-title" }, tr("config.section.kinds")),
|
||||
h4(
|
||||
h5("h3", { className: "dsn-group-title" }, tr("config.section.kinds")),
|
||||
h5(
|
||||
"div",
|
||||
{ className: "dsn-list" },
|
||||
KINDS.map((kind) => h4(KindRow, {
|
||||
KINDS.map((kind) => h5(KindRow, {
|
||||
key: kind,
|
||||
label: tr(`settings.kind.${kind}`),
|
||||
checked: snapshot.kinds[kind] === true,
|
||||
@@ -1083,33 +1167,49 @@ function ConfigSection(props) {
|
||||
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",
|
||||
{ className: "dsn-group" },
|
||||
h4("h3", { className: "dsn-group-title" }, tr("config.section.permission")),
|
||||
h4(
|
||||
h5("h3", { className: "dsn-group-title" }, tr("config.section.popup")),
|
||||
h5(
|
||||
"div",
|
||||
{ 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",
|
||||
{ className: "dsn-row" },
|
||||
h4(
|
||||
h5(
|
||||
"div",
|
||||
{ className: "dsn-row-text" },
|
||||
h4(
|
||||
h5(
|
||||
"span",
|
||||
{
|
||||
className: "dsn-state",
|
||||
role: failed ? "alert" : "status"
|
||||
},
|
||||
h4("span", { className: `dsn-dot${dotClass}` }),
|
||||
h5("span", { className: `dsn-dot${dotClass}` }),
|
||||
permissionText
|
||||
),
|
||||
h4("p", { className: "dsn-row-sub" }, permissionNote)
|
||||
h5("p", { className: "dsn-row-sub" }, permissionNote)
|
||||
),
|
||||
h4("button", {
|
||||
h5("button", {
|
||||
type: "button",
|
||||
className: `dsn-button${supported && permission !== "granted" ? " is-primary" : ""}`,
|
||||
disabled: !supported,
|
||||
@@ -1118,51 +1218,51 @@ function ConfigSection(props) {
|
||||
)
|
||||
)
|
||||
),
|
||||
h4(
|
||||
h5(
|
||||
"section",
|
||||
{ className: "dsn-group" },
|
||||
h4("h3", { className: "dsn-group-title" }, tr("config.section.status")),
|
||||
h4(
|
||||
h5("h3", { className: "dsn-group-title" }, tr("config.section.status")),
|
||||
h5(
|
||||
"dl",
|
||||
{ className: "dsn-facts" },
|
||||
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.window")),
|
||||
h4(
|
||||
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.window")),
|
||||
h5(
|
||||
"dd",
|
||||
{ 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")),
|
||||
h4(
|
||||
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.watch")),
|
||||
h5(
|
||||
"dd",
|
||||
{ className: "dsn-fact" },
|
||||
h4("p", {
|
||||
h5("p", {
|
||||
className: `dsn-fact-value${healthFailed ? " is-error" : ""}`,
|
||||
role: healthFailed ? "alert" : "status"
|
||||
}, healthText)
|
||||
),
|
||||
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.last")),
|
||||
h4(
|
||||
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.last")),
|
||||
h5(
|
||||
"dd",
|
||||
{ className: "dsn-fact" },
|
||||
delivery === null ? h4("p", { className: "dsn-fact-value" }, tr("config.diag.none")) : [
|
||||
h4("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 === 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)
|
||||
]
|
||||
),
|
||||
h4("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")),
|
||||
h4(
|
||||
h5("dt", { className: "dsn-fact-key" }, tr("config.diag.replay")),
|
||||
h5(
|
||||
"dd",
|
||||
{ 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",
|
||||
{ className: "dsn-list" },
|
||||
h4(
|
||||
h5(
|
||||
"div",
|
||||
{ className: "dsn-row is-action" },
|
||||
h4("button", {
|
||||
h5("button", {
|
||||
type: "button",
|
||||
className: "dsn-button",
|
||||
onClick: props.testAny
|
||||
@@ -1175,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: "" });
|
||||
@@ -1210,6 +1310,7 @@ function apply(ctx) {
|
||||
const store = createStore({
|
||||
permission: notificationPermission(),
|
||||
kinds: readStoredKinds(),
|
||||
dismissAfterMs: readStoredDismissMs(),
|
||||
toasts: [],
|
||||
promptError: "",
|
||||
health: { state: "starting", message: "" },
|
||||
@@ -1312,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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dsh-plugin/session-notify",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "会话完成、需要授权、需要回答时提醒你:窗口不在前台用系统通知,窗口在前台用应用内轻弹窗。",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ export 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 }) }
|
||||
// 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,
|
||||
|
||||
+62
-18
@@ -1,43 +1,87 @@
|
||||
/**
|
||||
* This package's browser-local preferences.
|
||||
* This package's browser-local preferences: the three trigger switches and the
|
||||
* in-app popup lifetime.
|
||||
*
|
||||
* They are browser preferences rather than cordis configuration, so they live in
|
||||
* this origin's own storage instead of the profile's patch file: the plugin
|
||||
* writes nothing into DSH's configuration. Every read is defensive, because
|
||||
* storage can be unavailable or hold what an older version wrote, and every
|
||||
* write merges into the stored object so that setting one preference never
|
||||
* clears another.
|
||||
*
|
||||
* @module @dsh-plugin/session-notify/client/core/storage
|
||||
*/
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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,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,15 +37,22 @@ 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',
|
||||
|
||||
@@ -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,15 +37,22 @@ 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': '测试系统通知',
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
@@ -84,6 +85,17 @@ export function ConfigSection(props) {
|
||||
}))),
|
||||
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' },
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
@@ -58,6 +58,11 @@ export const CSS = `
|
||||
.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-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)}
|
||||
|
||||
@@ -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.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.
|
||||
|
||||
+36
-1
@@ -122,6 +122,11 @@ test('the plugin page reads as three groups 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',
|
||||
'This environment cannot show system notifications',
|
||||
platformHint,
|
||||
@@ -149,6 +154,8 @@ test('the plugin page reads as three groups with copy, not raw keys', () => {
|
||||
'dsn-row',
|
||||
'dsn-row-title',
|
||||
'dsn-state',
|
||||
'dsn-chips',
|
||||
'dsn-chip',
|
||||
'dsn-facts',
|
||||
'dsn-fact-key',
|
||||
'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}`)
|
||||
}
|
||||
// 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-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 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()
|
||||
|
||||
@@ -15,16 +15,17 @@ 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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -71,8 +71,8 @@ test('every class the components render has a rule, so nothing falls back to def
|
||||
'dsn-stack', 'dsn-toast', 'dsn-toast-icon', 'dsn-toast-icon is-completion', 'dsn-toast-icon is-question',
|
||||
'dsn-toast-text', 'dsn-toast-title', 'dsn-toast-desc', 'dsn-toast-actions', 'dsn-toast-action', 'dsn-toast-close',
|
||||
'dsn-page', 'dsn-lede', 'dsn-group', 'dsn-group-title', 'dsn-group-note', 'dsn-list', 'dsn-row', 'dsn-row-text',
|
||||
'dsn-row-title', 'dsn-row-sub', 'dsn-state', 'dsn-dot', 'dsn-facts', 'dsn-fact-key', 'dsn-fact', 'dsn-fact-value',
|
||||
'dsn-fact-title', 'dsn-switch', 'dsn-thumb', 'dsn-button',
|
||||
'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('.')}`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// The popup stack's lifetime: the user's choice decides it, and "never" really
|
||||
// means the card waits for the user rather than for a timer.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { createStore } from '../../src/client/core/store.js'
|
||||
import { createToastStack } from '../../src/client/core/toasts.js'
|
||||
import { createCopy } from '../../src/client/core/copy.js'
|
||||
import { createTranslator } from '../../src/client/i18n/index.js'
|
||||
import { TOAST_DURATION_MS } from '../../src/client/constants.js'
|
||||
|
||||
const tr = createTranslator({ getSnapshot: () => ({ active: 'zh' }) })
|
||||
const candidate = { kind: 'completion', sessionId: 's1', title: '标题', detail: '', pendingKind: '' }
|
||||
|
||||
/** A stack over a store whose only interesting field is the popup lifetime. */
|
||||
function stack(dismissAfterMs) {
|
||||
const store = createStore({ dismissAfterMs, toasts: [] })
|
||||
return { store, toasts: createToastStack({ store, copy: createCopy(tr) }) }
|
||||
}
|
||||
|
||||
const ids = (store) => store.getSnapshot().toasts.map((toast) => toast.id)
|
||||
|
||||
test('a card closes itself after the configured lifetime', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(6000)
|
||||
toasts.showToast(candidate)
|
||||
|
||||
assert.equal(ids(store).length, 1)
|
||||
context.mock.timers.tick(5999)
|
||||
assert.equal(ids(store).length, 1, 'the card must outlive its countdown')
|
||||
context.mock.timers.tick(1)
|
||||
assert.deepEqual(ids(store), [])
|
||||
})
|
||||
|
||||
test('a lifetime of zero leaves the card until it is dismissed', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(0)
|
||||
toasts.showToast(candidate)
|
||||
|
||||
context.mock.timers.tick(60 * 60 * 1000)
|
||||
assert.equal(ids(store).length, 1, 'nothing may close a "never" card')
|
||||
|
||||
toasts.closeToast(ids(store)[0])
|
||||
assert.deepEqual(ids(store), [])
|
||||
})
|
||||
|
||||
test('hovering holds the card, and the lifetime chosen while held applies afterwards', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(3000)
|
||||
toasts.showToast(candidate)
|
||||
const id = ids(store)[0]
|
||||
|
||||
toasts.holdToast(id)
|
||||
context.mock.timers.tick(30 * 1000)
|
||||
assert.equal(ids(store).length, 1, 'a held card has no timer left')
|
||||
|
||||
// The settings page switches to "never" while the pointer is still on the card.
|
||||
store.set({ ...store.getSnapshot(), dismissAfterMs: 0 })
|
||||
toasts.armToast(id)
|
||||
context.mock.timers.tick(60 * 60 * 1000)
|
||||
assert.equal(ids(store).length, 1)
|
||||
})
|
||||
|
||||
test('a lifetime change applies to the cards already on screen, but never to a held one', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(30000)
|
||||
toasts.showToast(candidate)
|
||||
toasts.showToast({ ...candidate, sessionId: 's2' })
|
||||
const [newest, oldest] = ids(store)
|
||||
toasts.holdToast(oldest)
|
||||
|
||||
// The settings page shortens the lifetime while both cards are visible.
|
||||
store.set({ ...store.getSnapshot(), dismissAfterMs: 3000 })
|
||||
toasts.rearmAll()
|
||||
context.mock.timers.tick(3000)
|
||||
assert.deepEqual(ids(store), [oldest], 'the visible card followed the new choice, the held one did not')
|
||||
|
||||
toasts.armToast(oldest)
|
||||
context.mock.timers.tick(3000)
|
||||
assert.deepEqual(ids(store), [], 'once the pointer leaves, the held card counts down again')
|
||||
void newest
|
||||
})
|
||||
|
||||
test('a lifetime this version cannot use falls back to the shipped default', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(undefined)
|
||||
toasts.showToast(candidate)
|
||||
|
||||
context.mock.timers.tick(TOAST_DURATION_MS)
|
||||
assert.deepEqual(ids(store), [])
|
||||
})
|
||||
|
||||
test('the stack never grows past its bound, however long the cards live', (context) => {
|
||||
context.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const { store, toasts } = stack(0)
|
||||
for (let index = 0; index < 5; index += 1) toasts.showToast({ ...candidate, sessionId: `s${index}` })
|
||||
|
||||
assert.equal(ids(store).length, 3)
|
||||
assert.deepEqual(ids(store), ['dsn-5', 'dsn-4', 'dsn-3'], 'newest first')
|
||||
})
|
||||
Reference in New Issue
Block a user