dsh-global-todo 0.5.2 → 0.5.3

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/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.5.3] — 2026-10-20
2
+
3
+ Settings a person can reach, and reminders that behave like reminders.
4
+
5
+ - **A settings popover.** Reminders and the external ingest door were both API-only; the header
6
+ now opens a small panel for the reminder switch, the lead time, the ingest switch, the token
7
+ (generate a fresh one in place; leave blank to keep the current) and the inbox confidence
8
+ threshold. The token still never comes back from the host.
9
+ - **Reminders gained a lead time and a switch.** remindersEnabled and reminderLeadMinutes live in
10
+ settings; the host sweep reads them each tick, announces at dueAt minus lead, and clears the due
11
+ list when reminders are off.
12
+ - **Browser notifications are opt-in.** When the panel is open and a new deadline becomes due, it
13
+ fires a system notification if permission was granted; otherwise the due bar offers to ask for
14
+ permission. The notification is a bonus — the bar is the source of truth.
15
+
1
16
  # Changelog
2
17
 
3
18
  All notable changes to this project are documented in this file.
Binary file
Binary file
Binary file
package/lib/client.js CHANGED
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
50
50
  * different mechanisms (restart vs. hot reload), so "which one am I looking
51
51
  * at" needs an answer that the bundle itself carries.
52
52
  */
