dsh-global-todo 0.3.2 → 0.4.1
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 +105 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +27 -4
- package/README.zh.md +23 -4
- package/cordis.patch.yml +6 -0
- package/docs/DESIGN.md +31 -4
- package/docs/ROADMAP.md +55 -42
- package/docs/icon.svg +19 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/lib/client.js +233 -20
- package/lib/index.js +108 -24
- package/lib/model.js +4 -4
- package/lib/routes.js +29 -0
- package/lib/service.js +66 -6
- package/lib/settings.js +91 -6
- package/lib/tools.js +18 -4
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -50,7 +50,9 @@ 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 = '
|
|
53
|
+
const UI_REV = 'ui25'
|
|
54
|
+
/** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
|
|
55
|
+
let activeLang = 'zh'
|
|
54
56
|
|
|
55
57
|
// ------------------------------------------------------------------ 文案
|
|
56
58
|
|
|
@@ -73,12 +75,15 @@ window.__ModuleLoader__.load({
|
|
|
73
75
|
'action.done': '完成',
|
|
74
76
|
'action.reopen': '标回未完成',
|
|
75
77
|
'action.doing': '标为进行中',
|
|
78
|
+
'action.archive': '归档',
|
|
79
|
+
'action.unarchive': '取消归档',
|
|
76
80
|
'action.remove': '删除',
|
|
77
81
|
'action.removeConfirm': '确认删除',
|
|
78
82
|
'action.unlink': '摘掉',
|
|
79
83
|
'action.addLink': '挂上',
|
|
80
84
|
'action.open': '打开',
|
|
81
85
|
'action.copy': '复制路径',
|
|
86
|
+
'action.copyPathHint': '(点击复制路径)',
|
|
82
87
|
'action.copied': '已复制',
|
|
83
88
|
'action.undo': '撤销',
|
|
84
89
|
'action.export': '导出',
|
|
@@ -99,6 +104,12 @@ window.__ModuleLoader__.load({
|
|
|
99
104
|
'select.all': '全选',
|
|
100
105
|
'select.allOn': '取消全选',
|
|
101
106
|
'select.assign': '一键分配',
|
|
107
|
+
'select.complete': '完成',
|
|
108
|
+
'select.archive': '归档',
|
|
109
|
+
'field.bulkTag': '标签…',
|
|
110
|
+
'select.addTag': '加标签',
|
|
111
|
+
'notice.bulk': '已更新 {n} 条',
|
|
112
|
+
'notice.bulkPartial': '已更新 {n} 条,跳过 {m} 条',
|
|
102
113
|
'select.clear': '清除',
|
|
103
114
|
'select.done': '完成',
|
|
104
115
|
'select.hint': '先勾几条待办,再点「一键分配」:优先回到写下它的那个对话,其次同工作区的对话,都找不到才让你选。',
|
|
@@ -109,6 +120,18 @@ window.__ModuleLoader__.load({
|
|
|
109
120
|
'assign.failed': '{n} 条没分出去:{detail}',
|
|
110
121
|
'autoCollect.label': '自动获取',
|
|
111
122
|
'autoCollect.hint': '开启后:某个对话**安静满 {n} 分钟**(期间没有任何新发言)才自动问它一次「有没有要跟进的事」;你一边说话它一边计时重置,所以工作时不会被打扰。它问过之后,**只有你在那个对话里再说一句话**才会重新问——不会追着自己的提问反复问。关掉就完全靠你和 Agent 手动记。',
|
|
123
|
+
'autoCollect.last': '最近一次判定:{reason}(等待 {waiting} · 到点 {due})',
|
|
124
|
+
'autoCollect.reason.off': '开关关着',
|
|
125
|
+
'autoCollect.reason.no-session': '没有会话',
|
|
126
|
+
'autoCollect.reason.no-activity': '不知道安静了多久',
|
|
127
|
+
'autoCollect.reason.exempt-workspace': '该工作区已豁免',
|
|
128
|
+
'autoCollect.reason.unknown-dir': '不知道工作目录(保守不问)',
|
|
129
|
+
'autoCollect.reason.retry-wait': '上次失败,等重试',
|
|
130
|
+
'autoCollect.reason.awaiting-human': '问过,等你说话',
|
|
131
|
+
'autoCollect.reason.not-idle-yet': '还没安静够',
|
|
132
|
+
'autoCollect.reason.cooldown': '冷却中',
|
|
133
|
+
'autoCollect.reason.ok': '可以问',
|
|
134
|
+
'autoCollect.reason.turn': '轮次没有干净结束({kind})',
|
|
112
135
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
113
136
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
114
137
|
'action.exempt': '豁免',
|
|
@@ -158,6 +181,8 @@ window.__ModuleLoader__.load({
|
|
|
158
181
|
'filter.doing': '进行中',
|
|
159
182
|
'filter.done': '已完成',
|
|
160
183
|
'filter.overdue': '逾期',
|
|
184
|
+
'filter.archived': '已归档',
|
|
185
|
+
'filter.label': '筛选',
|
|
161
186
|
'field.title': '要做什么?回车即存',
|
|
162
187
|
'field.description': '描述:验收标准、已知的坑…',
|
|
163
188
|
'field.links': '相关文档与地址,一行一个(/绝对路径 或 https://…)',
|
|
@@ -176,7 +201,6 @@ window.__ModuleLoader__.load({
|
|
|
176
201
|
'group.time': '时间',
|
|
177
202
|
'group.level': '级别',
|
|
178
203
|
'group.manual': '手动添加',
|
|
179
|
-
'group.conversation': '对话',
|
|
180
204
|
'group.other': '其他',
|
|
181
205
|
'group.items': '{n} 条',
|
|
182
206
|
'group.thisConversation': '本对话',
|
|
@@ -199,6 +223,7 @@ window.__ModuleLoader__.load({
|
|
|
199
223
|
'meta.session.live': '会话在跑',
|
|
200
224
|
'meta.session.bound': '已绑定会话',
|
|
201
225
|
'meta.session.noDir': '会话无工作目录',
|
|
226
|
+
'meta.session.noDirHint': '这个会话没有工作目录,agent 在里面做不了事;用「派发:开新会话」重派一次',
|
|
202
227
|
'meta.dispatchFailed': '上次派发失败',
|
|
203
228
|
'detail.description': '描述',
|
|
204
229
|
'detail.noDescription': '没有描述。',
|
|
@@ -225,6 +250,8 @@ window.__ModuleLoader__.load({
|
|
|
225
250
|
'meta.completed': '完成于 {when}',
|
|
226
251
|
'detail.edit': '编辑',
|
|
227
252
|
'error.prefix': '出错了',
|
|
253
|
+
'error.notJson': 'HTTP {status}:响应不是 JSON',
|
|
254
|
+
'boundary.error': '全局待办面板出错了:{message}',
|
|
228
255
|
'counts.active': '未完成 {n}',
|
|
229
256
|
'counts.overdue': '逾期 {n}',
|
|
230
257
|
'counts.done': '已完成 {n}',
|
|
@@ -232,6 +259,7 @@ window.__ModuleLoader__.load({
|
|
|
232
259
|
'loading': '载入中…',
|
|
233
260
|
'deleted': '已删除「{title}」',
|
|
234
261
|
'hint.keys': '快捷键:/ 搜索 · n 新建 · Esc 收起',
|
|
262
|
+
'list.truncated': '只显示前 {shown} 条,共 {matched} 条;用搜索或筛选缩小范围',
|
|
235
263
|
'justNow': '刚刚',
|
|
236
264
|
'minutesAgo': '{n} 分钟前',
|
|
237
265
|
'hoursAgo': '{n} 小时前',
|
|
@@ -257,12 +285,15 @@ window.__ModuleLoader__.load({
|
|
|
257
285
|
'action.done': 'Done',
|
|
258
286
|
'action.reopen': 'Reopen',
|
|
259
287
|
'action.doing': 'Start',
|
|
288
|
+
'action.archive': 'Archive',
|
|
289
|
+
'action.unarchive': 'Unarchive',
|
|
260
290
|
'action.remove': 'Delete',
|
|
261
291
|
'action.removeConfirm': 'Confirm delete',
|
|
262
292
|
'action.unlink': 'Remove',
|
|
263
293
|
'action.addLink': 'Attach',
|
|
264
294
|
'action.open': 'Open',
|
|
265
295
|
'action.copy': 'Copy path',
|
|
296
|
+
'action.copyPathHint': ' (click to copy path)',
|
|
266
297
|
'action.copied': 'Copied',
|
|
267
298
|
'action.undo': 'Undo',
|
|
268
299
|
'action.export': 'Export',
|
|
@@ -283,6 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
283
314
|
'select.all': 'Select all',
|
|
284
315
|
'select.allOn': 'Clear selection',
|
|
285
316
|
'select.assign': 'Assign',
|
|
317
|
+
'select.complete': 'Complete',
|
|
318
|
+
'select.archive': 'Archive',
|
|
319
|
+
'field.bulkTag': 'Tag…',
|
|
320
|
+
'select.addTag': 'Add tag',
|
|
321
|
+
'notice.bulk': 'Updated {n}',
|
|
322
|
+
'notice.bulkPartial': 'Updated {n}, skipped {m}',
|
|
286
323
|
'select.clear': 'Clear',
|
|
287
324
|
'select.done': 'Done',
|
|
288
325
|
'select.hint': 'Pick a few todos, then Assign: back to the conversation that wrote each one, else a conversation in the same workspace, else you choose.',
|
|
@@ -293,6 +330,18 @@ window.__ModuleLoader__.load({
|
|
|
293
330
|
'assign.failed': '{n} could not be assigned: {detail}',
|
|
294
331
|
'autoCollect.label': 'Auto-collect',
|
|
295
332
|
'autoCollect.hint': 'When on: a conversation is asked once whether anything needs following up, but only after it has been **quiet for {n} minutes** — every new message restarts that clock, so a session you are working in is never interrupted. Once it has asked, only **you writing in that conversation** arms it again, so the question can never chase its own answer.',
|
|
333
|
+
'autoCollect.last': 'Last decision: {reason} (waiting {waiting}, due {due})',
|
|
334
|
+
'autoCollect.reason.off': 'the switch is off',
|
|
335
|
+
'autoCollect.reason.no-session': 'no conversation',
|
|
336
|
+
'autoCollect.reason.no-activity': 'the quiet time is unknown',
|
|
337
|
+
'autoCollect.reason.exempt-workspace': 'that workspace is exempt',
|
|
338
|
+
'autoCollect.reason.unknown-dir': 'the working directory is unknown (stays quiet)',
|
|
339
|
+
'autoCollect.reason.retry-wait': 'the last attempt failed; waiting to retry',
|
|
340
|
+
'autoCollect.reason.awaiting-human': 'asked; waiting for you to speak',
|
|
341
|
+
'autoCollect.reason.not-idle-yet': 'not quiet for long enough yet',
|
|
342
|
+
'autoCollect.reason.cooldown': 'cooling down',
|
|
343
|
+
'autoCollect.reason.ok': 'ready to ask',
|
|
344
|
+
'autoCollect.reason.turn': 'the turn did not end cleanly ({kind})',
|
|
296
345
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
297
346
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
298
347
|
'action.exempt': 'Exempt',
|
|
@@ -336,7 +385,7 @@ window.__ModuleLoader__.load({
|
|
|
336
385
|
'dir.fromSession': '(from this conversation)',
|
|
337
386
|
'dir.picked': '(chosen)',
|
|
338
387
|
'meta.origin': 'from {dir}',
|
|
339
|
-
'meta.completed': '
|
|
388
|
+
'meta.completed': 'Completed {when}',
|
|
340
389
|
'action.save': 'Save',
|
|
341
390
|
'action.cancel': 'Cancel',
|
|
342
391
|
'filter.all': 'All',
|
|
@@ -344,6 +393,8 @@ window.__ModuleLoader__.load({
|
|
|
344
393
|
'filter.doing': 'Doing',
|
|
345
394
|
'filter.done': 'Done',
|
|
346
395
|
'filter.overdue': 'Overdue',
|
|
396
|
+
'filter.archived': 'Archived',
|
|
397
|
+
'filter.label': 'Filters',
|
|
347
398
|
'field.title': 'What needs doing? Enter to save',
|
|
348
399
|
'field.description': 'Description: acceptance, known traps…',
|
|
349
400
|
'field.links': 'Documents and links, one per line (/abs/path or https://…)',
|
|
@@ -383,6 +434,7 @@ window.__ModuleLoader__.load({
|
|
|
383
434
|
'meta.session.live': 'session running',
|
|
384
435
|
'meta.session.bound': 'session bound',
|
|
385
436
|
'meta.session.noDir': 'session has no directory',
|
|
437
|
+
'meta.session.noDirHint': 'This conversation has no working directory, so an agent cannot do anything in it; hand it off again with a new conversation.',
|
|
386
438
|
'meta.dispatchFailed': 'last hand-off failed',
|
|
387
439
|
'detail.description': 'Description',
|
|
388
440
|
'detail.noDescription': 'No description.',
|
|
@@ -392,6 +444,8 @@ window.__ModuleLoader__.load({
|
|
|
392
444
|
'detail.lastDispatch': 'Last hand-off',
|
|
393
445
|
'detail.edit': 'Edit',
|
|
394
446
|
'error.prefix': 'Error',
|
|
447
|
+
'error.notJson': 'HTTP {status}: the response was not JSON',
|
|
448
|
+
'boundary.error': 'The todo panel crashed: {message}',
|
|
395
449
|
'counts.active': 'Open {n}',
|
|
396
450
|
'counts.overdue': 'Overdue {n}',
|
|
397
451
|
'counts.done': 'Done {n}',
|
|
@@ -399,10 +453,27 @@ window.__ModuleLoader__.load({
|
|
|
399
453
|
'loading': 'Loading…',
|
|
400
454
|
'deleted': 'Deleted “{title}”',
|
|
401
455
|
'hint.keys': 'Keys: / search · n new · Esc collapse',
|
|
456
|
+
'list.truncated': 'Showing the first {shown} of {matched}; narrow the search or filter to see the rest',
|
|
402
457
|
'justNow': 'just now',
|
|
403
458
|
'minutesAgo': '{n} min ago',
|
|
404
459
|
'hoursAgo': '{n} h ago',
|
|
405
460
|
'daysAgo': '{n} d ago',
|
|
461
|
+
'group.conversation': 'Conversation',
|
|
462
|
+
'detail.completion': 'Completion',
|
|
463
|
+
'detail.notes': 'Progress notes',
|
|
464
|
+
'note.placeholder': 'Add a note: where this stands, what is blocking it, who needs to act',
|
|
465
|
+
'note.add': 'Add note',
|
|
466
|
+
'note.by.agent': 'Agent',
|
|
467
|
+
'note.by.human': 'You',
|
|
468
|
+
'note.none': 'No notes yet.',
|
|
469
|
+
'note.count': '{n} notes',
|
|
470
|
+
'note.latest': 'Latest: {text}',
|
|
471
|
+
'link.missing': 'File is gone (moved or deleted)',
|
|
472
|
+
'search.hint': 'Supports tag:release / is:overdue / has:link / level:p0, space-separated',
|
|
473
|
+
'completion.by.agent': 'Finished by the agent',
|
|
474
|
+
'completion.by.human': 'Finished by you',
|
|
475
|
+
'completion.auto': 'Recorded automatically when the session finished',
|
|
476
|
+
'completion.none': 'No closing summary',
|
|
406
477
|
}
|
|
407
478
|
|
|
408
479
|
const DICTS = { zh, en }
|
|
@@ -429,7 +500,10 @@ window.__ModuleLoader__.load({
|
|
|
429
500
|
function resolveLang(ctx) {
|
|
430
501
|
try {
|
|
431
502
|
const snapshot = ctx.locale?.getLocale?.() ?? ctx.locale?.getSnapshot?.()
|
|
432
|
-
|
|
503
|
+
// LocaleSnapshot is { active, locales, revision }. The older guess looked for
|
|
504
|
+
// locale/id/language/current, so switching the language in settings never reached
|
|
505
|
+
// this panel; keep the fallbacks for a host that still answers another shape.
|
|
506
|
+
const raw = snapshot?.active ?? snapshot?.locale ?? snapshot?.id ?? snapshot?.language ?? snapshot?.current ?? ''
|
|
433
507
|
const text = typeof raw === 'string' ? raw.toLowerCase() : ''
|
|
434
508
|
if (text.startsWith('zh')) return 'zh'
|
|
435
509
|
if (text.startsWith('en')) return 'en'
|
|
@@ -651,7 +725,7 @@ window.__ModuleLoader__.load({
|
|
|
651
725
|
payload = await response.json()
|
|
652
726
|
} catch {
|
|
653
727
|
reportFailure('call', { status: response.status, reason: 'not-json', message: path })
|
|
654
|
-
throw new ApiError('internal',
|
|
728
|
+
throw new ApiError('internal', translate(activeLang, 'error.notJson', { status: response.status }))
|
|
655
729
|
}
|
|
656
730
|
if (payload?.ok === true) return payload.data
|
|
657
731
|
reportFailure('call', {
|
|
@@ -1263,6 +1337,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1263
1337
|
const onPick = typeof props.onPick === 'function' ? props.onPick : () => {}
|
|
1264
1338
|
// The note box belongs to the row that is open, so its draft lives here.
|
|
1265
1339
|
const [noteDraft, setNoteDraft] = useState('')
|
|
1340
|
+
// Opening a row near the bottom of a long list put the editor below the
|
|
1341
|
+
// viewport; the click looked like it did nothing. Bring the row into view
|
|
1342
|
+
// when it opens, without yanking the page when it closes.
|
|
1343
|
+
const rootRef = useRef(null)
|
|
1266
1344
|
const [linkValue, setLinkValue] = useState('')
|
|
1267
1345
|
const [linkTitle, setLinkTitle] = useState('')
|
|
1268
1346
|
const [draft, setDraft] = useState({
|
|
@@ -1282,6 +1360,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1282
1360
|
})
|
|
1283
1361
|
}, [todo.id, todo.updatedAt, todo.title, todo.description, todo.dueAt, todo.priority])
|
|
1284
1362
|
|
|
1363
|
+
useEffect(() => {
|
|
1364
|
+
if (!open) return
|
|
1365
|
+
const node = rootRef.current
|
|
1366
|
+
if (node !== null && typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'nearest' })
|
|
1367
|
+
}, [open])
|
|
1368
|
+
|
|
1285
1369
|
const dirty =
|
|
1286
1370
|
draft.title !== todo.title ||
|
|
1287
1371
|
draft.description !== todo.description ||
|
|
@@ -1340,7 +1424,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1340
1424
|
key: 'session',
|
|
1341
1425
|
className: stranded ? 'gt-chip-warn' : todo.sessionAlive === true ? 'gt-chip-ok' : '',
|
|
1342
1426
|
title: stranded
|
|
1343
|
-
? '
|
|
1427
|
+
? t('meta.session.noDirHint')
|
|
1344
1428
|
: (todo.sessionDir ?? ''),
|
|
1345
1429
|
},
|
|
1346
1430
|
stranded ? t('meta.session.noDir') : todo.sessionAlive === true ? t('meta.session.live') : t('meta.session.bound'),
|
|
@@ -1366,6 +1450,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1366
1450
|
return h(
|
|
1367
1451
|
'div',
|
|
1368
1452
|
{
|
|
1453
|
+
ref: rootRef,
|
|
1369
1454
|
className: 'gt-item',
|
|
1370
1455
|
'data-status': todo.status,
|
|
1371
1456
|
'data-overdue': overdue ? '1' : '0',
|
|
@@ -1444,7 +1529,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1444
1529
|
title:
|
|
1445
1530
|
link.missing === true
|
|
1446
1531
|
? `${link.value} —— ${t('link.missing')}`
|
|
1447
|
-
: `${link.value}
|
|
1532
|
+
: `${link.value}${t('action.copyPathHint')}`,
|
|
1448
1533
|
onClick: () => copy(link.value),
|
|
1449
1534
|
},
|
|
1450
1535
|
h(Icon, { name: 'doc', size: 11 }),
|
|
@@ -1487,7 +1572,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1487
1572
|
key: `art:${index}:${link.value}`,
|
|
1488
1573
|
type: 'button',
|
|
1489
1574
|
className: 'gt-chip gt-chip-ok',
|
|
1490
|
-
title: `${link.value}
|
|
1575
|
+
title: `${link.value}${t('action.copyPathHint')}`,
|
|
1491
1576
|
onClick: () => copy(link.value),
|
|
1492
1577
|
},
|
|
1493
1578
|
h(Icon, { name: 'check', size: 11 }),
|
|
@@ -1531,6 +1616,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1531
1616
|
done
|
|
1532
1617
|
? { key: 'reopen', label: t('action.reopen'), icon: 'check', onSelect: () => onAction('reopen', todo) }
|
|
1533
1618
|
: { key: 'doing', label: t('action.doing'), icon: 'clock', onSelect: () => onAction('doing', todo) },
|
|
1619
|
+
todo.status === 'archived'
|
|
1620
|
+
? { key: 'unarchive', label: t('action.unarchive'), icon: 'check', onSelect: () => onAction('unarchive', todo) }
|
|
1621
|
+
: { key: 'archive', label: t('action.archive'), icon: 'doc', onSelect: () => onAction('archive', todo) },
|
|
1534
1622
|
{
|
|
1535
1623
|
key: 'remove',
|
|
1536
1624
|
label: confirming ? t('action.removeConfirm') : t('action.remove'),
|
|
@@ -1843,7 +1931,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1843
1931
|
const { ctx, lang } = props
|
|
1844
1932
|
const t = useCallback((key, params) => translate(lang, key, params), [lang])
|
|
1845
1933
|
|
|
1846
|
-
const [state, setState] = useState({ todos: [], counts: null, health: null })
|
|
1934
|
+
const [state, setState] = useState({ todos: [], counts: null, health: null, matched: 0, truncated: false })
|
|
1847
1935
|
const [loading, setLoading] = useState(true)
|
|
1848
1936
|
const [error, setError] = useState(null)
|
|
1849
1937
|
const [filter, setFilter] = useState('active')
|
|
@@ -1872,6 +1960,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1872
1960
|
// The hidden file input behind 导入: a page cannot open a file dialog by itself.
|
|
1873
1961
|
const importRef = useRef(null)
|
|
1874
1962
|
const [selected, setSelected] = useState({})
|
|
1963
|
+
// One tag field for the whole selection: typing a tag then pressing add applies it to
|
|
1964
|
+
// every picked row in one request, which is the batch half that was missing.
|
|
1965
|
+
const [bulkTag, setBulkTag] = useState('')
|
|
1875
1966
|
const [assignBusy, setAssignBusy] = useState(false)
|
|
1876
1967
|
const [assignAsk, setAssignAsk] = useState(null)
|
|
1877
1968
|
const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
|
|
@@ -2030,7 +2121,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2030
2121
|
// say so, rather than silently displaying a different conversation as selected.
|
|
2031
2122
|
const pinnedEntry =
|
|
2032
2123
|
targetValue !== '' && targetValue !== 'auto' && targetValue !== 'new' && !targetValue.startsWith('new:') && !listedIds.has(targetValue)
|
|
2033
|
-
? [{ id: targetValue, title: `${sessionTitles[targetValue] ?? targetValue}
|
|
2124
|
+
? [{ id: targetValue, title: `${sessionTitles[targetValue] ?? targetValue} · ${t('target.archived')}` }]
|
|
2034
2125
|
: []
|
|
2035
2126
|
const conversations = [...pinnedEntry, ...labelled]
|
|
2036
2127
|
const mounted = useRef(true)
|
|
@@ -2045,7 +2136,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2045
2136
|
try {
|
|
2046
2137
|
const data = await call('list', { query: { status, ...(search.trim() === '' ? {} : { query: search.trim() }) } })
|
|
2047
2138
|
if (!mounted.current) return
|
|
2048
|
-
setState((current) => ({
|
|
2139
|
+
setState((current) => ({
|
|
2140
|
+
...current,
|
|
2141
|
+
todos: data.todos,
|
|
2142
|
+
counts: data.counts,
|
|
2143
|
+
matched: Number.isInteger(data.matched) ? data.matched : data.todos.length,
|
|
2144
|
+
truncated: data.truncated === true,
|
|
2145
|
+
}))
|
|
2049
2146
|
setError(null)
|
|
2050
2147
|
} catch (failure) {
|
|
2051
2148
|
if (!mounted.current) return
|
|
@@ -2238,6 +2335,30 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2238
2335
|
// The two toggles belong with the other "how this panel behaves" controls, not in
|
|
2239
2336
|
// the title row.
|
|
2240
2337
|
const settingsOn = state.health?.settings?.autoCollect === true
|
|
2338
|
+
// The Host already knows why it stayed quiet; the switch's tooltip is the cheapest
|
|
2339
|
+
// place to show it, and "nothing ever happens" stops being a mystery.
|
|
2340
|
+
const autoCollectLast = state.health?.autoCollect?.lastDecision ?? null
|
|
2341
|
+
const autoCollectWaiting = Number(state.health?.autoCollect?.waiting) || 0
|
|
2342
|
+
const autoCollectDue = Number(state.health?.autoCollect?.due) || 0
|
|
2343
|
+
/** Turn a reason code into the sentence a person can read. */
|
|
2344
|
+
const reasonText = (code) => {
|
|
2345
|
+
const text = typeof code === 'string' ? code : ''
|
|
2346
|
+
const known = [
|
|
2347
|
+
'off',
|
|
2348
|
+
'no-session',
|
|
2349
|
+
'no-activity',
|
|
2350
|
+
'exempt-workspace',
|
|
2351
|
+
'unknown-dir',
|
|
2352
|
+
'retry-wait',
|
|
2353
|
+
'awaiting-human',
|
|
2354
|
+
'not-idle-yet',
|
|
2355
|
+
'cooldown',
|
|
2356
|
+
'ok',
|
|
2357
|
+
]
|
|
2358
|
+
if (known.includes(text)) return t(`autoCollect.reason.${text}`)
|
|
2359
|
+
if (text.startsWith('turn-')) return t('autoCollect.reason.turn', { kind: text.slice(5) })
|
|
2360
|
+
return text
|
|
2361
|
+
}
|
|
2241
2362
|
const togglesNodes = [
|
|
2242
2363
|
h(
|
|
2243
2364
|
'button',
|
|
@@ -2263,7 +2384,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2263
2384
|
className: 'gt-tab',
|
|
2264
2385
|
'data-on': settingsOn ? '1' : '0',
|
|
2265
2386
|
'aria-pressed': settingsOn,
|
|
2266
|
-
title:
|
|
2387
|
+
title:
|
|
2388
|
+
t('autoCollect.hint', { n: Number(state.health?.settings?.idleMinutes) || 30 })
|
|
2389
|
+
+ (autoCollectLast === null
|
|
2390
|
+
? ''
|
|
2391
|
+
: `\n${t('autoCollect.last', {
|
|
2392
|
+
reason: reasonText(autoCollectLast.reason),
|
|
2393
|
+
waiting: autoCollectWaiting,
|
|
2394
|
+
due: autoCollectDue,
|
|
2395
|
+
})}`),
|
|
2267
2396
|
onClick: () => void toggleAutoCollect(!settingsOn),
|
|
2268
2397
|
},
|
|
2269
2398
|
t('autoCollect.label'),
|
|
@@ -2271,7 +2400,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2271
2400
|
]
|
|
2272
2401
|
|
|
2273
2402
|
const autoCollectOn = settingsOn
|
|
2274
|
-
const
|
|
2403
|
+
const autoCollectIdle = Number(state.health?.settings?.idleMinutes) || 30
|
|
2275
2404
|
|
|
2276
2405
|
/** The switch is Host state: flipping it here writes through `/settings`. */
|
|
2277
2406
|
const toggleAutoCollect = async (next) => {
|
|
@@ -2291,7 +2420,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2291
2420
|
}))
|
|
2292
2421
|
setError(null)
|
|
2293
2422
|
setNotice({
|
|
2294
|
-
text: next ? t('notice.autoCollectOn', { n:
|
|
2423
|
+
text: next ? t('notice.autoCollectOn', { n: autoCollectIdle }) : t('notice.autoCollectOff'),
|
|
2295
2424
|
sessionId: '',
|
|
2296
2425
|
})
|
|
2297
2426
|
setTimeout(() => setNotice(null), 6000)
|
|
@@ -2514,6 +2643,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2514
2643
|
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
2515
2644
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2516
2645
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
2646
|
+
// Archive keeps the history without cluttering the list; delete stays a separate,
|
|
2647
|
+
// deliberate action reached only through the two-step menu item.
|
|
2648
|
+
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
2649
|
+
if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2517
2650
|
if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2518
2651
|
if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2519
2652
|
if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
@@ -2559,7 +2692,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2559
2692
|
return undefined
|
|
2560
2693
|
}
|
|
2561
2694
|
|
|
2562
|
-
|
|
2695
|
+
/** Apply one change to the whole selection; a stale id is reported, not fatal. */
|
|
2696
|
+
const runBulk = async (change) => {
|
|
2697
|
+
const ids = Object.keys(selected)
|
|
2698
|
+
if (ids.length === 0) return
|
|
2699
|
+
try {
|
|
2700
|
+
const data = await call('bulk', { method: 'POST', body: { ids, ...change } })
|
|
2701
|
+
const skipped = (data?.unmatched?.length ?? 0) + (data?.errors?.length ?? 0)
|
|
2702
|
+
setNotice({
|
|
2703
|
+
text:
|
|
2704
|
+
skipped > 0
|
|
2705
|
+
? t('notice.bulkPartial', { n: data?.updated ?? 0, m: skipped })
|
|
2706
|
+
: t('notice.bulk', { n: data?.updated ?? 0 }),
|
|
2707
|
+
sessionId: '',
|
|
2708
|
+
})
|
|
2709
|
+
setTimeout(() => setNotice(null), 6000)
|
|
2710
|
+
setSelected({})
|
|
2711
|
+
setError(null)
|
|
2712
|
+
void reload()
|
|
2713
|
+
void refreshHealth()
|
|
2714
|
+
} catch (failure) {
|
|
2715
|
+
reportFailure('bulk', { message: failure?.message })
|
|
2716
|
+
setError(failure)
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
2563
2721
|
const groups = useMemo(
|
|
2564
2722
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2565
2723
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -2670,7 +2828,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2670
2828
|
? h(
|
|
2671
2829
|
'div',
|
|
2672
2830
|
{ className: 'gt-banner', role: 'alert' },
|
|
2673
|
-
`${t('error.prefix')}
|
|
2831
|
+
`${t('error.prefix')}: ${error.message} (${error.reason ?? 'internal'})`,
|
|
2674
2832
|
h('button', { type: 'button', className: 'gt-btn', onClick: () => void reload() }, t('action.retry')),
|
|
2675
2833
|
)
|
|
2676
2834
|
: null,
|
|
@@ -2770,6 +2928,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2770
2928
|
['doing', counts.doing],
|
|
2771
2929
|
['done', counts.done],
|
|
2772
2930
|
['overdue', counts.overdue],
|
|
2931
|
+
// Only shown once something is archived: an empty filter that exists from
|
|
2932
|
+
// day one is furniture, not a control.
|
|
2933
|
+
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
2773
2934
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
2774
2935
|
),
|
|
2775
2936
|
h('span', { className: 'gt-divider' }),
|
|
@@ -2992,6 +3153,48 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2992
3153
|
},
|
|
2993
3154
|
assignBusy ? t('assign.working') : t('select.assign'),
|
|
2994
3155
|
),
|
|
3156
|
+
h(
|
|
3157
|
+
'button',
|
|
3158
|
+
{
|
|
3159
|
+
type: 'button',
|
|
3160
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3161
|
+
disabled: Object.keys(selected).length === 0,
|
|
3162
|
+
onClick: () => void runBulk({ status: 'done' }),
|
|
3163
|
+
},
|
|
3164
|
+
t('select.complete'),
|
|
3165
|
+
),
|
|
3166
|
+
h(
|
|
3167
|
+
'button',
|
|
3168
|
+
{
|
|
3169
|
+
type: 'button',
|
|
3170
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3171
|
+
disabled: Object.keys(selected).length === 0,
|
|
3172
|
+
onClick: () => void runBulk({ status: 'archived' }),
|
|
3173
|
+
},
|
|
3174
|
+
t('select.archive'),
|
|
3175
|
+
),
|
|
3176
|
+
h('input', {
|
|
3177
|
+
className: 'gt-input gt-bulk-tag',
|
|
3178
|
+
value: bulkTag,
|
|
3179
|
+
placeholder: t('field.bulkTag'),
|
|
3180
|
+
'aria-label': t('field.bulkTag'),
|
|
3181
|
+
onChange: (event) => setBulkTag(event.target.value),
|
|
3182
|
+
}),
|
|
3183
|
+
h(
|
|
3184
|
+
'button',
|
|
3185
|
+
{
|
|
3186
|
+
type: 'button',
|
|
3187
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3188
|
+
disabled: Object.keys(selected).length === 0 || bulkTag.trim() === '',
|
|
3189
|
+
onClick: () => {
|
|
3190
|
+
const tag = bulkTag.trim()
|
|
3191
|
+
if (tag === '') return
|
|
3192
|
+
void runBulk({ addTags: [tag] })
|
|
3193
|
+
setBulkTag('')
|
|
3194
|
+
},
|
|
3195
|
+
},
|
|
3196
|
+
t('select.addTag'),
|
|
3197
|
+
),
|
|
2995
3198
|
h(
|
|
2996
3199
|
'button',
|
|
2997
3200
|
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSelected({}) },
|
|
@@ -3043,7 +3246,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3043
3246
|
? h(
|
|
3044
3247
|
'div',
|
|
3045
3248
|
{ className: 'gt-empty' },
|
|
3046
|
-
h('div', { className: 'gt-empty-title' },
|
|
3249
|
+
h('div', { className: 'gt-empty-title' }, (state.counts?.total ?? 0) === 0 && query.trim() === '' ? t('empty.none') : t('empty.filtered')),
|
|
3047
3250
|
h('div', { className: 'gt-sub' }, t('hint.keys')),
|
|
3048
3251
|
)
|
|
3049
3252
|
: h(
|
|
@@ -3142,6 +3345,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3142
3345
|
}),
|
|
3143
3346
|
),
|
|
3144
3347
|
|
|
3348
|
+
state.truncated === true
|
|
3349
|
+
? h(
|
|
3350
|
+
'div',
|
|
3351
|
+
{ className: 'gt-sub gt-truncated' },
|
|
3352
|
+
t('list.truncated', { shown: state.todos.length, matched: state.matched }),
|
|
3353
|
+
)
|
|
3354
|
+
: null,
|
|
3355
|
+
|
|
3145
3356
|
h(
|
|
3146
3357
|
'div',
|
|
3147
3358
|
{ className: 'gt-foot' },
|
|
@@ -3259,6 +3470,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3259
3470
|
*/
|
|
3260
3471
|
function withLocale(ctx, Component) {
|
|
3261
3472
|
return function LocaleBound(props) {
|
|
3473
|
+
activeLang = resolveLang(ctx)
|
|
3262
3474
|
const [, bump] = useState(0)
|
|
3263
3475
|
useEffect(() => {
|
|
3264
3476
|
const locale = ctx.locale
|
|
@@ -3323,6 +3535,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3323
3535
|
const { error } = this.state
|
|
3324
3536
|
if (error !== null) {
|
|
3325
3537
|
const message = error?.message ?? String(error)
|
|
3538
|
+
const lang = this.props.componentProps?.lang ?? activeLang
|
|
3326
3539
|
return h(
|
|
3327
3540
|
'div',
|
|
3328
3541
|
{ className: 'gt-root' },
|
|
@@ -3332,8 +3545,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3332
3545
|
h(
|
|
3333
3546
|
'div',
|
|
3334
3547
|
{ className: 'gt-banner', role: 'alert' },
|
|
3335
|
-
|
|
3336
|
-
h('button', { type: 'button', className: 'gt-btn', onClick: this.retry }, '
|
|
3548
|
+
translate(lang, 'boundary.error', { message }),
|
|
3549
|
+
h('button', { type: 'button', className: 'gt-btn', onClick: this.retry }, translate(lang, 'action.retry')),
|
|
3337
3550
|
),
|
|
3338
3551
|
),
|
|
3339
3552
|
)
|
|
@@ -3345,7 +3558,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3345
3558
|
// ------------------------------------------------------------------ apply
|
|
3346
3559
|
|
|
3347
3560
|
return {
|
|
3348
|
-
inject: ['sessions', 'uiWorkspace', '
|
|
3561
|
+
inject: ['sessions', 'uiWorkspace', 'slots', 'locale'],
|
|
3349
3562
|
|
|
3350
3563
|
apply(ctx) {
|
|
3351
3564
|
ctx.effect(() => ctx.locale.register(LOCALE_NS, DICTS), `${LOCALE_NS}: dictionaries`)
|