✨ feat(client): 弹窗可设自动关闭时间,完成提醒描述只显示标题
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user