53
- const UI_REV = 'ui33'
53
+ const UI_REV = 'ui35'
54
54
  /** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
55
55
  let activeLang = 'zh'
56
56
 
@@ -90,6 +90,19 @@ window.__ModuleLoader__.load({
90
90
  'action.import': '导入',
91
91
  'action.exportHint': '把整份清单导出成 JSON 文件(就是存储用的那份文档)',
92
92
  'action.importHint': '从 JSON 文件导入:同 id 覆盖、新 id 追加,不删除现有待办',
93
+ 'settings.title': '设置',
94
+ 'settings.reminders': '到点提醒',
95
+ 'settings.remindersEnable': '开启到点提醒',
96
+ 'settings.lead': '提前(分钟)',
97
+ 'settings.ingest': '外部接入(ingest)',
98
+ 'settings.ingestEnable': '启用外部写入',
99
+ 'settings.token': '访问 token',
100
+ 'settings.tokenSet': '已设置,留空保持不变',
101
+ 'settings.generate': '生成新 token',
102
+ 'settings.confirmBelow': '低于此置信度进收件箱',
103
+ 'settings.save': '保存',
104
+ 'settings.cancel': '取消',
105
+ 'settings.saved': '设置已保存',
93
106
  'notice.exported': '已导出 {n} 条待办',
94
107
  'notice.imported': '已导入 {n} 条(合并,现有共 {total} 条)',
95
108
  'import.badJson': '这个文件不是合法的 JSON',
@@ -138,6 +151,8 @@ window.__ModuleLoader__.load({
138
151
  'notice.externalChange': '清单在别处被改过,已载入最新内容',
139
152
  'reminder.title': '到点提醒({n})',
140
153
  'reminder.dismiss': '知道了',
154
+ 'reminder.enableNotifications': '开启系统通知',
155
+ 'reminder.notifyTitle': '待办到点',
141
156
  'import.preview': '将导入 {total} 条:新增 {create}、更新 {update}',
142
157
  'import.confirm': '导入',
143
158
  'meta.seqHint': '给人看的编号',
@@ -311,6 +326,19 @@ window.__ModuleLoader__.load({
311
326
  'action.import': 'Import',
312
327
  'action.exportHint': 'Download the whole list as JSON (the document the store keeps)',
313
328
  'action.importHint': 'Import a JSON file: same ids are updated, new ids appended, nothing deleted',
329
+ 'settings.title': 'Settings',
330
+ 'settings.reminders': 'Due reminders',
331
+ 'settings.remindersEnable': 'Announce deadlines',
332
+ 'settings.lead': 'Lead time (min)',
333
+ 'settings.ingest': 'External ingest',
334
+ 'settings.ingestEnable': 'Accept external writes',
335
+ 'settings.token': 'Access token',
336
+ 'settings.tokenSet': 'Set — leave blank to keep',
337
+ 'settings.generate': 'New token',
338
+ 'settings.confirmBelow': 'Below this confidence → inbox',
339
+ 'settings.save': 'Save',
340
+ 'settings.cancel': 'Cancel',
341
+ 'settings.saved': 'Settings saved',
314
342
  'notice.exported': 'Exported {n} todos',
315
343
  'notice.imported': 'Imported {n} (merged; {total} in the list now)',
316
344
  'import.badJson': 'That file is not valid JSON',
@@ -359,6 +387,8 @@ window.__ModuleLoader__.load({
359
387
  'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
360
388
  'reminder.title': 'Due now ({n})',
361
389
  'reminder.dismiss': 'Dismiss',
390
+ 'reminder.enableNotifications': 'Enable notifications',
391
+ 'reminder.notifyTitle': 'Todo due',
362
392
  'import.preview': 'Will import {total}: {create} new, {update} updated',
363
393
  'import.confirm': 'Import',
364
394
  'meta.seqHint': 'Human-facing number',
@@ -1014,6 +1044,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1014
1044
  color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; padding: 3px 8px;
1015
1045
  border-radius: 999px; cursor: pointer; max-width: 260px; overflow: hidden; text-overflow: ellipsis;
1016
1046
  white-space: nowrap; }
1047
+ .gt-settings { align-self: flex-end; width: 330px; display: flex; flex-direction: column; gap: 8px;
1048
+ padding: 12px 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
1049
+ background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
1050
+ .gt-settings-head { font-weight: 650; }
1051
+ .gt-check-line { display: flex; align-items: center; gap: 8px; justify-content: space-between;
1052
+ color: var(--dsw-alias-label-primary); }
1053
+ .gt-settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1017
1054
  .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
1018
1055
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1019
1056
  vertical-align: 1px; }
@@ -2042,6 +2079,20 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2042
2079
  // A picked file is previewed first: a merge is not undoable, and "did I pick the right
2043
2080
  // export" is exactly the kind of question a dry run answers before anything changes.
2044
2081
  const [importAsk, setImportAsk] = useState(null)
2082
+ // One settings popover: reminders and the external door both need a place a person can
2083
+ // reach without curl.
2084
+ const [settingsOpen, setSettingsOpen] = useState(false)
2085
+ const [tokenDraft, setTokenDraft] = useState('')
2086
+ const [ingestDraft, setIngestDraft] = useState(false)
2087
+ const [belowDraft, setBelowDraft] = useState('0.8')
2088
+ const [remindersDraft, setRemindersDraft] = useState(true)
2089
+ const [leadDraft, setLeadDraft] = useState('0')
2090
+ const [notificationPerm, setNotificationPerm] = useState(
2091
+ typeof window !== 'undefined' && typeof window.Notification === 'function'
2092
+ ? window.Notification.permission
2093
+ : 'unsupported',
2094
+ )
2095
+ const notifiedRef = useRef(new Set())
2045
2096
  const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
2046
2097
  // A notice may carry an action: after a hand-off the useful next step is
2047
2098
  // to go and look at the conversation the work is actually in.
@@ -2863,6 +2914,47 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2863
2914
  return undefined
2864
2915
  }
2865
2916
 
2917
+ /** Fill the popover from the host's state, so saving cannot send stale values. */
2918
+ const openSettings = () => {
2919
+ const current = state.health?.settings ?? {}
2920
+ setIngestDraft(current.ingest?.enabled === true)
2921
+ setBelowDraft(String(current.ingest?.confirmBelow ?? 0.8))
2922
+ setRemindersDraft(current.remindersEnabled !== false)
2923
+ setLeadDraft(String(current.reminderLeadMinutes ?? 0))
2924
+ setTokenDraft('')
2925
+ setSettingsOpen(true)
2926
+ }
2927
+
2928
+ const generateToken = () => {
2929
+ const bytes = new window.Uint8Array(24)
2930
+ window.crypto.getRandomValues(bytes)
2931
+ setTokenDraft(Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''))
2932
+ setIngestDraft(true)
2933
+ }
2934
+
2935
+ const saveSettings = async () => {
2936
+ try {
2937
+ await call('settings', {
2938
+ method: 'POST',
2939
+ body: {
2940
+ remindersEnabled: remindersDraft,
2941
+ reminderLeadMinutes: Number(leadDraft),
2942
+ ingestEnabled: ingestDraft,
2943
+ ingestConfirmBelow: Number(belowDraft),
2944
+ ...(tokenDraft.trim() === '' ? {} : { ingestToken: tokenDraft.trim() }),
2945
+ },
2946
+ })
2947
+ setSettingsOpen(false)
2948
+ setTokenDraft('')
2949
+ await refreshHealth()
2950
+ setNotice({ text: t('settings.saved'), sessionId: '' })
2951
+ setTimeout(() => setNotice(null), 5000)
2952
+ } catch (failure) {
2953
+ reportFailure('settings', { message: failure?.message })
2954
+ setError(failure)
2955
+ }
2956
+ }
2957
+
2866
2958
  /** Acknowledge reminders so the host stops listing them. */
