✨ feat(client): 弹窗可设自动关闭时间,完成提醒描述只显示标题

This commit is contained in:
pyh
2026-10-05 00:29:36 +08:00
parent 9218521dcb
commit faa3930ae8
12 changed files with 369 additions and 102 deletions
+170 -68
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
@@ -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({
+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,
+61 -17
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))))
}
+5
View File
@@ -58,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)}