dsh-global-todo 0.5.2 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## [0.6.0] — 2026-10-20
2
+
3
+ A design pass over the whole panel: one list, fewer competing controls, one type scale.
4
+
5
+ - **The layout is a column, not a wrapping row.** `.gt-root`/`.gt-inner` gained `min-width: 0` so the
6
+ column shrinks with its slot instead of overflowing at 1280 and 1100; the header wraps its actions
7
+ and the toolbar is two semantic rows (filter/search above, context above the list). The working
8
+ directory, handoff target and browse controls move behind a single Context toggle, collapsed by
9
+ default.
10
+ - **Five type sizes and one weight scale.** 20/28 panel title, 14/22 row title, 13/20 controls and
11
+ body, 12/18 metadata and section labels, 11.5 mono for paths. Buttons, inputs and tabs share a
12
+ 28px control height; the settings card is a 340px floating inspector (fixed, mutually exclusive
13
+ with the detail card) instead of an in-flow block that pushed the list down.
14
+ - **Rows read top-down.** The title opens the detail (the kebab remains as a redundant door); the
15
+ dispatch button is a quiet action instead of an outlined primary; metadata loses the origin path
16
+ and the stray separator (CSS-generated dots), and tags cap at four. Group-level priority hides
17
+ while the group is expanded, where every row already shows it.
18
+ - **Copy and states.** Full-width colons no longer leak into the English UI, `1 items` is fixed,
19
+ priority chips say Urgent/High/Normal/Low instead of P-codes, the empty state has a primary
20
+ action, the error banner can be dismissed, and the reminder bar keeps its accent on a 2px edge
21
+ (amber text failed contrast). The footer's store path and version move into Settings → About.
22
+
23
+ ## [0.5.3] — 2026-10-20
24
+
25
+ Settings a person can reach, and reminders that behave like reminders.
26
+
27
+ - **A settings popover.** Reminders and the external ingest door were both API-only; the header
28
+ now opens a small panel for the reminder switch, the lead time, the ingest switch, the token
29
+ (generate a fresh one in place; leave blank to keep the current) and the inbox confidence
30
+ threshold. The token still never comes back from the host.
31
+ - **Reminders gained a lead time and a switch.** remindersEnabled and reminderLeadMinutes live in
32
+ settings; the host sweep reads them each tick, announces at dueAt minus lead, and clears the due
33
+ list when reminders are off.
34
+ - **Browser notifications are opt-in.** When the panel is open and a new deadline becomes due, it
35
+ fires a system notification if permission was granted; otherwise the due bar offers to ask for
36
+ permission. The notification is a bonus — the bar is the source of truth.
37
+
1
38
  # Changelog
2
39
 
3
40
  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 = 'ui36'
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
 