2867
2959
  const ackReminders = async (ids) => {
2868
2960
  try {
@@ -2901,6 +2993,30 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2901
2993
 
2902
2994
  const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
2903
2995
  const remindersDue = Array.isArray(state.health?.reminders?.due) ? state.health.reminders.due : []
2996
+ const dueKey = remindersDue.map((item) => `${item.id}:${item.dueAt}`).join('|')
2997
+ useEffect(() => {
2998
+ if (typeof window === 'undefined' || typeof window.Notification !== 'function') return
2999
+ for (const item of remindersDue) {
3000
+ const key = `${item.id}:${item.dueAt}`
3001
+ if (notifiedRef.current.has(key)) continue
3002
+ notifiedRef.current.add(key)
3003
+ if (window.Notification.permission !== 'granted') continue
3004
+ try {
3005
+ const note = new window.Notification(t('reminder.notifyTitle'), { body: item.title, tag: item.id })
3006
+ note.onclick = () => {
3007
+ try {
3008
+ window.focus()
3009
+ } catch {
3010
+ /* focus is a nicety */
3011
+ }
3012
+ setExpandedId(item.id)
3013
+ }
3014
+ } catch {
3015
+ /* a notification is a bonus, never a failure */
3016
+ }
3017
+ }
3018
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3019
+ }, [dueKey])
2904
3020
  const groups = useMemo(
2905
3021
  () => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
2906
3022
  [state.todos, groupMode, sessionTitles, lang],
@@ -3011,6 +3127,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3011
3127
  },
3012
3128
  t('action.import'),
3013
3129
  ),
3130
+ h(
3131
+ 'button',
3132
+ {
3133
+ type: 'button',
3134
+ className: 'gt-btn gt-btn-quiet',
3135
+ 'aria-expanded': settingsOpen,
3136
+ onClick: () => (settingsOpen ? setSettingsOpen(false) : openSettings()),
3137
+ },
3138
+ t('settings.title'),
3139
+ ),
3014
3140
  h('input', {
3015
3141
  ref: importRef,
3016
3142
  type: 'file',
@@ -3020,6 +3146,71 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3020
3146
  onChange: (event) => void importJson(event),
3021
3147
  }),
3022
3148
  ),
