@goodandready/dsh-plugin-notify 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,714 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@goodandready/dsh-plugin-notify',
3
+ factory: (require) => {
4
+ var module = { exports: {} }
5
+ const React = require('react')
6
+ const NS = '@goodandready/dsh-plugin-notify'
7
+ const CHANNELS = ['feishu', 'wecom', 'dingtalk', 'slack', 'discord', 'custom']
8
+
9
+ const en = {
10
+ title: 'Notify',
11
+ subtitle: 'IM webhook, sound, toast, and desktop notifications',
12
+ intro: 'Store each webhook URL under Settings → Credentials, then type only the credential name below. Do not paste URLs into this form.',
13
+ 'field.feishu': 'Feishu credential',
14
+ 'field.wecom': 'WeCom credential',
15
+ 'field.dingtalk': 'DingTalk credential',
16
+ 'field.slack': 'Slack credential',
17
+ 'field.discord': 'Discord credential',
18
+ 'field.custom': 'Custom webhook credential',
19
+ 'field.events': 'Events (comma-separated)',
20
+ 'field.local': 'Local macOS notification (host)',
21
+ 'field.enableSound': 'Audio chimes (Web Audio)',
22
+ 'field.enableToasts': 'In-app on-screen toasts',
23
+ 'field.enableDesktop': 'Desktop OS notifications (Win/Mac/Linux)',
24
+ 'field.notifyBackgroundOnly': 'Notify only for background/other sessions',
25
+ 'field.timeoutMs': 'Webhook timeout (ms)',
26
+ 'field.dndStart': 'DND start (HH:MM)',
27
+ 'field.dndEnd': 'DND end (HH:MM)',
28
+ 'field.includeSession': 'Include session id',
29
+ 'field.includeDuration': 'Include duration',
30
+ 'field.excludePrefixes': 'Exclude session prefixes (comma-separated)',
31
+ 'hint.cred': 'Credential name only — value must be the full webhook URL.',
32
+ 'hint.events': 'task_done, error, approval_requested',
33
+ 'btn.testSound': 'Test sound',
34
+ 'btn.testToast': 'Test toast',
35
+ 'btn.reqPermission': 'Allow desktop notifications',
36
+ 'perm.granted': 'Desktop notifications enabled',
37
+ 'perm.denied': 'Notifications denied by browser settings',
38
+ 'perm.default': 'Permission not granted yet',
39
+ 'toast.taskDone': 'Task completed',
40
+ 'toast.error': 'Task failed',
41
+ 'toast.approval': 'Approval required',
42
+ 'toast.switchToSession': 'Switch to session',
43
+ 'settings.loading': 'Loading settings…',
44
+ 'settings.unavailable': 'Settings scope unavailable for this plugin.',
45
+ 'settings.save': 'Save',
46
+ 'settings.saving': 'Saving…',
47
+ 'settings.saved': 'Saved',
48
+ 'settings.saveFailed': 'Save failed: ',
49
+ }
50
+ const zh = {
51
+ title: '通知',
52
+ subtitle: 'IM Webhook、音频、浮动弹窗与桌面系统通知',
53
+ intro: '请先在设置 → 凭据中保存 Webhook URL,然后只在下方填写凭据名称。不要直接粘贴 URL。',
54
+ 'field.feishu': '飞书凭据',
55
+ 'field.wecom': '企业微信凭据',
56
+ 'field.dingtalk': '钉钉凭据',
57
+ 'field.slack': 'Slack 凭据',
58
+ 'field.discord': 'Discord 凭据',
59
+ 'field.custom': '自定义 Webhook 凭据',
60
+ 'field.events': '事件(逗号分隔)',
61
+ 'field.local': '本地 macOS 通知(主机)',
62
+ 'field.enableSound': '音频提示音(Web Audio)',
63
+ 'field.enableToasts': '界面内浮动弹窗(Toasts)',
64
+ 'field.enableDesktop': '桌面系统通知(Win/Mac/Linux)',
65
+ 'field.notifyBackgroundOnly': '仅在后台或其他会话时通知',
66
+ 'field.timeoutMs': 'Webhook 超时(毫秒)',
67
+ 'field.dndStart': '免打扰开始时间(HH:MM)',
68
+ 'field.dndEnd': '免打扰结束时间(HH:MM)',
69
+ 'field.includeSession': '包含会话 ID',
70
+ 'field.includeDuration': '包含持续时间',
71
+ 'field.excludePrefixes': '排除会话前缀(逗号分隔)',
72
+ 'hint.cred': '仅填写凭据名称;其值必须是完整的 Webhook URL。',
73
+ 'hint.events': 'task_done, error, approval_requested',
74
+ 'btn.testSound': '测试声音',
75
+ 'btn.testToast': '测试弹窗',
76
+ 'btn.reqPermission': '允许桌面通知',
77
+ 'perm.granted': '桌面通知已启用',
78
+ 'perm.denied': '已被浏览器设置禁止通知',
79
+ 'perm.default': '尚未授权通知权限',
80
+ 'toast.taskDone': '任务已完成',
81
+ 'toast.error': '任务失败',
82
+ 'toast.approval': '需要用户审批',
83
+ 'toast.switchToSession': '切换至该会话',
84
+ 'settings.loading': '正在加载设置…',
85
+ 'settings.unavailable': '此插件的设置范围不可用。',
86
+ 'settings.save': '保存',
87
+ 'settings.saving': '正在保存…',
88
+ 'settings.saved': '已保存',
89
+ 'settings.saveFailed': '保存失败:',
90
+ }
91
+
92
+ let ChevronIcon = null
93
+ try {
94
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
95
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
96
+ } catch (_) {
97
+ ChevronIcon = null
98
+ }
99
+
100
+ function FallbackChevron(props) {
101
+ return React.createElement('svg', {
102
+ className: 'pn-chev' + (props.open ? ' pn-chev-open' : ''),
103
+ style: { marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none' },
104
+ width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true',
105
+ }, React.createElement('path', {
106
+ d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', strokeWidth: 1.5,
107
+ strokeLinecap: 'round', strokeLinejoin: 'round',
108
+ }))
109
+ }
110
+ const Chevron = ChevronIcon || FallbackChevron
111
+
112
+ const cardCss = [
113
+ '.pn-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
114
+ '.pn-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
115
+ '.pn-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
116
+ '.pn-sub{color:var(--dsw-alias-label-secondary);font-size:13px}',
117
+ '.pn-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
118
+ '.pn-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
119
+ '.pn-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}',
120
+ '.pn-hint{color:var(--dsw-alias-label-secondary);font-size:12px}',
121
+ '.pn-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}',
122
+ '.pn-btn-action{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-4);color:var(--dsw-alias-label-primary)}',
123
+ '.pn-btn-action:hover{background:var(--dsw-alias-bg-layer-2)}',
124
+ '.pn-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}',
125
+ '.pn-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
126
+ '.pn-msg-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}',
127
+ '.pn-msg-err{color:var(--dsw-alias-state-danger-primary);font-size:12px}',
128
+ '.pn-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
129
+ '.pn-chev-open{transform:rotate(180deg)}',
130
+ '.pn-toast-container{position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column-reverse;gap:10px;pointer-events:none;max-width:380px;width:calc(100vw - 48px)}',
131
+ '.pn-toast{pointer-events:auto;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.32);padding:12px 14px;display:flex;flex-direction:column;gap:6px;animation:pnToastIn .24s cubic-bezier(0.16,1,0.3,1);transition:opacity .2s,transform .2s}',
132
+ '.pn-toast.pn-toast-out{opacity:0;transform:translateY(8px)}',
133
+ '.pn-toast-head{display:flex;align-items:center;gap:8px}',
134
+ '.pn-toast-badge{font-size:14px;line-height:1;flex:none}',
135
+ '.pn-toast-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
136
+ '.pn-toast-close{background:0 0;border:0;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 6px;font-size:14px;border-radius:4px;line-height:1}',
137
+ '.pn-toast-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4)}',
138
+ '.pn-toast-body{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}',
139
+ '.pn-toast-foot{display:flex;align-items:center;justify-content:space-between;margin-top:2px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l2)}',
140
+ '.pn-toast-sess{font-size:11px;color:var(--dsw-alias-label-tertiary);font-family:monospace;overflow:hidden;text-overflow:ellipsis;max-width:180px;white-space:nowrap}',
141
+ '.pn-toast-btn{background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:3px 8px;font-size:12px;cursor:pointer;font-weight:500}',
142
+ '.pn-toast-btn:hover{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
143
+ '@keyframes pnToastIn{from{opacity:0;transform:translateY(12px) scale(0.96)}to{opacity:1;transform:translateY(0) scale(1)}}',
144
+ ].join('')
145
+
146
+ function ensureStyles() {
147
+ if (typeof document === 'undefined') return
148
+ if (document.getElementById('dsh-plugin-notify-card-styles')) return
149
+ const el = document.createElement('style')
150
+ el.id = 'dsh-plugin-notify-card-styles'
151
+ el.dataset.dshPlugin = 'dsh-plugin-notify'
152
+ el.textContent = cardCss
153
+ document.head.appendChild(el)
154
+ }
155
+
156
+ function makeT(dict, fallback) {
157
+ return function t(key) {
158
+ return (dict && dict[key]) || (fallback && fallback[key]) || key
159
+ }
160
+ }
161
+
162
+ function useActiveLocale(ctx) {
163
+ return React.useSyncExternalStore(
164
+ React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
165
+ React.useCallback(() => (ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : 'en'), [ctx]),
166
+ React.useCallback(() => 'en', []),
167
+ )
168
+ }
169
+
170
+ function splitList(s) {
171
+ return String(s || '').split(',').map((x) => x.trim()).filter(Boolean)
172
+ }
173
+
174
+ let sharedAudioCtx = null
175
+ function playChime(kind) {
176
+ if (typeof window === 'undefined') return
177
+ try {
178
+ const AudioCtx = window.AudioContext || window.webkitAudioContext
179
+ if (!AudioCtx) return
180
+ if (!sharedAudioCtx || sharedAudioCtx.state === 'closed') {
181
+ sharedAudioCtx = new AudioCtx()
182
+ }
183
+ if (sharedAudioCtx.state === 'suspended') {
184
+ sharedAudioCtx.resume().catch(() => {})
185
+ }
186
+ const actx = sharedAudioCtx
187
+ const now = actx.currentTime
188
+ let notes = [523.25, 659.25, 783.99, 1046.50]
189
+ let step = 0.10
190
+ let duration = 0.35
191
+
192
+ if (kind === 'error') {
193
+ notes = [392.00, 311.13, 261.63]
194
+ step = 0.12
195
+ duration = 0.40
196
+ } else if (kind === 'approval_requested') {
197
+ notes = [880.00, 1318.51]
198
+ step = 0.15
199
+ duration = 0.45
200
+ }
201
+
202
+ notes.forEach((freq, idx) => {
203
+ const osc = actx.createOscillator()
204
+ const gain = actx.createGain()
205
+ osc.type = 'sine'
206
+ osc.frequency.setValueAtTime(freq, now + idx * step)
207
+ gain.gain.setValueAtTime(0.001, now + idx * step)
208
+ gain.gain.exponentialRampToValueAtTime(0.12, now + idx * step + 0.02)
209
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + idx * step + duration)
210
+ osc.connect(gain)
211
+ gain.connect(actx.destination)
212
+ osc.start(now + idx * step)
213
+ osc.stop(now + idx * step + duration + 0.05)
214
+ })
215
+ } catch (_err) {
216
+ // best effort: audio playback may be blocked by browser policy
217
+ }
218
+ }
219
+
220
+ function getActiveSessionId() {
221
+ if (typeof window === 'undefined') return ''
222
+ try {
223
+ const hash = window.location.hash || ''
224
+ const m = hash.match(/session[/-]([a-zA-Z0-9_-]+)/)
225
+ if (m) return m[1]
226
+ const params = new URLSearchParams(window.location.search)
227
+ return params.get('session') || params.get('sessionId') || ''
228
+ } catch (_) {
229
+ return ''
230
+ }
231
+ }
232
+
233
+ function navigateToSession(ctx, sessionId) {
234
+ if (!sessionId) return
235
+ try {
236
+ if (ctx && ctx.sessions && typeof ctx.sessions.activate === 'function') {
237
+ ctx.sessions.activate(sessionId)
238
+ return
239
+ }
240
+ } catch (_err) {
241
+ // best effort: session navigation fallback
242
+ }
243
+ if (typeof window !== 'undefined' && window.location) {
244
+ window.location.hash = `session-${sessionId}`
245
+ }
246
+ }
247
+
248
+ function getToastContainer() {
249
+ if (typeof document === 'undefined') return null
250
+ let container = document.getElementById('dsh-notify-toast-container')
251
+ if (!container) {
252
+ container = document.createElement('div')
253
+ container.id = 'dsh-notify-toast-container'
254
+ container.className = 'pn-toast-container'
255
+ container.dataset.dshPlugin = 'dsh-plugin-notify'
256
+ document.body.appendChild(container)
257
+ }
258
+ return container
259
+ }
260
+
261
+ function showInAppToast(event, t, ctx) {
262
+ if (typeof document === 'undefined') return
263
+ ensureStyles()
264
+ const container = getToastContainer()
265
+ if (!container) return
266
+
267
+ const toast = document.createElement('div')
268
+ toast.className = 'pn-toast'
269
+ toast.dataset.dshPlugin = 'dsh-plugin-notify'
270
+ toast.setAttribute('role', 'alert')
271
+
272
+ const badge = event.kind === 'task_done' ? '✅' : event.kind === 'error' ? '⚠️' : '⏸️'
273
+ const kindLabel = event.kind === 'task_done'
274
+ ? (t('toast.taskDone') || 'Task completed')
275
+ : event.kind === 'error'
276
+ ? (t('toast.error') || 'Task failed')
277
+ : (t('toast.approval') || 'Approval required')
278
+
279
+ const titleText = `${badge} ${kindLabel}: ${event.title || event.sessionId}`
280
+
281
+ const head = document.createElement('div')
282
+ head.className = 'pn-toast-head'
283
+
284
+ const titleSpan = document.createElement('span')
285
+ titleSpan.className = 'pn-toast-title'
286
+ titleSpan.textContent = titleText
287
+
288
+ const closeBtn = document.createElement('button')
289
+ closeBtn.className = 'pn-toast-close'
290
+ closeBtn.setAttribute('type', 'button')
291
+ closeBtn.setAttribute('aria-label', 'Close')
292
+ closeBtn.textContent = '✕'
293
+
294
+ head.appendChild(titleSpan)
295
+ head.appendChild(closeBtn)
296
+ toast.appendChild(head)
297
+
298
+ if (event.summary) {
299
+ const body = document.createElement('div')
300
+ body.className = 'pn-toast-body'
301
+ body.textContent = event.summary
302
+ toast.appendChild(body)
303
+ }
304
+
305
+ const foot = document.createElement('div')
306
+ foot.className = 'pn-toast-foot'
307
+
308
+ const sessSpan = document.createElement('span')
309
+ sessSpan.className = 'pn-toast-sess'
310
+ sessSpan.textContent = `ID: ${String(event.sessionId || '').slice(0, 16)}`
311
+ foot.appendChild(sessSpan)
312
+
313
+ if (event.sessionId) {
314
+ const switchBtn = document.createElement('button')
315
+ switchBtn.className = 'pn-toast-btn'
316
+ switchBtn.setAttribute('type', 'button')
317
+ switchBtn.textContent = t('toast.switchToSession') || 'Switch to session'
318
+ switchBtn.onclick = (e) => {
319
+ e.stopPropagation()
320
+ dismiss()
321
+ navigateToSession(ctx, event.sessionId)
322
+ }
323
+ foot.appendChild(switchBtn)
324
+ }
325
+ toast.appendChild(foot)
326
+
327
+ let dismissed = false
328
+ const dismiss = () => {
329
+ if (dismissed) return
330
+ dismissed = true
331
+ toast.classList.add('pn-toast-out')
332
+ setTimeout(() => {
333
+ if (toast.parentNode) toast.parentNode.removeChild(toast)
334
+ }, 220)
335
+ }
336
+
337
+ closeBtn.onclick = (e) => {
338
+ e.stopPropagation()
339
+ dismiss()
340
+ }
341
+
342
+ toast.onclick = () => {
343
+ if (event.sessionId) {
344
+ dismiss()
345
+ navigateToSession(ctx, event.sessionId)
346
+ }
347
+ }
348
+
349
+ container.appendChild(toast)
350
+ setTimeout(dismiss, 7000)
351
+ }
352
+
353
+ function showDesktopNotification(event, t, ctx) {
354
+ if (typeof window === 'undefined' || !('Notification' in window)) return
355
+ if (Notification.permission !== 'granted') return
356
+
357
+ try {
358
+ const kindLabel = event.kind === 'task_done'
359
+ ? (t('toast.taskDone') || 'Task completed')
360
+ : event.kind === 'error'
361
+ ? (t('toast.error') || 'Task failed')
362
+ : (t('toast.approval') || 'Approval required')
363
+
364
+ const badge = event.kind === 'task_done' ? '✅' : event.kind === 'error' ? '⚠️' : '⏸️'
365
+ const notifTitle = `${badge} ${kindLabel}: ${event.title || event.sessionId}`
366
+
367
+ const bodyParts = []
368
+ if (event.summary) bodyParts.push(event.summary)
369
+ if (event.durationMs) bodyParts.push(`Duration: ${Math.round(event.durationMs / 1000)}s`)
370
+ if (event.sessionId) bodyParts.push(`Session: ${event.sessionId}`)
371
+
372
+ const notif = new Notification(notifTitle, {
373
+ body: bodyParts.join('\n'),
374
+ tag: `dsh-notify-${event.sessionId || 'any'}`,
375
+ })
376
+
377
+ notif.onclick = () => {
378
+ try {
379
+ if (window.focus) window.focus()
380
+ } catch (_err) {
381
+ // best effort: window focus may be denied
382
+ }
383
+ if (event.sessionId) navigateToSession(ctx, event.sessionId)
384
+ notif.close()
385
+ }
386
+ } catch (_err) {
387
+ // best effort: notification display error
388
+ }
389
+ }
390
+
391
+ let activeEventSource = null
392
+
393
+ function startSseListener(ctx, t, getSettings) {
394
+ if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
395
+ if (activeEventSource) return () => {}
396
+
397
+ let stopped = false
398
+ let es = null
399
+
400
+ function connect() {
401
+ if (stopped) return
402
+ try {
403
+ es = new EventSource('/dsh-plugin-notify/events')
404
+ activeEventSource = es
405
+
406
+ es.onmessage = (e) => {
407
+ try {
408
+ if (!e.data || e.data.startsWith(':')) return
409
+ const payload = JSON.parse(e.data)
410
+ handleIncomingEvent(payload, ctx, t, getSettings)
411
+ } catch (_err) {
412
+ // ignore malformed SSE payload
413
+ }
414
+ }
415
+
416
+ es.onerror = () => {
417
+ if (es) {
418
+ es.close()
419
+ es = null
420
+ activeEventSource = null
421
+ }
422
+ if (!stopped) {
423
+ setTimeout(connect, 5000)
424
+ }
425
+ }
426
+ } catch (_err) {
427
+ // best effort: reconnect if EventSource constructor fails
428
+ }
429
+ }
430
+
431
+ connect()
432
+
433
+ return () => {
434
+ stopped = true
435
+ if (es) {
436
+ es.close()
437
+ es = null
438
+ activeEventSource = null
439
+ }
440
+ }
441
+ }
442
+
443
+ function handleIncomingEvent(event, ctx, t, getSettings) {
444
+ const cfg = (getSettings && getSettings()) || {}
445
+ const activeSid = getActiveSessionId()
446
+
447
+ const isBackground = !activeSid || String(event.sessionId) !== String(activeSid)
448
+ const notifyBgOnly = !!cfg.notifyBackgroundOnly
449
+
450
+ if (notifyBgOnly && !isBackground) {
451
+ return
452
+ }
453
+
454
+ // 1. Audio chime
455
+ if (Boolean(cfg.enableSound)) {
456
+ playChime(event.kind)
457
+ }
458
+
459
+ // 2. In-app toast
460
+ if (Boolean(cfg.enableToasts)) {
461
+ showInAppToast(event, t, ctx)
462
+ }
463
+
464
+ // 3. Desktop OS notification
465
+ if (Boolean(cfg.enableDesktopNotifications)) {
466
+ showDesktopNotification(event, t, ctx)
467
+ }
468
+ }
469
+
470
+ function NotifyCard(props) {
471
+ const ctx = props.ctx
472
+ const locale = useActiveLocale(ctx)
473
+ const t = props.t || makeT(locale === 'zh' ? zh : en, en)
474
+ const [open, setOpen] = React.useState(false)
475
+ const [draft, setDraft] = React.useState(null)
476
+ const [saving, setSaving] = React.useState(false)
477
+ const [err, setErr] = React.useState('')
478
+ const [saved, setSaved] = React.useState(false)
479
+ const [permState, setPermState] = React.useState(() => {
480
+ if (typeof window !== 'undefined' && 'Notification' in window) {
481
+ return Notification.permission
482
+ }
483
+ return 'unsupported'
484
+ })
485
+
486
+ const requestPermission = async () => {
487
+ if (typeof window !== 'undefined' && 'Notification' in window) {
488
+ try {
489
+ const res = await Notification.requestPermission()
490
+ setPermState(res)
491
+ } catch (_err) {
492
+ // best effort: permission request failed
493
+ }
494
+ }
495
+ }
496
+
497
+ const scope = React.useMemo(
498
+ () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
499
+ [ctx],
500
+ )
501
+ const snapshot = React.useSyncExternalStore(
502
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
503
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
504
+ React.useCallback(() => ({ status: 'loading' }), []),
505
+ )
506
+
507
+ React.useEffect(() => { ensureStyles() }, [])
508
+
509
+ const status = (snapshot && snapshot.status) || 'loading'
510
+ const stored = (snapshot && snapshot.value) || {}
511
+
512
+ React.useEffect(() => {
513
+ if (status === 'ready' && draft === null) {
514
+ const wh = stored.webhooks || {}
515
+ setDraft({
516
+ feishu: wh.feishu || '',
517
+ wecom: wh.wecom || '',
518
+ dingtalk: wh.dingtalk || '',
519
+ slack: wh.slack || '',
520
+ discord: wh.discord || '',
521
+ custom: wh.custom || '',
522
+ events: Array.isArray(stored.events) ? stored.events.join(', ') : 'task_done, error, approval_requested',
523
+ local: stored.local !== false,
524
+ enableSound: Boolean(stored.enableSound),
525
+ enableToasts: Boolean(stored.enableToasts),
526
+ enableDesktopNotifications: Boolean(stored.enableDesktopNotifications),
527
+ notifyBackgroundOnly: !!stored.notifyBackgroundOnly,
528
+ timeoutMs: String(stored.timeoutMs != null ? stored.timeoutMs : 5000),
529
+ dndStart: (stored.dnd && stored.dnd.start) || '',
530
+ dndEnd: (stored.dnd && stored.dnd.end) || '',
531
+ includeSession: stored.includeSession !== false,
532
+ includeDuration: stored.includeDuration !== false,
533
+ excludePrefixes: Array.isArray(stored.excludeSessionPrefixes) ? stored.excludeSessionPrefixes.join(', ') : '',
534
+ })
535
+ }
536
+ }, [status, stored, draft])
537
+
538
+ const save = async () => {
539
+ if (!scope || !draft) return
540
+ setSaving(true); setErr(''); setSaved(false)
541
+ const broken = []
542
+ const webhooks = {}
543
+ for (const ch of CHANNELS) webhooks[ch] = String(draft[ch] || '').trim()
544
+ const timeoutNum = Number(String(draft.timeoutMs).trim())
545
+ const payload = {
546
+ webhooks,
547
+ events: splitList(draft.events),
548
+ local: !!draft.local,
549
+ enableSound: !!draft.enableSound,
550
+ enableToasts: !!draft.enableToasts,
551
+ enableDesktopNotifications: !!draft.enableDesktopNotifications,
552
+ notifyBackgroundOnly: !!draft.notifyBackgroundOnly,
553
+ timeoutMs: Number.isFinite(timeoutNum) && timeoutNum > 0 ? timeoutNum : 5000,
554
+ dnd: { start: String(draft.dndStart || '').trim(), end: String(draft.dndEnd || '').trim() },
555
+ includeSession: !!draft.includeSession,
556
+ includeDuration: !!draft.includeDuration,
557
+ excludeSessionPrefixes: splitList(draft.excludePrefixes),
558
+ }
559
+ for (const [k, v] of Object.entries(payload)) {
560
+ try { await scope.set(k, v) }
561
+ catch (e) { broken.push(k + ': ' + (e && e.message || String(e))) }
562
+ }
563
+ setSaving(false)
564
+ if (broken.length) { setErr(t('settings.saveFailed') + broken.join('; ')); return }
565
+ setSaved(true)
566
+ setTimeout(() => setSaved(false), 2500)
567
+ }
568
+
569
+ const field = (key, input) => React.createElement('div', { className: 'pn-field' },
570
+ React.createElement('label', { className: 'pn-label' }, t('field.' + key)),
571
+ input,
572
+ key === 'events' ? React.createElement('span', { className: 'pn-hint' }, t('hint.events'))
573
+ : CHANNELS.includes(key) ? React.createElement('span', { className: 'pn-hint' }, t('hint.cred'))
574
+ : null,
575
+ )
576
+
577
+ return React.createElement('li', { className: 'pn-card' },
578
+ React.createElement('button', { type: 'button', className: 'pn-head', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
579
+ React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
580
+ React.createElement('span', { className: 'pn-title' }, t('title')),
581
+ React.createElement('span', { className: 'pn-sub' }, t('subtitle')),
582
+ ),
583
+ React.createElement(Chevron, { open }),
584
+ ),
585
+ open ? React.createElement('div', { className: 'pn-body' },
586
+ status === 'loading'
587
+ ? React.createElement('p', { className: 'pn-hint', style: { padding: '12px 0' } }, t('settings.loading'))
588
+ : status !== 'ready'
589
+ ? React.createElement('p', { className: 'pn-msg-err', style: { padding: '12px 0' } }, t('settings.unavailable'))
590
+ : React.createElement('div', null,
591
+ React.createElement('p', { className: 'pn-hint', style: { padding: '12px 0 0' } }, t('intro')),
592
+ ...CHANNELS.map((ch) => field(ch, React.createElement('input', {
593
+ className: 'pn-input', value: (draft && draft[ch]) || '',
594
+ onChange: (e) => setDraft({ ...draft, [ch]: e.target.value }),
595
+ autoComplete: 'off',
596
+ }))),
597
+ field('events', React.createElement('input', {
598
+ className: 'pn-input', value: (draft && draft.events) || '',
599
+ onChange: (e) => setDraft({ ...draft, events: e.target.value }),
600
+ })),
601
+ field('timeoutMs', React.createElement('input', {
602
+ className: 'pn-input', type: 'number', value: (draft && draft.timeoutMs) || '',
603
+ onChange: (e) => setDraft({ ...draft, timeoutMs: e.target.value }),
604
+ })),
605
+ field('dndStart', React.createElement('input', {
606
+ className: 'pn-input', value: (draft && draft.dndStart) || '',
607
+ onChange: (e) => setDraft({ ...draft, dndStart: e.target.value }),
608
+ })),
609
+ field('dndEnd', React.createElement('input', {
610
+ className: 'pn-input', value: (draft && draft.dndEnd) || '',
611
+ onChange: (e) => setDraft({ ...draft, dndEnd: e.target.value }),
612
+ })),
613
+ field('excludePrefixes', React.createElement('input', {
614
+ className: 'pn-input', value: (draft && draft.excludePrefixes) || '',
615
+ onChange: (e) => setDraft({ ...draft, excludePrefixes: e.target.value }),
616
+ })),
617
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
618
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
619
+ React.createElement('input', { type: 'checkbox', id: 'pn-sound', checked: !!(draft && draft.enableSound), onChange: (e) => setDraft({ ...draft, enableSound: e.target.checked }) }),
620
+ React.createElement('label', { htmlFor: 'pn-sound', className: 'pn-label' }, t('field.enableSound')),
621
+ ),
622
+ React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: () => playChime('task_done') }, t('btn.testSound')),
623
+ ),
624
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
625
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
626
+ React.createElement('input', { type: 'checkbox', id: 'pn-toasts', checked: !!(draft && draft.enableToasts), onChange: (e) => setDraft({ ...draft, enableToasts: e.target.checked }) }),
627
+ React.createElement('label', { htmlFor: 'pn-toasts', className: 'pn-label' }, t('field.enableToasts')),
628
+ ),
629
+ React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: () => showInAppToast({ kind: 'task_done', title: 'Example Task', summary: 'Turn completed with 2 tools', sessionId: 'sess-demo-123' }, t, ctx) }, t('btn.testToast')),
630
+ ),
631
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
632
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
633
+ React.createElement('input', { type: 'checkbox', id: 'pn-desktop', checked: !!(draft && draft.enableDesktopNotifications), onChange: (e) => setDraft({ ...draft, enableDesktopNotifications: e.target.checked }) }),
634
+ React.createElement('label', { htmlFor: 'pn-desktop', className: 'pn-label' }, t('field.enableDesktop')),
635
+ ),
636
+ permState === 'default'
637
+ ? React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: requestPermission }, t('btn.reqPermission'))
638
+ : React.createElement('span', { className: 'pn-hint' }, permState === 'granted' ? t('perm.granted') : t('perm.denied')),
639
+ ),
640
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
641
+ React.createElement('input', { type: 'checkbox', id: 'pn-bgonly', checked: !!(draft && draft.notifyBackgroundOnly), onChange: (e) => setDraft({ ...draft, notifyBackgroundOnly: e.target.checked }) }),
642
+ React.createElement('label', { htmlFor: 'pn-bgonly', className: 'pn-label' }, t('field.notifyBackgroundOnly')),
643
+ ),
644
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
645
+ React.createElement('input', { type: 'checkbox', id: 'pn-local', checked: !!(draft && draft.local), onChange: (e) => setDraft({ ...draft, local: e.target.checked }) }),
646
+ React.createElement('label', { htmlFor: 'pn-local', className: 'pn-label' }, t('field.local')),
647
+ ),
648
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
649
+ React.createElement('input', { type: 'checkbox', id: 'pn-sess', checked: !!(draft && draft.includeSession), onChange: (e) => setDraft({ ...draft, includeSession: e.target.checked }) }),
650
+ React.createElement('label', { htmlFor: 'pn-sess', className: 'pn-label' }, t('field.includeSession')),
651
+ ),
652
+ React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
653
+ React.createElement('input', { type: 'checkbox', id: 'pn-dur', checked: !!(draft && draft.includeDuration), onChange: (e) => setDraft({ ...draft, includeDuration: e.target.checked }) }),
654
+ React.createElement('label', { htmlFor: 'pn-dur', className: 'pn-label' }, t('field.includeDuration')),
655
+ ),
656
+ err ? React.createElement('div', { className: 'pn-msg-err' }, err) : null,
657
+ saved ? React.createElement('div', { className: 'pn-msg-ok' }, t('settings.saved')) : null,
658
+ React.createElement('div', { className: 'pn-foot' },
659
+ React.createElement('button', { type: 'button', className: 'pn-save', disabled: saving, onClick: save }, saving ? t('settings.saving') : t('settings.save')),
660
+ ),
661
+ ),
662
+ ) : null,
663
+ )
664
+ }
665
+
666
+ function apply(ctx) {
667
+ const t = ctx.locale ? ctx.locale.bind(NS) : ((k) => k)
668
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-plugin-notify: dictionaries')
669
+
670
+ try {
671
+ ctx.slots.inject('settings.plugin.item', () => {
672
+ ctx.slots.register(
673
+ { name: 'settings.plugin.item', key: NS, locale: NS, order: 40, inject: () => ({ ctx }) },
674
+ (props) => React.createElement(NotifyCard, { ...props, ctx }),
675
+ )
676
+ })
677
+ } catch (e) {
678
+ console.warn('[dsh-plugin-notify] settings.plugin.item failed', e)
679
+ }
680
+
681
+ let currentSettings = {}
682
+ if (ctx.settingsScope) {
683
+ try {
684
+ const scope = ctx.settingsScope.bind({ namespace: NS })
685
+ if (scope && typeof scope.subscribe === 'function') {
686
+ scope.subscribe(() => {
687
+ const snap = scope.getSnapshot ? scope.getSnapshot() : null
688
+ if (snap && snap.status === 'ready' && snap.value) {
689
+ currentSettings = snap.value
690
+ }
691
+ })
692
+ }
693
+ const initial = scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : null
694
+ if (initial && initial.status === 'ready' && initial.value) {
695
+ currentSettings = initial.value
696
+ }
697
+ } catch (_err) {
698
+ // best effort: fallback if snapshot fails
699
+ }
700
+ }
701
+
702
+ if (typeof ctx.effect === 'function') {
703
+ ctx.effect(() => {
704
+ return startSseListener(ctx, t, () => currentSettings)
705
+ }, 'dsh-plugin-notify: sse')
706
+ } else {
707
+ startSseListener(ctx, t, () => currentSettings)
708
+ }
709
+ }
710
+
711
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
712
+ return module.exports
713
+ },
714
+ })