@@ -64,6 +64,7 @@ window.__ModuleLoader__.load({
64
64
  'action.retry': '重试',
65
65
  'action.add': '添加',
66
66
  'action.more': '更多字段',
67
+ 'action.overflow': '更多操作',
67
68
  'action.less': '收起字段',
68
69
  'action.detail': '详情',
69
70
  'action.hide': '收起',
@@ -90,6 +91,21 @@ window.__ModuleLoader__.load({
90
91
  'action.import': '导入',
91
92
  'action.exportHint': '把整份清单导出成 JSON 文件(就是存储用的那份文档)',
92
93
  'action.importHint': '从 JSON 文件导入:同 id 覆盖、新 id 追加,不删除现有待办',
94
+ 'settings.title': '设置',
95
+ 'settings.context': '上下文',
96
+ 'settings.reminders': '到点提醒',
97
+ 'settings.remindersEnable': '开启到点提醒',
98
+ 'settings.lead': '提前(分钟)',
99
+ 'settings.ingest': '外部接入(ingest)',
100
+ 'settings.ingestEnable': '启用外部写入',
101
+ 'settings.token': '访问 token',
102
+ 'settings.tokenSet': '已设置,留空保持不变',
103
+ 'settings.generate': '生成新 token',
104
+ 'settings.confirmBelow': '低于此置信度进收件箱',
105
+ 'settings.save': '保存',
106
+ 'settings.cancel': '取消',
107
+ 'settings.saved': '设置已保存',
108
+ 'settings.about': '关于',
93
109
  'notice.exported': '已导出 {n} 条待办',
94
110
  'notice.imported': '已导入 {n} 条(合并,现有共 {total} 条)',
95
111
  'import.badJson': '这个文件不是合法的 JSON',
@@ -111,7 +127,7 @@ window.__ModuleLoader__.load({
111
127
  'notice.bulk': '已更新 {n} 条',
112
128
  'notice.bulkPartial': '已更新 {n} 条,跳过 {m} 条',
113
129
  'select.clear': '清除',
114
- 'select.done': '完成',
130
+ 'select.done': '退出选择',
115
131
  'select.hint': '先勾几条待办,再点「一键分配」:优先回到写下它的那个对话,其次同工作区的对话,都找不到才让你选。',
116
132
  'assign.working': '正在分配…',
117
133
  'assign.summary': '已分配 {n} 条(原对话 {origin} · 同工作区 {workspace} · 你选的 {chosen})',
@@ -138,6 +154,8 @@ window.__ModuleLoader__.load({
138
154
  'notice.externalChange': '清单在别处被改过,已载入最新内容',
139
155
  'reminder.title': '到点提醒({n})',
140
156
  'reminder.dismiss': '知道了',
157
+ 'reminder.enableNotifications': '开启系统通知',
158
+ 'reminder.notifyTitle': '待办到点',
141
159
  'import.preview': '将导入 {total} 条:新增 {create}、更新 {update}',
142
160
  'import.confirm': '导入',
143
161
  'meta.seqHint': '给人看的编号',
@@ -203,10 +221,10 @@ window.__ModuleLoader__.load({
203
221
  'field.linkValue': '文档绝对路径或网址',
204
222
  'field.linkTitle': '给它起个短名字(可空)',
205
223
  'field.search': '搜标题、描述、标签、附件…',
206
- 'priority.low': 'P3 低',
207
- 'priority.normal': 'P2 常规',
208
- 'priority.high': 'P1 高',
209
- 'priority.urgent': 'P0 紧急',
224
+ 'priority.low': '低',
225
+ 'priority.normal': '常规',
226
+ 'priority.high': '高',
227
+ 'priority.urgent': '紧急',
210
228
  'group.by': '分组',
211
229
  'group.conversation': '对话',
212
230
  'group.time': '时间',
@@ -214,6 +232,7 @@ window.__ModuleLoader__.load({
214
232
  'group.manual': '手动添加',
215
233
  'group.other': '其他',
216
234
  'group.items': '{n} 条',
235
+ 'group.item': '1 条',
217
236
  'group.thisConversation': '本对话',
218
237
  'group.overdue': '逾期',
219
238
  'group.today': '今天',
@@ -223,6 +242,7 @@ window.__ModuleLoader__.load({
223
242
  'group.done': '已完成',
224
243
  'group.cancelled': '已取消',
225
244
  'empty.none': '还没有待办。上面写一句,回车就记下了。',
245
+ 'empty.add': '记第一条',
226
246
  'empty.filtered': '这个筛选下没有待办。',
227
247
  'meta.added': '加入于',
228
248
  'meta.due': '截止',
@@ -285,6 +305,7 @@ window.__ModuleLoader__.load({
285
305
  'action.retry': 'Retry',
286
306
  'action.add': 'Add',
287
307
  'action.more': 'More fields',
308
+ 'action.overflow': 'More actions',
288
309
  'action.less': 'Fewer fields',
289
310
  'action.detail': 'Details',
290
311
  'action.hide': 'Hide',
@@ -311,6 +332,21 @@ window.__ModuleLoader__.load({
311
332
  'action.import': 'Import',
312
333
  'action.exportHint': 'Download the whole list as JSON (the document the store keeps)',
313
334
  'action.importHint': 'Import a JSON file: same ids are updated, new ids appended, nothing deleted',
335
+ 'settings.title': 'Settings',
336
+ 'settings.context': 'Context',
337
+ 'settings.reminders': 'Due reminders',
338
+ 'settings.remindersEnable': 'Announce deadlines',
339
+ 'settings.lead': 'Lead time (min)',
340
+ 'settings.ingest': 'External ingest',
341
+ 'settings.ingestEnable': 'Accept external writes',
342
+ 'settings.token': 'Access token',
343
+ 'settings.tokenSet': 'Set — leave blank to keep',
344
+ 'settings.generate': 'New token',
345
+ 'settings.confirmBelow': 'Below this confidence → inbox',
346
+ 'settings.save': 'Save',
347
+ 'settings.cancel': 'Cancel',
348
+ 'settings.saved': 'Settings saved',
349
+ 'settings.about': 'About',
314
350
  'notice.exported': 'Exported {n} todos',
315
351
  'notice.imported': 'Imported {n} (merged; {total} in the list now)',
316
352
  'import.badJson': 'That file is not valid JSON',
@@ -359,6 +395,8 @@ window.__ModuleLoader__.load({
359
395
  'notice.externalChange': 'The list changed somewhere else; the latest content is loaded',
360
396
  'reminder.title': 'Due now ({n})',
361
397
  'reminder.dismiss': 'Dismiss',
398
+ 'reminder.enableNotifications': 'Enable notifications',
399
+ 'reminder.notifyTitle': 'Todo due',
362
400
  'import.preview': 'Will import {total}: {create} new, {update} updated',
363
401
  'import.confirm': 'Import',
364
402
  'meta.seqHint': 'Human-facing number',
@@ -426,16 +464,17 @@ window.__ModuleLoader__.load({
426
464
  'field.linkValue': 'Absolute document path or URL',
427
465
  'field.linkTitle': 'Short label (optional)',
428
466
  'field.search': 'Search title, description, tags, attachments…',
429
- 'priority.low': 'P3 Low',
430
- 'priority.normal': 'P2 Normal',
431
- 'priority.high': 'P1 High',
432
- 'priority.urgent': 'P0 Urgent',
467
+ 'priority.low': 'Low',
468
+ 'priority.normal': 'Normal',
469
+ 'priority.high': 'High',
470
+ 'priority.urgent': 'Urgent',
433
471
  'group.by': 'Group',
434
472
  'group.time': 'Time',
435
473
  'group.level': 'Level',
436
474
  'group.manual': 'Added by hand',
437
475
  'group.other': 'Other',
438
476
  'group.items': '{n} items',
477
+ 'group.item': '1 item',
439
478
  'group.thisConversation': 'this conversation',
440
479
  'group.overdue': 'Overdue',
441
480
  'group.today': 'Today',
@@ -445,6 +484,7 @@ window.__ModuleLoader__.load({
445
484
  'group.done': 'Done',
446
485
  'group.cancelled': 'Cancelled',
447
486
  'empty.none': 'No todos yet. Write one above and press Enter.',
487
+ 'empty.add': 'Add your first todo',
448
488
  'empty.filtered': 'Nothing matches this filter.',
449
489
  'meta.added': 'added',
450
490
  'meta.due': 'due',
@@ -841,6 +881,17 @@ window.__ModuleLoader__.load({
841
881
  )
842
882
  }
843
883
  if (props.name === 'clock') return h('svg', common, h('circle', { cx: 8, cy: 8, r: 5.6 }), h('path', { d: 'M8 4.8V8l2.2 1.4' }))
884
+ if (props.name === 'close') return h('svg', common, h('path', { d: 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6' }))
885
+ if (props.name === 'gear') {
886
+ return h(
887
+ 'svg',
888
+ common,
889
+ h('path', { d: 'M2.6 4.4h10.8M2.6 8h10.8M2.6 11.6h10.8' }),
890
+ h('circle', { cx: 6, cy: 4.4, r: 1.5, fill: 'currentColor', stroke: 'none' }),
891
+ h('circle', { cx: 10, cy: 8, r: 1.5, fill: 'currentColor', stroke: 'none' }),
892
+ h('circle', { cx: 5.2, cy: 11.6, r: 1.5, fill: 'currentColor', stroke: 'none' }),
893
+ )
894
+ }
844
895
  if (props.name === 'chevron') return h('svg', common, h('path', { d: 'M5.6 3.8L10 8l-4.4 4.2' }))
845
896
  if (props.name === 'flag') return h('svg', common, h('path', { d: 'M4 14V2.6h7l-1.2 2.8L11 8.2H4' }))
846
897
  if (props.name === 'mute') {
@@ -857,25 +908,29 @@ window.__ModuleLoader__.load({
857
908
  // ------------------------------------------------------------------ 样式
858
909
 
859
910
  const CSS = `
860
- .gt-root { display: flex; flex-direction: column; height: 100%; min-height: 0;
861
- color: var(--dsw-alias-label-primary); font-size: 13px; }
862
- .gt-inner { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 980px;
863
- margin: 0 auto; padding: 16px 20px 28px; min-height: 0; flex: 1; }
864
- .gt-head { display: flex; align-items: flex-start; gap: 12px; }
911
+ .gt-root { display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
912
+ color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; }
913
+ .gt-inner { display: flex; flex-direction: column; gap: 14px; width: 100%; max-width: 1040px; min-width: 0;
914
+ box-sizing: border-box; margin: 0 auto; padding: 20px 24px 32px; flex: 1; }
915
+ .gt-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
916
+ .gt-head-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex: 0 0 auto; }
865
917
  .gt-headline { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
866
- .gt-title { font-size: 17px; font-weight: 650; letter-spacing: .2px; }
867
- .gt-sub { color: var(--dsw-alias-label-secondary); font-size: 12px; }
918
+ .gt-title { font-size: 20px; line-height: 28px; font-weight: 600; letter-spacing: 0; }
919
+ .gt-sub { color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }
868
920
  .gt-spacer { flex: 1; }
869
921
  .gt-iconbtn { display: inline-flex; align-items: center; justify-content: center; gap: 5px;
870
922
  background: transparent; color: var(--dsw-alias-label-secondary); border: 1px solid transparent;
871
- border-radius: 8px; padding: 5px 7px; font: inherit; cursor: pointer; }
923
+ border-radius: 8px; padding: 0 7px; min-height: 28px; min-width: 28px; box-sizing: border-box;
924
+ font: inherit; cursor: pointer; }
872
925
  .gt-iconbtn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l1);
873
926
  background: var(--dsw-alias-bg-layer-1); }
874
- .gt-btn { display: inline-flex; align-items: center; gap: 5px; background: var(--dsw-alias-bg-layer-1);
927
+ .gt-btn { display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-1);
875
928
  color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
876
- padding: 4px 9px; font: inherit; cursor: pointer; white-space: nowrap; }
929
+ padding: 0 11px; min-height: 28px; box-sizing: border-box; font: inherit; font-size: 13px;
930
+ font-weight: 500; line-height: 20px; cursor: pointer; white-space: nowrap; }
877
931
  .gt-btn:hover { border-color: var(--dsw-alias-border-l2); }
878
- .gt-btn:disabled { opacity: .5; cursor: default; }
932
+ .gt-btn:disabled { opacity: 1; background: var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary);
933
+ border-color: transparent; cursor: default; }
879
934
  .gt-btn-primary { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary);
880
935
  color: var(--dsw-alias-bg-base); font-weight: 600; }
881
936
  .gt-btn-primary:hover { border-color: var(--dsw-alias-brand-primary); }
@@ -892,22 +947,22 @@ window.__ModuleLoader__.load({
892
947
  .gt-new { display: flex; flex-direction: column; gap: 10px; }
893
948
  .gt-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
894
949
  .gt-input, .gt-select, .gt-textarea { background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary);
895
- border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 9px; font: inherit; }
950
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 7px 10px; font: inherit; }
896
951
  .gt-input:focus, .gt-select:focus, .gt-textarea:focus { outline: none; border-color: var(--dsw-alias-brand-primary); }
897
952
  .gt-input { flex: 1; min-width: 120px; }
898
953
  .gt-input-sm { flex: 0 1 auto; min-width: 96px; }
899
954
  .gt-textarea { width: 100%; min-height: 54px; resize: vertical; font-family: inherit; }
900
955
  .gt-select { padding: 6px 7px; }
901
- .gt-search { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 160px;
956
+ .gt-search { display: flex; align-items: center; gap: 6px; flex: 1 1 220px; min-width: 0;
902
957
  background: var(--dsw-alias-bg-base); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px;
903
- padding: 0 9px; color: var(--dsw-alias-label-secondary); }
958
+ min-height: 30px; box-sizing: border-box; padding: 0 9px; color: var(--dsw-alias-label-secondary); }
904
959
  .gt-search input { flex: 1; background: transparent; border: none; color: var(--dsw-alias-label-primary);
905
- font: inherit; padding: 6px 0; min-width: 0; }
960
+ font: inherit; font-size: 13px; padding: 0; min-width: 0; }
906
961
  .gt-search input:focus { outline: none; }
907
962
  .gt-search:focus-within { border-color: var(--dsw-alias-brand-primary); }
908
963
  .gt-kbd { font-size: 10.5px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px;
909
964
  padding: 0 4px; color: var(--dsw-alias-label-secondary); }
910
- .gt-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px;
965
+ .gt-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 12px;
911
966
  color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l1);
912
967
  border-radius: 8px; padding: 3px 6px 3px 8px; max-width: 560px; }
913
968
  /* Only the path may shrink. Everything else in the pill used to shrink with it, so a
@@ -916,20 +971,30 @@ window.__ModuleLoader__.load({
916
971
  .gt-dir > .gt-muted, .gt-dir > .gt-btn, .gt-dir > svg { white-space: nowrap; flex-shrink: 0; }
917
972
  .gt-dir-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--dsw-alias-label-primary);
918
973
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
919
- .gt-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
974
+ .gt-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
975
+ gap: 8px 10px; }
976
+ .gt-toolbar-main { grid-column: 1 / -1; grid-row: 1; display: flex; align-items: center;
977
+ gap: 8px; min-width: 0; flex-wrap: nowrap; }
978
+ .gt-toolbar-main .gt-search { flex: 1 1 auto; }
979
+ .gt-toolbar > .gt-spacer { display: none; }
980
+ .gt-toolbar > .gt-context-summary { grid-row: 2; grid-column: 1; min-width: 0; }
981
+ .gt-toolbar > .gt-tabs { grid-row: 2; grid-column: 2; justify-self: end; }
982
+ .gt-toolbar > .gt-context-details { grid-column: 1 / -1; grid-row: 3; }
983
+ @media (max-width: 1250px) { .gt-toolbar-main { flex-wrap: wrap; } }
920
984
  .gt-seg { display: inline-flex; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1);
921
985
  border-radius: 9px; padding: 2px; gap: 2px; }
922
986
  .gt-seg button { border: none; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit;
923
987
  border-radius: 7px; padding: 3px 10px; cursor: pointer; }
924
- .gt-seg button[data-on="1"] { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
988
+ .gt-seg button[data-on="1"] { background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-primary); font-weight: 600; }
925
989
  .gt-chips { display: flex; gap: 6px; flex-wrap: wrap; }
926
990
  /* One segmented style for every control that is "pick one of these": filters, grouping,
927
991
  the two toggles. Chips stay for facts about a todo (tags, levels, notes). */
928
992
  .gt-tabs { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); }
929
993
  .gt-tab { border: none; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit;
930
- font-size: 12px; border-radius: 6px; padding: 3px 9px; cursor: pointer; white-space: nowrap; }
994
+ font-size: 12.5px; border-radius: 6px; padding: 0 10px; min-height: 24px; box-sizing: border-box;
995
+ cursor: pointer; white-space: nowrap; }
931
996
  .gt-tab:hover { color: var(--dsw-alias-label-primary); }
932
- .gt-tab[data-on="1"] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-weight: 600; }
997
+ .gt-tab[data-on="1"] { background: var(--dsw-alias-button-ghost-active-fill); color: var(--dsw-alias-label-primary); font-weight: 600; }
933
998
  .gt-summary { color: var(--dsw-alias-label-secondary); font-size: 12px; }
934
999
  .gt-browse { border: 1px solid var(--dsw-alias-border-l1); border-radius: 9px; padding: 8px 10px;
935
1000
  display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow: auto; }
@@ -941,7 +1006,7 @@ window.__ModuleLoader__.load({
941
1006
  .gt-browse-item:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-brand-primary); }
942
1007
  .gt-divider { width: 1px; height: 16px; background: var(--dsw-alias-border-l1); }
943
1008
  .gt-context { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
944
- .gt-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; padding: 2px 8px;
1009
+ .gt-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; line-height: 16px; padding: 2px 8px;
945
1010
  border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); background: transparent;
946
1011
  color: var(--dsw-alias-label-secondary); }
947
1012
  button.gt-chip { cursor: pointer; font-family: inherit; }
@@ -951,27 +1016,31 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
951
1016
  .gt-chip-warn { color: var(--dsw-alias-state-warn-primary); border-color: var(--dsw-alias-state-warn-primary); }
952
1017
  .gt-chip-bad { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }
953
1018
  .gt-chip-ok { color: var(--dsw-alias-state-success-primary); }
954
- .gt-groups { display: flex; flex-direction: column; gap: 14px; overflow: auto; min-height: 0; flex: 1; }
1019
+ .gt-groups { display: flex; flex-direction: column; gap: 18px; overflow: auto; min-height: 0; flex: 1; }
955
1020
  .gt-group { display: flex; flex-direction: column; gap: 6px; }
956
- .gt-group-head { display: flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary);
957
- font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; padding: 0 2px; }
1021
+ .gt-group-head { display: flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary);
1022
+ font-size: 12px; line-height: 18px; padding: 0 2px; }
958
1023
  .gt-group-head button { border: none; background: transparent; color: inherit; cursor: pointer;
959
1024
  display: inline-flex; align-items: center; gap: 6px; font: inherit; padding: 2px 0; }
960
1025
  .gt-group-head svg { transition: transform .12s ease; transform: rotate(90deg); }
961
1026
  .gt-group[data-collapsed="1"] .gt-group-head svg { transform: rotate(0deg); }
962
1027
  .gt-group-action { margin-left: 4px; border: 1px solid transparent; background: transparent; color: inherit;
963
- border-radius: 999px; padding: 1px 8px; font: inherit; font-size: 11px; letter-spacing: .04em; cursor: pointer;
964
- display: inline-flex; align-items: center; gap: 5px; opacity: .72; transition: opacity .12s ease; }
1028
+ border-radius: 999px; padding: 3px 8px; min-height: 24px; box-sizing: border-box; font: inherit;
1029
+ font-size: 0; letter-spacing: .04em; cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
1030
+ opacity: .72; transition: opacity .12s ease; }
1031
+ .gt-group:hover .gt-group-action, .gt-group-action:focus-visible { font-size: 11px; }
965
1032
  .gt-group:hover .gt-group-action, .gt-group-action:focus-visible { opacity: 1; }
966
1033
  .gt-group-action:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
967
1034
  /* An exemption is a state, not an action: it stays visible and reads as "on" without a hover. */
968
1035
  .gt-group-action[data-on="1"] { opacity: 1; color: var(--dsw-alias-label-primary);
969
1036
  border-color: var(--dsw-alias-border-l2); }
970
- .gt-group-count { color: var(--dsw-alias-label-secondary); }
971
- .gt-group-label { color: var(--dsw-alias-label-primary); font-weight: 600; text-transform: none;
972
- letter-spacing: 0; max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
973
- .gt-level { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 0 5px;
974
- border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); }
1037
+ .gt-group-count { color: var(--dsw-alias-label-secondary); font-size: 12px; font-weight: 400; }
1038
+ /* A group's level is redundant while it is expanded — the rows show it. */
1039
+ .gt-group:not([data-collapsed="1"]) .gt-group-head .gt-level { display: none; }
1040
+ .gt-group-label { color: var(--dsw-alias-label-primary); font-weight: 600; font-size: 12px;
1041
+ line-height: 18px; max-width: 480px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1042
+ .gt-level { display: inline-flex; align-items: center; gap: 3px; font-size: 11.5px; padding: 0;
1043
+ border: none; color: var(--dsw-alias-label-secondary); }
975
1044
  .gt-level[data-level="urgent"] { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); font-weight: 600; }
976
1045
  .gt-level[data-level="high"] { color: var(--dsw-alias-state-warn-primary); border-color: var(--dsw-alias-state-warn-primary); }
977
1046
  .gt-level[data-level="low"] { color: var(--dsw-alias-label-secondary); }
@@ -979,50 +1048,76 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
979
1048
  display: inline-block; }
980
1049
  .gt-here { font-size: 10.5px; padding: 0 5px; border-radius: 999px; border: 1px solid var(--dsw-alias-brand-primary);
981
1050
  color: var(--dsw-alias-brand-primary); }
982
- .gt-list { display: flex; flex-direction: column; gap: 6px; }
983
- .gt-item { position: relative; display: flex; flex-direction: column; gap: 9px;
1051
+ .gt-list { display: flex; flex-direction: column; gap: 8px; }
1052
+ .gt-item { position: relative; display: flex; flex-direction: column; gap: 8px;
984
1053
  background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1);
985
- border-radius: 11px; padding: 11px 13px; }
1054
+ border-radius: 12px; padding: 12px 14px; }
986
1055
  .gt-item:hover { border-color: var(--dsw-alias-border-l2); }
987
1056
  .gt-item[data-status="doing"] { border-left: 2px solid var(--dsw-alias-brand-primary); }
988
1057
  .gt-item[data-overdue="1"] { border-left: 2px solid var(--dsw-alias-state-error-primary); }
989
1058
  .gt-item[data-status="done"], .gt-item[data-status="cancelled"] { opacity: .72; }
990
1059
  .gt-item[data-open="1"] { border-color: var(--dsw-alias-brand-primary); }
991
1060
  .gt-item-head { display: flex; gap: 10px; align-items: flex-start; }
992
- .gt-check { flex: 0 0 auto; width: 19px; height: 19px; margin-top: 1px; border-radius: 999px; cursor: pointer;
1061
+ .gt-check { flex: 0 0 auto; width: 24px; height: 24px; margin-top: 0; border-radius: 999px; cursor: pointer;
993
1062
  border: 1.4px solid var(--dsw-alias-border-l2); background: transparent; color: transparent;
994
1063
  display: inline-flex; align-items: center; justify-content: center; padding: 0; }
995
1064
  .gt-check:hover { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
996
1065
  .gt-check[data-on="1"] { border-color: var(--dsw-alias-state-success-primary);
997
1066
  background: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-bg-base); }
998
- .gt-pick { flex: 0 0 auto; width: 17px; height: 17px; margin-top: 2px; border-radius: 5px; cursor: pointer;
1067
+ .gt-pick { flex: 0 0 auto; width: 22px; height: 22px; margin-top: 1px; border-radius: 6px; cursor: pointer;
999
1068
  border: 1.4px solid var(--dsw-alias-border-l2); background: transparent; color: transparent;
1000
1069
  display: inline-flex; align-items: center; justify-content: center; padding: 0; }
1001
1070
  .gt-pick[data-on="1"] { border-color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-bg-base); }
1002
1071
  .gt-item[data-selected="1"] { border-color: var(--dsw-alias-brand-primary); }
1003
- .gt-selectbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 7px 10px;
1004
- border: 1px solid var(--dsw-alias-brand-primary); border-radius: 9px; background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
1005
- .gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1006
- .gt-item-title { font-weight: 600; font-size: 13.5px; word-break: break-word; line-height: 1.45; }
1007
- .gt-input-xs { width: 68px; padding: 3px 6px; font-size: 12px; }
1072
+ .gt-selectbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 9px 12px;
1073
+ border: 1px solid var(--dsw-alias-brand-primary); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); font-size: 13px; }
1074
+ .gt-item-body { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 0; cursor: pointer; }
1075
+ .gt-item-body:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 6px; }
1076
+ .gt-item-title { font-weight: 600; font-size: 14px; word-break: break-word; line-height: 22px;
1077
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
1078
+ .gt-input-xs { width: 64px; flex: 0 0 auto; min-width: 0; padding: 0 8px; min-height: 28px;
1079
+ box-sizing: border-box; font-size: 12.5px; }
1008
1080
  .gt-seq { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-secondary); }
1009
- .gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px;
1010
- border: 1px solid var(--dsw-alias-state-warn-primary); border-radius: 10px;
1011
- background: var(--dsw-alias-bg-layer-1); font-size: 12.5px; }
1012
- .gt-reminders-title { color: var(--dsw-alias-state-warn-primary); font-weight: 600; }
1013
- .gt-reminder-item { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2);
1014
- color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; padding: 3px 8px;
1015
- border-radius: 999px; cursor: pointer; max-width: 260px; overflow: hidden; text-overflow: ellipsis;
1081
+ .gt-reminders { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px;
1082
+ min-height: 36px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1);
1083
+ border-radius: 10px; background: var(--dsw-alias-bg-layer-1); font-size: 13px; }
1084
+ .gt-reminders { border-left: 2px solid var(--dsw-alias-state-warn-primary); }
1085
+ .gt-reminders-title { color: var(--dsw-alias-label-primary); font-weight: 600; }
1086
+ .gt-reminder-item { border: 1px solid transparent; background: transparent;
1087
+ color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; padding: 2px 6px;
1088
+ border-radius: 6px; cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis;
1016
1089
  white-space: nowrap; }
1090
+ .gt-reminder-item:hover { background: var(--dsw-alias-button-ghost-active-fill); }
1091
+ .gt-settings { display: flex; flex-direction: column; gap: 12px; padding: 16px; width: 340px;
1092
+ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
1093
+ background: var(--dsw-alias-bg-overlay); font-size: 13px; line-height: 20px; }
1094
+ .gt-settings-path { font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1095
+ .gt-settings .gt-input { flex: 0 0 auto; width: 100%; min-height: 32px; }
1096
+ .gt-settings .gt-check-line .gt-input { width: 64px; }
1097
+ .gt-settings-head { font-weight: 650; font-size: 14px; }
1098
+ .gt-check-line { display: flex; align-items: center; gap: 8px; justify-content: flex-start;
1099
+ color: var(--dsw-alias-label-primary); }
1100
+ .gt-settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
1101
+ .gt-context-summary { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
1102
+ .gt-context-summary .gt-chip { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1103
+ .gt-context-details { display: none; flex-direction: column; gap: 8px; padding: 10px 12px;
1104
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
1105
+ background: var(--dsw-alias-bg-layer-1); }
1106
+ .gt-context-details[data-open="1"] { display: flex; }
1107
+ .gt-detail-head { display: flex; align-items: flex-start; gap: 8px; justify-content: space-between; }
1108
+ .gt-detail-title { font-size: 14px; line-height: 22px; font-weight: 600; min-width: 0;
1109
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1110
+ .gt-truncated { color: var(--dsw-alias-state-warn-primary); font-size: 12px; }
1111
+ .gt-bulk-tag { flex: 0 1 120px; min-width: 90px; }
1017
1112
  .gt-inbox { margin-left: 6px; padding: 1px 6px; border-radius: 999px; font-size: 10.5px; font-weight: 600;
1018
1113
  color: var(--dsw-alias-state-warn-primary); border: 1px solid var(--dsw-alias-state-warn-primary);
1019
1114
  vertical-align: 1px; }
1020
1115
  .gt-item[data-status="done"] .gt-item-title, .gt-item[data-status="cancelled"] .gt-item-title {
1021
1116
  text-decoration: line-through; color: var(--dsw-alias-label-secondary); }
1022
- .gt-meta { display: flex; gap: 5px 8px; flex-wrap: wrap; color: var(--dsw-alias-label-secondary); font-size: 11.5px;
1023
- align-items: center; }
1117
+ .gt-meta { display: flex; gap: 2px 0; flex-wrap: wrap; color: var(--dsw-alias-label-secondary); font-size: 12px;
1118
+ line-height: 18px; align-items: center; }
1024
1119
  .gt-meta > span { display: inline-flex; align-items: center; }
1025
- .gt-meta-sep { opacity: .45; }
1120
+ .gt-meta > span + span::before { content: '·'; margin: 0 6px; opacity: .45; }
1026
1121
  .gt-tag { color: var(--dsw-alias-brand-primary); }
1027
1122
  /* The dispatch action stays put: hiding the only way to hand work to an agent
1028
1123
  behind a hover makes a lone todo look like it can do nothing. */
@@ -1031,23 +1126,23 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1031
1126
  .gt-attach .gt-chip { max-width: 280px; color: var(--dsw-alias-label-secondary); }
1032
1127
  .gt-attach a.gt-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
1033
1128
  .gt-attach-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1034
- .gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 168px; display: flex;
1035
- flex-direction: column; padding: 4px; background: var(--dsw-alias-bg-overlay);
1129
+ .gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 184px; display: flex;
1130
+ flex-direction: column; padding: 5px; background: var(--dsw-alias-bg-overlay);
1036
1131
  border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; }
1037
1132
  .gt-menu button { display: flex; align-items: center; gap: 8px; text-align: left; border: none;
1038
- background: transparent; color: var(--dsw-alias-label-primary); font: inherit; padding: 6px 8px;
1133
+ background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; padding: 8px 10px;
1039
1134
  border-radius: 7px; cursor: pointer; }
1040
- .gt-menu button:hover { background: var(--dsw-alias-bg-layer-2); }
1135
+ .gt-menu button:hover { background: var(--dsw-alias-button-ghost-active-fill); }
1041
1136
  .gt-menu button[data-danger="1"] { color: var(--dsw-alias-state-error-primary); }
1042
- .gt-detail { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--dsw-alias-border-l1);
1043
- padding-top: 10px; margin-top: 2px; }
1137
+ .gt-detail { display: flex; flex-direction: column; gap: 12px; border-top: 1px solid var(--dsw-alias-border-l1);
1138
+ padding-top: 12px; margin-top: 2px; }
1044
1139
  /* Wide screens: the open row's detail becomes a card on the right instead of pushing the list
1045
1140
  down. Deliberately CSS-only over unchanged markup: one detail implementation, and it only
1046
1141
  applies where there is room for a second column. */
1047
- @media (min-width: 1100px) {
1142
+ @media (min-width: 1224px) {
1048
1143
  /* Only the list gives way: the title row and the toolbar keep their full width, so the
1049
1144
  controls never wrap or hide behind the card. */
1050
- .gt-root[data-wide-detail="1"] .gt-groups { padding-right: 380px; }
1145
+ .gt-root[data-wide-detail="1"] .gt-groups { padding-right: 360px; }
1051
1146
  .gt-root[data-wide-detail="1"] .gt-inner { position: relative; }
1052
1147
  .gt-root[data-wide-detail="1"] .gt-item[data-open="1"] .gt-detail {
1053
1148
  /* Anchored to the panel's own column (.gt-inner), not to the viewport: the slot does not
@@ -1058,21 +1153,25 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1058
1153
  a gap instead of overlapping by the padding width. */
1059
1154
  box-sizing: border-box;
1060
1155
  position: fixed; top: var(--gt-detail-top, 208px);
1061
- right: var(--gt-detail-right, max(24px, calc((100vw - 980px) / 2 + 24px)));
1062
- bottom: var(--gt-detail-bottom, 64px); width: 360px;
1156
+ right: var(--gt-detail-right, max(24px, calc((100vw - 1040px) / 2 + 24px)));
1157
+ bottom: var(--gt-detail-bottom, 64px); width: 340px;
1063
1158
  overflow: auto; padding: 16px; margin: 0; border: 1px solid var(--dsw-alias-border-l2);
1064
1159
  border-radius: 12px; background: var(--dsw-alias-bg-overlay); z-index: 30;
1065
1160
  }
1066
1161
  }
1067
1162
  .gt-section { display: flex; flex-direction: column; gap: 5px; }
1068
1163
  .gt-completion { white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
1069
- .gt-section-title { color: var(--dsw-alias-label-secondary); font-size: 11.5px; text-transform: uppercase;
1070
- letter-spacing: .06em; }
1164
+ .gt-section-title { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; font-weight: 600; }
1071
1165
  .gt-pre { white-space: pre-wrap; word-break: break-word; margin: 0; line-height: 1.6; }
1072
1166
  .gt-muted { color: var(--dsw-alias-label-secondary); }
1073
1167
  .gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
1074
1168
  .gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
1075
1169
  .gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
1170
+ .gt-note-item { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; }
1171
+ .gt-note-item + .gt-note-item { border-top: 1px solid var(--dsw-alias-border-l1); }
1172
+ .gt-note-meta { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--dsw-alias-label-secondary);
1173
+ white-space: nowrap; }
1174
+ .gt-note-text { font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-width: 68ch; }
1076
1175
  .gt-link a { color: var(--dsw-alias-brand-primary); text-decoration: none; }
1077
1176
  .gt-link a:hover { text-decoration: underline; }
1078
1177
  .gt-link-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }
@@ -1434,9 +1533,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1434
1533
  if (Number.isInteger(todo.seq) && todo.seq > 0) {
1435
1534
  meta.push(h('span', { key: 'seq', className: 'gt-seq', title: t('meta.seqHint') }, `#${todo.seq}`))
1436
1535
  }
1437
- meta.push(
1438
- h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, `${t('meta.added')} ${absoluteTime(todo.createdAt)}(${ageText(lang, todo.createdAt, now)})`),
1439
- )
1536
+ if (todo.dueAt !== null) {
1537
+ meta.push(
1538
+ h(
1539
+ 'span',
1540
+ { key: 'due', className: overdue ? 'gt-chip-bad' : '', title: new Date(todo.dueAt).toLocaleString() },
1541
+ overdue
1542
+ ? t('meta.overdue', { n: daysLate(todo.dueAt, now) })
1543
+ : sameLocalDay(Date.parse(todo.dueAt), now)
1544
+ ? t('meta.today')
1545
+ : `${t('meta.due')} ${absoluteTime(Date.parse(todo.dueAt))}`,
1546
+ ),
1547
+ )
1548
+ }
1440
1549
  if (todo.priority !== 'normal') {
1441
1550
  meta.push(
1442
1551
  h(
@@ -1447,23 +1556,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1447
1556
  ),
1448
1557
  )
1449
1558
  }
1450
- if (todo.dueAt !== null) {
1559
+ meta.push(h('span', { key: 'added', title: new Date(todo.createdAt).toLocaleString() }, ageText(lang, todo.createdAt, now)))
1560
+ if (todo.tags.length > 0) {
1561
+ const shown = todo.tags.slice(0, 4)
1451
1562
  meta.push(
1452
1563
  h(
1453
1564
  'span',
1454
- { key: 'due', className: overdue ? 'gt-chip-bad' : '', title: new Date(todo.dueAt).toLocaleString() },
1455
- overdue
1456
- ? t('meta.overdue', { n: daysLate(todo.dueAt, now) })
1457
- : sameLocalDay(Date.parse(todo.dueAt), now)
1458
- ? t('meta.today')
1459
- : `${t('meta.due')} ${absoluteTime(Date.parse(todo.dueAt))}`,
1565
+ { key: 'tags', className: 'gt-tag', title: todo.tags.map((tag) => `#${tag}`).join(' ') },
1566
+ shown.map((tag) => `#${tag}`).join(' ') + (todo.tags.length > shown.length ? ` +${todo.tags.length - shown.length}` : ''),
1460
1567
  ),
1461
1568
  )
1462
1569
  }
1463
- if (todo.tags.length > 0) {
1464
- meta.push(h('span', { key: 'tags', className: 'gt-tag' }, todo.tags.map((tag) => `#${tag}`).join(' ')))
1465
- }
1466
- meta.push(h('span', { key: 'by' }, t(`meta.by.${todo.createdBy === 'agent' ? 'agent' : 'human'}`)))
1467
1570
  if (Array.isArray(todo.notes) && todo.notes.length > 0) {
1468
1571
  const latest = todo.notes[todo.notes.length - 1]
1469
1572
  meta.push(h('span', { key: 'notes', title: latest.text }, t('note.count', { n: todo.notes.length })))
@@ -1471,12 +1574,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1471
1574
  if (todo.completion !== null && todo.completion !== undefined) {
1472
1575
  meta.push(h('span', { key: 'completed', title: todo.completion.summary }, t('meta.completed', { when: absoluteTime(todo.completion.at) })))
1473
1576
  }
1474
- if (typeof todo.originDir === 'string' && todo.originDir !== '') {
1475
- meta.push(h('span', { key: 'origin', title: todo.originDir }, t('meta.origin', { dir: shortPath(todo.originDir) })))
1476
- }
1577
+ meta.push(h('span', { key: 'by' }, t(`meta.by.${todo.createdBy === 'agent' ? 'agent' : 'human'}`)))
1477
1578
  if (todo.sessionId !== '') {
1478
- // A live conversation with no working directory cannot do the work. Say
1479
- // so on the row instead of letting the next hand-off land there again.
1480
1579
  const stranded = todo.sessionAlive === true && (todo.sessionDir ?? '') === ''
1481
1580
  meta.push(
1482
1581
  h(
@@ -1484,9 +1583,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1484
1583
  {
1485
1584
  key: 'session',
1486
1585
  className: stranded ? 'gt-chip-warn' : todo.sessionAlive === true ? 'gt-chip-ok' : '',
1487
- title: stranded
1488
- ? t('meta.session.noDirHint')
1489
- : (todo.sessionDir ?? ''),
1586
+ title: stranded ? t('meta.session.noDirHint') : (todo.sessionDir ?? ''),
1490
1587
  },
1491
1588
  stranded ? t('meta.session.noDir') : todo.sessionAlive === true ? t('meta.session.live') : t('meta.session.bound'),
1492
1589
  ),
@@ -1551,7 +1648,21 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1551
1648
  ),
1552
1649
  h(
1553
1650
  'div',
1554
- { className: 'gt-item-body' },
1651
+ {
1652
+ className: 'gt-item-body',
1653
+ role: 'button',
1654
+ tabIndex: 0,
1655
+ 'aria-expanded': open,
1656
+ onClick: (event) => {
1657
+ if (event.target?.closest?.('a,button,input,textarea,select') !== null) return
1658
+ onToggleOpen(todo.id)
1659
+ },
1660
+ onKeyDown: (event) => {
1661
+ if (event.key !== 'Enter' && event.key !== ' ') return
1662
+ event.preventDefault()
1663
+ onToggleOpen(todo.id)
1664
+ },
1665
+ },
1555
1666
  h(
1556
1667
  'div',
1557
1668
  { className: 'gt-item-title' },
@@ -1563,9 +1674,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1563
1674
  h(
1564
1675
  'div',
1565
1676
  { className: 'gt-meta' },
1566
- meta.flatMap((node, index) =>
1567
- index === 0 ? [node] : [h('span', { key: `sep${index}`, className: 'gt-meta-sep' }, '·'), node],
1568
- ),
1677
+ meta,
1569
1678
  ),
1570
1679
  // What is attached is half the point of the list, so the first few are
1571
1680
  // shown as chips instead of being reported as a number.
@@ -1664,7 +1773,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1664
1773
  'button',
1665
1774
  {
1666
1775
  type: 'button',
1667
- className: 'gt-btn gt-btn-brand',
1776
+ className: 'gt-btn gt-btn-quiet',
1668
1777
  disabled: busy,
1669
1778
  title: dispatchLabel,
1670
1779
  onClick: () => onAction('dispatch', todo),
@@ -1715,6 +1824,16 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1715
1824
  ? h(
1716
1825
  'div',
1717
1826
  { className: 'gt-detail' },
1827
+ h(
1828
+ 'div',
1829
+ { className: 'gt-detail-head' },
1830
+ h('span', { className: 'gt-detail-title' }, `${Number.isInteger(todo.seq) && todo.seq > 0 ? `#${todo.seq} ` : ''}${todo.title}`),
1831
+ h(
1832
+ 'button',
1833
+ { type: 'button', className: 'gt-iconbtn', title: t('action.hide'), 'aria-label': t('action.hide'), onClick: () => onToggleOpen(todo.id) },
1834
+ h(Icon, { name: 'close', size: 13 }),
1835
+ ),
1836
+ ),
1718
1837
  h(
1719
1838
  'div',
1720
1839
  { className: 'gt-section' },
@@ -1799,14 +1918,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
1799
1918
  (todo.notes ?? []).map((entry, index) =>
1800
1919
  h(
1801
1920
  'li',
1802
- { className: 'gt-link', key: `note:${index}:${entry.at}` },
1921
+ { className: 'gt-note-item', key: `note:${index}:${entry.at}` },
1803
1922
  h(
1804
- 'span',
1805
- { className: 'gt-link-main' },
1806
- h(Icon, { name: 'doc', size: 13 }),
1807
- h('span', { className: 'gt-muted' }, `${absoluteTime(entry.at)} · ${entry.by === 'agent' ? t('note.by.agent') : t('note.by.human')}`),
1808
- h('span', { className: 'gt-completion' }, entry.text),
1923
+ 'div',
1924
+ { className: 'gt-note-meta' },
1925
+ h(Icon, { name: 'doc', size: 12 }),
1926
+ h('span', null, absoluteTime(entry.at)),
1927
+ h('span', null, entry.by === 'agent' ? t('note.by.agent') : t('note.by.human')),
1809
1928
  ),
1929
+ h('div', { className: 'gt-note-text' }, entry.text),
1810
1930
  ),
1811
1931
  ),
1812
1932
  ),
@@ -2042,6 +2162,22 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2042
2162
  // A picked file is previewed first: a merge is not undoable, and "did I pick the right
2043
2163
  // export" is exactly the kind of question a dry run answers before anything changes.
2044
2164
  const [importAsk, setImportAsk] = useState(null)
2165
+ // One settings popover: reminders and the external door both need a place a person can
2166
+ // reach without curl.
2167
+ const [settingsOpen, setSettingsOpen] = useState(false)
2168
+ const [settingsBox, setSettingsBox] = useState(null)
2169
+ const [contextOpen, setContextOpen] = useState(false)
2170
+ const [tokenDraft, setTokenDraft] = useState('')
2171
+ const [ingestDraft, setIngestDraft] = useState(false)
2172
+ const [belowDraft, setBelowDraft] = useState('0.8')
2173
+ const [remindersDraft, setRemindersDraft] = useState(true)
2174
+ const [leadDraft, setLeadDraft] = useState('0')
2175
+ const [notificationPerm, setNotificationPerm] = useState(
2176
+ typeof window !== 'undefined' && typeof window.Notification === 'function'
2177
+ ? window.Notification.permission
2178
+ : 'unsupported',
2179
+ )
2180
+ const notifiedRef = useRef(new Set())
2045
2181
  const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
2046
2182
  // A notice may carry an action: after a hand-off the useful next step is
2047
2183
  // to go and look at the conversation the work is actually in.
@@ -2270,6 +2406,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2270
2406
  }
2271
2407
  }, [lang])
2272
2408
 
2409
+ useEffect(() => {
2410
+ if (!settingsOpen || typeof window === 'undefined') {
2411
+ setSettingsBox(null)
2412
+ return undefined
2413
+ }
2414
+ const measure = () => {
2415
+ const element = innerRef.current
2416
+ if (element === null) return
2417
+ const rect = element.getBoundingClientRect()
2418
+ setSettingsBox({
2419
+ top: Math.round(rect.top + 210),
2420
+ right: Math.round(window.innerWidth - rect.right + 20),
2421
+ bottom: Math.round(window.innerHeight - rect.bottom + 20),
2422
+ })
2423
+ }
2424
+ measure()
2425
+ window.addEventListener('resize', measure)
2426
+ return () => window.removeEventListener('resize', measure)
2427
+ }, [settingsOpen, lang])
2428
+
2273
2429
  useEffect(() => {
2274
2430
  if (expandedId === '' || typeof window === 'undefined') {
2275
2431
  setDetailAnchor(null)
@@ -2863,6 +3019,49 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2863
3019
  return undefined
2864
3020
  }
2865
3021
 
3022
+ /** Fill the popover from the host's state, so saving cannot send stale values. */
3023
+ const openSettings = () => {
3024
+ // One inspector at a time: the settings card and the detail card occupy the same side.
3025
+ setExpandedId('')
3026
+ const current = state.health?.settings ?? {}
3027
+ setIngestDraft(current.ingest?.enabled === true)
3028
+ setBelowDraft(String(current.ingest?.confirmBelow ?? 0.8))
3029
+ setRemindersDraft(current.remindersEnabled !== false)
3030
+ setLeadDraft(String(current.reminderLeadMinutes ?? 0))
3031
+ setTokenDraft('')
3032
+ setSettingsOpen(true)
3033
+ }
3034
+
3035
+ const generateToken = () => {
3036
+ const bytes = new window.Uint8Array(24)
3037
+ window.crypto.getRandomValues(bytes)
3038
+ setTokenDraft(Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''))
3039
+ setIngestDraft(true)
3040
+ }
3041
+
3042
+ const saveSettings = async () => {
3043
+ try {
3044
+ await call('settings', {
3045
+ method: 'POST',
3046
+ body: {
3047
+ remindersEnabled: remindersDraft,
3048
+ reminderLeadMinutes: Number(leadDraft),
3049
+ ingestEnabled: ingestDraft,
3050
+ ingestConfirmBelow: Number(belowDraft),
3051
+ ...(tokenDraft.trim() === '' ? {} : { ingestToken: tokenDraft.trim() }),
3052
+ },
3053
+ })
3054
+ setSettingsOpen(false)
3055
+ setTokenDraft('')
3056
+ await refreshHealth()
3057
+ setNotice({ text: t('settings.saved'), sessionId: '' })
3058
+ setTimeout(() => setNotice(null), 5000)
3059
+ } catch (failure) {
3060
+ reportFailure('settings', { message: failure?.message })
3061
+ setError(failure)
3062
+ }
3063
+ }
3064
+
2866
3065
  /** Acknowledge reminders so the host stops listing them. */
2867
3066
  const ackReminders = async (ids) => {
2868
3067
  try {
@@ -2901,6 +3100,30 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2901
3100
 
2902
3101
  const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0, inbox: 0 }
2903
3102
  const remindersDue = Array.isArray(state.health?.reminders?.due) ? state.health.reminders.due : []
3103
+ const dueKey = remindersDue.map((item) => `${item.id}:${item.dueAt}`).join('|')
3104
+ useEffect(() => {
3105
+ if (typeof window === 'undefined' || typeof window.Notification !== 'function') return
3106
+ for (const item of remindersDue) {
3107
+ const key = `${item.id}:${item.dueAt}`
3108
+ if (notifiedRef.current.has(key)) continue
3109
+ notifiedRef.current.add(key)
3110
+ if (window.Notification.permission !== 'granted') continue
3111
+ try {
3112
+ const note = new window.Notification(t('reminder.notifyTitle'), { body: item.title, tag: item.id })
3113
+ note.onclick = () => {
3114
+ try {
3115
+ window.focus()
3116
+ } catch {
3117
+ /* focus is a nicety */
3118
+ }
3119
+ setExpandedId(item.id)
3120
+ }
3121
+ } catch {
3122
+ /* a notification is a bonus, never a failure */
3123
+ }
3124
+ }
3125
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3126
+ }, [dueKey])
2904
3127
  const groups = useMemo(
2905
3128
  () => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
2906
3129
  [state.todos, groupMode, sessionTitles, lang],
@@ -2955,26 +3178,27 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2955
3178
  'div',
2956
3179
  { className: 'gt-headline' },
2957
3180
  h('div', { className: 'gt-title' }, t('panel.title')),
2958
- h(
2959
- 'div',
2960
- { className: 'gt-sub' },
2961
- t('panel.subtitle'),
2962
- h('span', { className: 'gt-summary' }, ' · '),
2963
- h('span', { className: 'gt-summary' }, t('summary.active', { n: counts.open + counts.doing })),
2964
- counts.overdue > 0
2965
- ? h('span', { className: 'gt-summary' }, ` · ${t('summary.overdue', { n: counts.overdue })}`)
2966
- : null,
2967
- counts.done > 0
2968
- ? h('span', { className: 'gt-summary' }, ` · ${t('summary.done', { n: counts.done })}`)
2969
- : null,
2970
- ),
3181
+ h('div', { className: 'gt-sub' }, t('panel.subtitle')),
2971
3182
  ),
2972
- h('div', { className: 'gt-spacer' }),
2973
3183
  h(
2974
- 'button',
2975
- {
2976
- type: 'button',
2977
- className: 'gt-btn gt-btn-primary',
3184
+ 'div',
3185
+ { className: 'gt-head-actions' },
3186
+ h(
3187
+ 'button',
3188
+ {
3189
+ type: 'button',
3190
+ className: 'gt-iconbtn',
3191
+ title: t('settings.title'),
3192
+ 'aria-label': t('settings.title'),
3193
+ onClick: openSettings,
3194
+ },
3195
+ h(Icon, { name: 'gear' }),
3196
+ ),
3197
+ h(
3198
+ 'button',
3199
+ {
3200
+ type: 'button',
3201
+ className: 'gt-btn gt-btn-primary',
2978
3202
  'aria-expanded': adding,
2979
3203
  onClick: () => {
2980
3204
  setAdding((value) => !value)
@@ -2984,32 +3208,17 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
2984
3208
  h(Icon, { name: 'plus', size: 13 }),
2985
3209
  t('action.add'),
2986
3210
  ),
2987
- h(
2988
- 'button',
2989
- { type: 'button', className: 'gt-iconbtn', onClick: () => void reload(), disabled: loading, title: t('action.refresh'), 'aria-label': t('action.refresh') },
2990
- h(Icon, { name: 'refresh' }),
2991
- ),
2992
- // Import/export of the same document the store keeps: the small door
2993
- // out of a plugin whose whole point is that the list is a file.
2994
- h(
2995
- 'button',
2996
- {
2997
- type: 'button',
2998
- className: 'gt-btn gt-btn-quiet',
2999
- title: t('action.exportHint'),
3000
- onClick: () => void exportJson(),
3001
- },
3002
- t('action.export'),
3003
- ),
3004
- h(
3005
- 'button',
3006
- {
3007
- type: 'button',
3008
- className: 'gt-btn gt-btn-quiet',
3009
- title: t('action.importHint'),
3010
- onClick: () => importRef.current?.click?.(),
3011
- },
3012
- t('action.import'),
3211
+ // One overflow menu instead of four competing buttons: the primary action is
3212
+ // adding a todo; everything else is occasional.
3213
+ h(Menu, {
3214
+ label: t('action.overflow'),
3215
+ items: [
3216
+ { key: 'refresh', label: t('action.refresh'), icon: 'refresh', onSelect: () => void reload() },
3217
+ { key: 'export', label: t('action.export'), icon: 'doc', onSelect: () => void exportJson() },
3218
+ { key: 'import', label: t('action.import'), icon: 'plus', onSelect: () => importRef.current?.click?.() },
3219
+ { key: 'settings', label: t('settings.title'), icon: 'gear', onSelect: () => openSettings() },
3220
+ ],
3221
+ }),
3013
3222
  ),
3014
3223
  h('input', {
3015
3224
  ref: importRef,
@@ -3020,12 +3229,105 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3020
3229
  onChange: (event) => void importJson(event),
3021
3230
  }),
3022
3231
  ),
3232
+ settingsOpen
3233
+ ? h(
3234
+ 'div',
3235
+ {
3236
+ className: 'gt-settings',
3237
+ role: 'dialog',
3238
+ 'aria-label': t('settings.title'),
3239
+ style:
3240
+ settingsBox === null
3241
+ ? undefined
3242
+ : {
3243
+ position: 'fixed',
3244
+ top: `${settingsBox.top}px`,
3245
+ right: `${settingsBox.right}px`,
3246
+ bottom: `${settingsBox.bottom}px`,
3247
+ maxHeight: 'none',
3248
+ overflow: 'auto',
3249
+ zIndex: 40,
3250
+ },
3251
+ },
3252
+ h('div', { className: 'gt-settings-head' }, t('settings.title')),
3253
+ h('div', { className: 'gt-section-title' }, t('settings.reminders')),
3254
+ h(
3255
+ 'label',
3256
+ { className: 'gt-check-line' },
3257
+ h('input', { type: 'checkbox', checked: remindersDraft, onChange: (event) => setRemindersDraft(event.target.checked) }),
3258
+ t('settings.remindersEnable'),
3259
+ ),
3260
+ h(
3261
+ 'label',
3262
+ { className: 'gt-check-line' },
3263
+ t('settings.lead'),
3264
+ h('input', {
3265
+ type: 'number',
3266
+ min: 0,
3267
+ max: 1440,
3268
+ className: 'gt-input gt-input-xs',
3269
+ value: leadDraft,
3270
+ onChange: (event) => setLeadDraft(event.target.value),
3271
+ }),
3272
+ ),
3273
+ h('div', { className: 'gt-section-title' }, t('settings.ingest')),
3274
+ h(
3275
+ 'label',
3276
+ { className: 'gt-check-line' },
3277
+ h('input', { type: 'checkbox', checked: ingestDraft, onChange: (event) => setIngestDraft(event.target.checked) }),
3278
+ t('settings.ingestEnable'),
3279
+ ),
3280
+ h('input', {
3281
+ className: 'gt-input',
3282
+ type: 'password',
3283
+ value: tokenDraft,
3284
+ placeholder:
3285
+ state.health?.settings?.ingest?.tokenSet === true ? t('settings.tokenSet') : t('settings.token'),
3286
+ autoComplete: 'new-password',
3287
+ onChange: (event) => setTokenDraft(event.target.value),
3288
+ }),
3289
+ h(
3290
+ 'div',
3291
+ { className: 'gt-settings-row' },
3292
+ h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: generateToken }, t('settings.generate')),
3293
+ h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSettingsOpen(false) }, t('settings.cancel')),
3294
+ h('button', { type: 'button', className: 'gt-btn gt-btn-primary', onClick: () => void saveSettings() }, t('settings.save')),
3295
+ ),
3296
+ h(
3297
+ 'label',
3298
+ { className: 'gt-check-line' },
3299
+ t('settings.confirmBelow'),
3300
+ h('input', {
3301
+ type: 'number',
3302
+ min: 0,
3303
+ max: 1,
3304
+ step: 0.05,
3305
+ className: 'gt-input gt-input-xs',
3306
+ value: belowDraft,
3307
+ onChange: (event) => setBelowDraft(event.target.value),
3308
+ }),
3309
+ ),
3310
+ h('div', { className: 'gt-section-title' }, t('settings.about')),
3311
+ h(
3312
+ 'div',
3313
+ { className: 'gt-muted gt-mono gt-settings-path', title: state.health?.store?.file ?? '' },
3314
+ state.health?.store?.file ?? '',
3315
+ ),
3316
+ h('div', { className: 'gt-muted' }, `v${state.health?.version ?? ''} · ${UI_REV}`),
3317
+ )
3318
+ : null,
3319
+
3023
3320
  error !== null
3024
3321
  ? h(
3025
3322
  'div',
3026
3323
  { className: 'gt-banner', role: 'alert' },
3027
- `${t('error.prefix')}: ${error.message} (${error.reason ?? 'internal'})`,
3324
+ h('span', { className: 'gt-banner-text' }, `${t('error.prefix')}: ${error.message}`),
3028
3325
  h('button', { type: 'button', className: 'gt-btn', onClick: () => void reload() }, t('action.retry')),
3326
+ h(
3327
+ 'button',
3328
+ { type: 'button', className: 'gt-iconbtn', title: t('action.hide'), 'aria-label': t('action.hide'), onClick: () => setError(null) },
3329
+ h(Icon, { name: 'close', size: 13 }),
3330
+ ),
3029
3331
  )
3030
3332
  : null,
3031
3333
 
@@ -3115,6 +3417,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3115
3417
  h(
3116
3418
  'div',
3117
3419
  { className: 'gt-toolbar' },
3420
+ h('div', { className: 'gt-toolbar-main' },
3118
3421
  h(
3119
3422
  'span',
3120
3423
  { className: 'gt-tabs', role: 'group', 'aria-label': t('filter.label') },
@@ -3170,12 +3473,37 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3170
3473
  ),
3171
3474
  ),
3172
3475
  ),
3476
+ ),
3173
3477
  h('div', { className: 'gt-spacer' }),
3478
+ h(
3479
+ 'div',
3480
+ { className: 'gt-context-summary' },
3481
+ h(
3482
+ 'button',
3483
+ { type: 'button', className: 'gt-chip', title: effectiveDir === '' ? t('dir.none') : effectiveDir, onClick: () => setContextOpen((value) => !value) },
3484
+ h(Icon, { name: 'doc', size: 12 }),
3485
+ effectiveDir === '' ? t('dir.none') : effectiveDir,
3486
+ ),
3487
+ h(
3488
+ 'button',
3489
+ { type: 'button', className: 'gt-chip', title: t('target.pick'), onClick: () => setContextOpen((value) => !value) },
3490
+ h(Icon, { name: 'play', size: 11 }),
3491
+ `${t('target.label')}: ${conversations.find((entry) => entry.id === targetValue)?.title ?? t('target.auto')}`,
3492
+ ),
3493
+ h(
3494
+ 'button',
3495
+ { type: 'button', className: 'gt-btn gt-btn-quiet', 'aria-expanded': contextOpen, onClick: () => setContextOpen((value) => !value) },
3496
+ t('settings.context'),
3497
+ ),
3498
+ ),
3499
+ h(
3500
+ 'div',
3501
+ { className: 'gt-context-details', 'data-open': contextOpen ? '1' : '0' },
3174
3502
  h(
3175
3503
  'span',
3176
3504
  { className: 'gt-dir', title: effectiveDir === '' ? t('dir.none') : effectiveDir },
3177
3505
  h(Icon, { name: 'doc', size: 12 }),
3178
- h('span', { className: 'gt-muted' }, `${t('field.dir')}:`),
3506
+ h('span', { className: 'gt-muted' }, `${t('field.dir')}: `),
3179
3507
  h('span', { className: 'gt-dir-path' }, effectiveDir === '' ? t('dir.none') : effectiveDir),
3180
3508
  h('span', { className: 'gt-muted' }, pickedDir === '' ? t('dir.fromSession') : t('dir.picked')),
3181
3509
  h('button', { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void chooseDir() }, t('action.pickDir')),
@@ -3280,7 +3608,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3280
3608
  ),
3281
3609
  ),
3282
3610
  ),
3283
- h('span', { className: 'gt-muted' }, `${t('target.label')}:`),
3611
+ h('span', { className: 'gt-muted' }, `${t('target.label')}: `),
3284
3612
  h(
3285
3613
  'select',
3286
3614
  {
@@ -3310,6 +3638,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3310
3638
  { value: 'auto', label: t('target.auto') },
3311
3639
  ].map((option) => h('option', { key: option.value, value: option.value }, option.label)),
3312
3640
  ),
3641
+ ),
3313
3642
  h('span', { className: 'gt-spacer' }),
3314
3643
  h('span', { className: 'gt-tabs', role: 'group', 'aria-label': t('autoCollect.label') }, togglesNodes),
3315
3644
  ),
@@ -3462,6 +3791,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3462
3791
  `${item.seq > 0 ? `#${item.seq} ` : ''}${item.title}`,
3463
3792
  ),
3464
3793
  ),
3794
+ notificationPerm === 'default'
3795
+ ? h(
3796
+ 'button',
3797
+ {
3798
+ type: 'button',
3799
+ className: 'gt-btn gt-btn-quiet',
3800
+ onClick: () => {
3801
+ void window.Notification.requestPermission().then((permission) => setNotificationPerm(permission))
3802
+ },
3803
+ },
3804
+ t('reminder.enableNotifications'),
3805
+ )
3806
+ : null,
3465
3807
  h(
3466
3808
  'button',
3467
3809
  { type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => void ackReminders(remindersDue.map((item) => item.id)) },
@@ -3527,6 +3869,20 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3527
3869
  'div',
3528
3870
  { className: 'gt-empty' },
3529
3871
  h('div', { className: 'gt-empty-title' }, (state.counts?.total ?? 0) === 0 && query.trim() === '' ? t('empty.none') : t('empty.filtered')),
3872
+ (state.counts?.total ?? 0) === 0 && query.trim() === ''
3873
+ ? h(
3874
+ 'button',
3875
+ {
3876
+ type: 'button',
3877
+ className: 'gt-btn gt-btn-primary',
3878
+ onClick: () => {
3879
+ setAdding(true)
3880
+ setMore(false)
3881
+ },
3882
+ },
3883
+ t('empty.add'),
3884
+ )
3885
+ : null,
3530
3886
  h('div', { className: 'gt-sub' }, t('hint.keys')),
3531
3887
  )
3532
3888
  : h(
@@ -3551,7 +3907,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3551
3907
  },
3552
3908
  h(Icon, { name: 'chevron', size: 12 }),
3553
3909
  h('span', { className: 'gt-group-label' }, group.label),
3554
- h('span', { className: 'gt-group-count' }, t('group.items', { n: group.todos.length })),
3910
+ h(
3911
+ 'span',
3912
+ { className: 'gt-group-count' },
3913
+ group.todos.length === 1 ? t('group.item') : t('group.items', { n: group.todos.length }),
3914
+ ),
3555
3915
  group.level === 'normal'
3556
3916
  ? null
3557
3917
  : h(
@@ -3617,7 +3977,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3617
3977
  open: expandedId === todo.id,
3618
3978
  dispatchLabel,
3619
3979
  onAction,
3620
- onToggleOpen: (id) => setExpandedId((current) => (current === id ? '' : id)),
3980
+ onToggleOpen: (id) => {
3981
+ setSettingsOpen(false)
3982
+ setExpandedId((current) => (current === id ? '' : id))
3983
+ },
3621
3984
  }),
3622
3985
  ),
3623
3986
  ),
@@ -3633,15 +3996,6 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
3633
3996
  )
3634
3997
  : null,
3635
3998
 
3636
- h(
3637
- 'div',
3638
- { className: 'gt-foot' },
3639
- state.health !== null ? h('span', null, t('store.at', { file: state.health.store?.file ?? '' })) : null,
3640
- state.health !== null
3641
- ? h('span', { className: 'gt-mono' }, `v${state.health.version ?? ''}·${UI_REV}`)
3642
- : null,
3643
- h('span', null, t('hint.keys')),
3644
- ),
3645
3999
  ),
3646
4000
  notice !== null && notice !== undefined
3647
4001
  ? h(
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.6.0'
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.6.0",
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",