3149
+ settingsOpen
3150
+ ? h(
3151
+ 'div',
3152
+ { className: 'gt-settings', role: 'dialog', 'aria-label': t('settings.title') },
3153
+ h('div', { className: 'gt-settings-head' }, t('settings.title')),
3154
+ h('div', { className: 'gt-section-title' }, t('settings.reminders')),
3155
+ h(
3156
+ 'label',
3157
+ { className: 'gt-check-line' },
3158
+ h('input', { type: 'checkbox', checked: remindersDraft, onChange: (event) => setRemindersDraft(event.target.checked) }),
3159
+ t('settings.remindersEnable'),
3160
+ ),
3161
+ h(
3162
+ 'label',
3163
+ { className: 'gt-check-line' },
3164
+ t('settings.lead'),
3165
+ h('input', {
3166
+ type: 'number',
3167
+ min: 0,
3168
+ max: 1440,
3169
+ className: 'gt-input gt-input-xs',
3170
+ value: leadDraft,
3171
+ onChange: (event) => setLeadDraft(event.target.value),
3172
+ }),
3173
+ ),
3174
+ h('div', { className: 'gt-section-title' }, t('settings.ingest')),
3175
+ h(
3176
+ 'label',
3177
+ { className: 'gt-check-line' },
3178
+ h('input', { type: 'checkbox', checked: ingestDraft, onChange: (event) => setIngestDraft(event.target.checked) }),
3179
+ t('settings.ingestEnable'),
3180
+ ),
3181
+ h('input', {
3182
+ className: 'gt-input',
3183
+ type: 'password',
3184
+ value: tokenDraft,
3185
+ placeholder:
3186
+ state.health?.settings?.ingest?.tokenSet === true ? t('settings.tokenSet') : t('settings.token'),
3187
+ autoComplete: 'new-password',
3188
+ onChange: (event) => setTokenDraft(event.target.value),
3189
+ }),
3190
+ h(
3191
+ 'div',
3192
+ { className: 'gt-settings-row' },
3193
+ h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: generateToken }, t('settings.generate')),
3194
+ h('button', { type: 'button', className: 'gt-btn gt-btn-brand', onClick: () => void saveSettings() }, t('settings.save')),
3195
+ h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
3196
+ ),
3197
+ h(
3198
+ 'label',
3199
+ { className: 'gt-check-line' },
3200
+ t('settings.confirmBelow'),
3201
+ h('input', {
3202
+ type: 'number',
3203
+ min: 0,
3204
+ max: 1,
3205
+ step: 0.05,
3206
+ className: 'gt-input gt-input-xs',
3207
+ value: belowDraft,
3208
+ onChange: (event) => setBelowDraft(event.target.value),
3209
+ }),
3210
+ ),
3211
+ )
3212
+ : null,
3213
+
3023
3214
  error !== null
3024
3215
  ? h(
3025
3216
  'div',
@@ -3462,6 +3653,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3462
3653
  `${item.seq > 0 ? `#${item.seq} ` : ''}${item.title}`,
3463
3654
  ),
3464
3655
  ),
