From faa3930ae8fd560477f2fc933412ba66f0d9760c Mon Sep 17 00:00:00 2001 From: Pan <503103216@qq.com> Date: Mon, 5 Oct 2026 00:29:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(client):=20=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=8F=AF=E8=AE=BE=E8=87=AA=E5=8A=A8=E5=85=B3=E9=97=AD=E6=97=B6?= =?UTF-8?q?=E9=97=B4=EF=BC=8C=E5=AE=8C=E6=88=90=E6=8F=90=E9=86=92=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E5=8F=AA=E6=98=BE=E7=A4=BA=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client.js | 240 +++++++++++++++++++++++---------- src/client/constants.js | 9 +- src/client/core/actions.js | 19 ++- src/client/core/copy.js | 5 +- src/client/core/storage.js | 80 ++++++++--- src/client/core/toasts.js | 43 +++++- src/client/i18n/en.js | 11 +- src/client/i18n/zh.js | 11 +- src/client/plugin.js | 4 +- src/client/ui/ConfigSection.js | 14 +- src/client/ui/DismissRow.js | 30 +++++ src/client/ui/styles.js | 5 + 12 files changed, 369 insertions(+), 102 deletions(-) create mode 100644 src/client/ui/DismissRow.js diff --git a/client.js b/client.js index c0eab69..2fcf058 100644 --- a/client.js +++ b/client.js @@ -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({ diff --git a/src/client/constants.js b/src/client/constants.js index 653f807..cbc0e47 100644 --- a/src/client/constants.js +++ b/src/client/constants.js @@ -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 diff --git a/src/client/core/actions.js b/src/client/core/actions.js index 4c1985a..2a1ca5c 100644 --- a/src/client/core/actions.js +++ b/src/client/core/actions.js @@ -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 } } diff --git a/src/client/core/copy.js b/src/client/core/copy.js index 557df2d..d33905c 100644 --- a/src/client/core/copy.js +++ b/src/client/core/copy.js @@ -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, diff --git a/src/client/core/storage.js b/src/client/core/storage.js index 2f4e6f2..c920c55 100644 --- a/src/client/core/storage.js +++ b/src/client/core/storage.js @@ -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 }) } diff --git a/src/client/core/toasts.js b/src/client/core/toasts.js index 97e3230..3e741d7 100644 --- a/src/client/core/toasts.js +++ b/src/client/core/toasts.js @@ -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 } } diff --git a/src/client/i18n/en.js b/src/client/i18n/en.js index 3506866..bdf743a 100644 --- a/src/client/i18n/en.js +++ b/src/client/i18n/en.js @@ -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', diff --git a/src/client/i18n/zh.js b/src/client/i18n/zh.js index 3af17b3..313c0d7 100644 --- a/src/client/i18n/zh.js +++ b/src/client/i18n/zh.js @@ -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': '测试系统通知', diff --git a/src/client/plugin.js b/src/client/plugin.js index f7b4d6d..f49b2d9 100644 --- a/src/client/plugin.js +++ b/src/client/plugin.js @@ -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)) diff --git a/src/client/ui/ConfigSection.js b/src/client/ui/ConfigSection.js index 7bcd3a7..89b14e2 100644 --- a/src/client/ui/ConfigSection.js +++ b/src/client/ui/ConfigSection.js @@ -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' }, diff --git a/src/client/ui/DismissRow.js b/src/client/ui/DismissRow.js new file mode 100644 index 0000000..cf00540 --- /dev/null +++ b/src/client/ui/DismissRow.js @@ -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)))) +} diff --git a/src/client/ui/styles.js b/src/client/ui/styles.js index 5e5d872..c041398 100644 --- a/src/client/ui/styles.js +++ b/src/client/ui/styles.js @@ -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)}