3656
+ notificationPerm === 'default'
3657
+ ? h(
3658
+ 'button',
3659
+ {
3660
+ type: 'button',
3661
+ className: 'gt-btn gt-btn-quiet',
3662
+ onClick: () => {
3663
+ void window.Notification.requestPermission().then((permission) => setNotificationPerm(permission))
3664
+ },
3665
+ },
3666
+ t('reminder.enableNotifications'),
3667
+ )
3668
+ : null,
3465
3669
  h(
3466
3670
  'button',
3467
3671
  { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void ackReminders(remindersDue.map((item) => item.id)) },
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
36
36
  import { createTools } from './tools.js'
37
37
 
38
38
  /** Must equal `package.json`'s version; `npm run check` enforces that. */
39
- export const VERSION = '0.5.2'
39
+ export const VERSION = '0.5.3'
40
40
 
41
41
  /** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
42
42
  export const name = 'dsh-global-todo'
@@ -418,8 +418,17 @@ export function apply(ctx, config) {
418
418
  ctx.effect(() => {
419
419
  const tick = async () => {
420
420
  try {
421
+ const current = settings.get()
422
+ if (current.remindersEnabled === false) {
423
+ reminderState.due = []
424
+ return
425
+ }
421
426
  const { todos } = await service.list({ status: 'active' })
422
- const due = dueReminders(todos, { now: Date.now(), reminded: reminderState.reminded })
427
+ const due = dueReminders(todos, {
428
+ now: Date.now(),
429
+ reminded: reminderState.reminded,
430
+ leadMs: (current.reminderLeadMinutes ?? 0) * 60_000,
431
+ })
423
432
  if (due.length === 0) return
424
433
  for (const item of due) reminderState.reminded[item.id] = item.dueAt
425
434
  const known = new Set(reminderState.due.map((item) => item.id))
package/lib/reminders.js CHANGED
@@ -17,6 +17,8 @@
17
17
  */
18
18
  export function dueReminders(todos, options = {}) {
19
19
  const now = Number.isFinite(options.now) ? options.now : Date.now()
20
+ // A lead time turns "at the deadline" into "a little before it": the same rule, shifted.
21
+ const lead = Number.isFinite(options.leadMs) && options.leadMs > 0 ? options.leadMs : 0
20
22
  const reminded = options.reminded !== null && typeof options.reminded === 'object' ? options.reminded : {}
21
23
  const due = []
22
24
  for (const todo of Array.isArray(todos) ? todos : []) {
@@ -24,7 +26,7 @@ export function dueReminders(todos, options = {}) {
24
26
  if (todo.status !== 'open' && todo.status !== 'doing') continue
25
27
  if (todo.dueAt === null || todo.dueAt === undefined || typeof todo.dueAt !== 'string') continue
26
28
  const at = Date.parse(todo.dueAt)
27
- if (!Number.isFinite(at) || at > now) continue
29
+ if (!Number.isFinite(at) || at - lead > now) continue
28
30
  if (reminded[todo.id] === todo.dueAt) continue
29
31
  due.push({
30
32
  id: todo.id,
package/lib/routes.js CHANGED
@@ -417,6 +417,8 @@ export function createRoutes(deps) {
417
417
  loadError: settingsInfo.error,
418
418
  // The token itself never leaves the settings file; the panel only needs to know
419
419
  // whether one is set before it can usefully show the ingest section.
420
+ remindersEnabled: deps.settings?.get?.().remindersEnabled !== false,
421
+ reminderLeadMinutes: deps.settings?.get?.().reminderLeadMinutes ?? 0,
420
422
  ingest: {
421
423
  enabled: deps.settings?.get?.().ingestEnabled === true,
422
424
  sources: deps.settings?.get?.().ingestSources ?? [],
package/lib/settings.js CHANGED
@@ -66,6 +66,10 @@ export const SETTINGS_DEFAULTS = Object.freeze({
66
66
  ingestSources: [...INGEST_SOURCES],
67
67
  /** Items below this confidence wait in the inbox instead of joining the list. */
68
68
  ingestConfirmBelow: 0.8,
69
+ /** Announce deadlines in the panel. On by default; a deadline nobody hears about is just data. */
70
+ remindersEnabled: true,
71
+ /** Announce this many minutes before the deadline (0 = at the deadline). */
72
+ reminderLeadMinutes: 0,
69
73
  })
70
74
 
71
75
  /**
@@ -105,6 +109,7 @@ export function normalizeSettings(raw) {
105
109
  sources.push(kind)
106
110
  }
107
111
  const below = Number(source.ingestConfirmBelow)
112
+ const lead = Number(source.reminderLeadMinutes)
108
113
  return {
109
114
  autoCollect: source.autoCollect === true,
110
115
  cooldownMinutes: Number.isInteger(minutes) ? Math.min(Math.max(minutes, 5), 24 * 60) : SETTINGS_DEFAULTS.cooldownMinutes,
@@ -117,6 +122,8 @@ export function normalizeSettings(raw) {
117
122
  ingestToken: typeof source.ingestToken === 'string' ? source.ingestToken.trim().slice(0, 512) : '',
118
123
  ingestSources: sources.length === 0 ? [...INGEST_SOURCES] : sources,
119
124
  ingestConfirmBelow: Number.isFinite(below) ? Math.min(Math.max(below, 0), 1) : SETTINGS_DEFAULTS.ingestConfirmBelow,
125
+ remindersEnabled: source.remindersEnabled !== false,
126
+ reminderLeadMinutes: Number.isFinite(lead) ? Math.min(Math.max(lead, 0), 24 * 60) : SETTINGS_DEFAULTS.reminderLeadMinutes,
120
127
  }
121
128
  }
122
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-global-todo",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "icon": "./docs/icon.svg",
5
5
  "description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
6
6
  "type": "module",