dsh-global-todo 0.3.2 → 0.4.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 +84 -0
- package/CONTRIBUTING.md +14 -0
- package/README.md +25 -3
- package/README.zh.md +21 -3
- package/cordis.patch.yml +6 -0
- package/docs/DESIGN.md +30 -3
- package/docs/ROADMAP.md +50 -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 +134 -19
- package/lib/index.js +108 -24
- package/lib/routes.js +20 -0
- package/lib/service.js +16 -5
- package/lib/settings.js +91 -6
- package/lib/tools.js +13 -2
- 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 = 'ui24'
|
|
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
|
|
|
@@ -79,6 +81,7 @@ window.__ModuleLoader__.load({
|
|
|
79
81
|
'action.addLink': '挂上',
|
|
80
82
|
'action.open': '打开',
|
|
81
83
|
'action.copy': '复制路径',
|
|
84
|
+
'action.copyPathHint': '(点击复制路径)',
|
|
82
85
|
'action.copied': '已复制',
|
|
83
86
|
'action.undo': '撤销',
|
|
84
87
|
'action.export': '导出',
|
|
@@ -109,6 +112,18 @@ window.__ModuleLoader__.load({
|
|
|
109
112
|
'assign.failed': '{n} 条没分出去:{detail}',
|
|
110
113
|
'autoCollect.label': '自动获取',
|
|
111
114
|
'autoCollect.hint': '开启后:某个对话**安静满 {n} 分钟**(期间没有任何新发言)才自动问它一次「有没有要跟进的事」;你一边说话它一边计时重置,所以工作时不会被打扰。它问过之后,**只有你在那个对话里再说一句话**才会重新问——不会追着自己的提问反复问。关掉就完全靠你和 Agent 手动记。',
|
|
115
|
+
'autoCollect.last': '最近一次判定:{reason}(等待 {waiting} · 到点 {due})',
|
|
116
|
+
'autoCollect.reason.off': '开关关着',
|
|
117
|
+
'autoCollect.reason.no-session': '没有会话',
|
|
118
|
+
'autoCollect.reason.no-activity': '不知道安静了多久',
|
|
119
|
+
'autoCollect.reason.exempt-workspace': '该工作区已豁免',
|
|
120
|
+
'autoCollect.reason.unknown-dir': '不知道工作目录(保守不问)',
|
|
121
|
+
'autoCollect.reason.retry-wait': '上次失败,等重试',
|
|
122
|
+
'autoCollect.reason.awaiting-human': '问过,等你说话',
|
|
123
|
+
'autoCollect.reason.not-idle-yet': '还没安静够',
|
|
124
|
+
'autoCollect.reason.cooldown': '冷却中',
|
|
125
|
+
'autoCollect.reason.ok': '可以问',
|
|
126
|
+
'autoCollect.reason.turn': '轮次没有干净结束({kind})',
|
|
112
127
|
'notice.autoCollectOn': '已开启自动获取:一次人为发言最多自动问一次',
|
|
113
128
|
'notice.autoCollectOff': '已关闭自动获取',
|
|
114
129
|
'action.exempt': '豁免',
|
|
@@ -158,6 +173,7 @@ window.__ModuleLoader__.load({
|
|
|
158
173
|
'filter.doing': '进行中',
|
|
159
174
|
'filter.done': '已完成',
|
|
160
175
|
'filter.overdue': '逾期',
|
|
176
|
+
'filter.label': '筛选',
|
|
161
177
|
'field.title': '要做什么?回车即存',
|
|
162
178
|
'field.description': '描述:验收标准、已知的坑…',
|
|
163
179
|
'field.links': '相关文档与地址,一行一个(/绝对路径 或 https://…)',
|
|
@@ -176,7 +192,6 @@ window.__ModuleLoader__.load({
|
|
|
176
192
|
'group.time': '时间',
|
|
177
193
|
'group.level': '级别',
|
|
178
194
|
'group.manual': '手动添加',
|
|
179
|
-
'group.conversation': '对话',
|
|
180
195
|
'group.other': '其他',
|
|
181
196
|
'group.items': '{n} 条',
|
|
182
197
|
'group.thisConversation': '本对话',
|
|
@@ -199,6 +214,7 @@ window.__ModuleLoader__.load({
|
|
|
199
214
|
'meta.session.live': '会话在跑',
|
|
200
215
|
'meta.session.bound': '已绑定会话',
|
|
201
216
|
'meta.session.noDir': '会话无工作目录',
|
|
217
|
+
'meta.session.noDirHint': '这个会话没有工作目录,agent 在里面做不了事;用「派发:开新会话」重派一次',
|
|
202
218
|
'meta.dispatchFailed': '上次派发失败',
|
|
203
219
|
'detail.description': '描述',
|
|
204
220
|
'detail.noDescription': '没有描述。',
|
|
@@ -225,6 +241,8 @@ window.__ModuleLoader__.load({
|
|
|
225
241
|
'meta.completed': '完成于 {when}',
|
|
226
242
|
'detail.edit': '编辑',
|
|
227
243
|
'error.prefix': '出错了',
|
|
244
|
+
'error.notJson': 'HTTP {status}:响应不是 JSON',
|
|
245
|
+
'boundary.error': '全局待办面板出错了:{message}',
|
|
228
246
|
'counts.active': '未完成 {n}',
|
|
229
247
|
'counts.overdue': '逾期 {n}',
|
|
230
248
|
'counts.done': '已完成 {n}',
|
|
@@ -232,6 +250,7 @@ window.__ModuleLoader__.load({
|
|
|
232
250
|
'loading': '载入中…',
|
|
233
251
|
'deleted': '已删除「{title}」',
|
|
234
252
|
'hint.keys': '快捷键:/ 搜索 · n 新建 · Esc 收起',
|
|
253
|
+
'list.truncated': '只显示前 {shown} 条,共 {matched} 条;用搜索或筛选缩小范围',
|
|
235
254
|
'justNow': '刚刚',
|
|
236
255
|
'minutesAgo': '{n} 分钟前',
|
|
237
256
|
'hoursAgo': '{n} 小时前',
|
|
@@ -263,6 +282,7 @@ window.__ModuleLoader__.load({
|
|
|
263
282
|
'action.addLink': 'Attach',
|
|
264
283
|
'action.open': 'Open',
|
|
265
284
|
'action.copy': 'Copy path',
|
|
285
|
+
'action.copyPathHint': ' (click to copy path)',
|
|
266
286
|
'action.copied': 'Copied',
|
|
267
287
|
'action.undo': 'Undo',
|
|
268
288
|
'action.export': 'Export',
|
|
@@ -293,6 +313,18 @@ window.__ModuleLoader__.load({
|
|
|
293
313
|
'assign.failed': '{n} could not be assigned: {detail}',
|
|
294
314
|
'autoCollect.label': 'Auto-collect',
|
|
295
315
|
'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.',
|
|
316
|
+
'autoCollect.last': 'Last decision: {reason} (waiting {waiting}, due {due})',
|
|
317
|
+
'autoCollect.reason.off': 'the switch is off',
|
|
318
|
+
'autoCollect.reason.no-session': 'no conversation',
|
|
319
|
+
'autoCollect.reason.no-activity': 'the quiet time is unknown',
|
|
320
|
+
'autoCollect.reason.exempt-workspace': 'that workspace is exempt',
|
|
321
|
+
'autoCollect.reason.unknown-dir': 'the working directory is unknown (stays quiet)',
|
|
322
|
+
'autoCollect.reason.retry-wait': 'the last attempt failed; waiting to retry',
|
|
323
|
+
'autoCollect.reason.awaiting-human': 'asked; waiting for you to speak',
|
|
324
|
+
'autoCollect.reason.not-idle-yet': 'not quiet for long enough yet',
|
|
325
|
+
'autoCollect.reason.cooldown': 'cooling down',
|
|
326
|
+
'autoCollect.reason.ok': 'ready to ask',
|
|
327
|
+
'autoCollect.reason.turn': 'the turn did not end cleanly ({kind})',
|
|
296
328
|
'notice.autoCollectOn': 'Auto-collect on: at most one question per message from you',
|
|
297
329
|
'notice.autoCollectOff': 'Auto-collect off',
|
|
298
330
|
'action.exempt': 'Exempt',
|
|
@@ -336,7 +368,7 @@ window.__ModuleLoader__.load({
|
|
|
336
368
|
'dir.fromSession': '(from this conversation)',
|
|
337
369
|
'dir.picked': '(chosen)',
|
|
338
370
|
'meta.origin': 'from {dir}',
|
|
339
|
-
'meta.completed': '
|
|
371
|
+
'meta.completed': 'Completed {when}',
|
|
340
372
|
'action.save': 'Save',
|
|
341
373
|
'action.cancel': 'Cancel',
|
|
342
374
|
'filter.all': 'All',
|
|
@@ -344,6 +376,7 @@ window.__ModuleLoader__.load({
|
|
|
344
376
|
'filter.doing': 'Doing',
|
|
345
377
|
'filter.done': 'Done',
|
|
346
378
|
'filter.overdue': 'Overdue',
|
|
379
|
+
'filter.label': 'Filters',
|
|
347
380
|
'field.title': 'What needs doing? Enter to save',
|
|
348
381
|
'field.description': 'Description: acceptance, known traps…',
|
|
349
382
|
'field.links': 'Documents and links, one per line (/abs/path or https://…)',
|
|
@@ -383,6 +416,7 @@ window.__ModuleLoader__.load({
|
|
|
383
416
|
'meta.session.live': 'session running',
|
|
384
417
|
'meta.session.bound': 'session bound',
|
|
385
418
|
'meta.session.noDir': 'session has no directory',
|
|
419
|
+
'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
420
|
'meta.dispatchFailed': 'last hand-off failed',
|
|
387
421
|
'detail.description': 'Description',
|
|
388
422
|
'detail.noDescription': 'No description.',
|
|
@@ -392,6 +426,8 @@ window.__ModuleLoader__.load({
|
|
|
392
426
|
'detail.lastDispatch': 'Last hand-off',
|
|
393
427
|
'detail.edit': 'Edit',
|
|
394
428
|
'error.prefix': 'Error',
|
|
429
|
+
'error.notJson': 'HTTP {status}: the response was not JSON',
|
|
430
|
+
'boundary.error': 'The todo panel crashed: {message}',
|
|
395
431
|
'counts.active': 'Open {n}',
|
|
396
432
|
'counts.overdue': 'Overdue {n}',
|
|
397
433
|
'counts.done': 'Done {n}',
|
|
@@ -399,10 +435,27 @@ window.__ModuleLoader__.load({
|
|
|
399
435
|
'loading': 'Loading…',
|
|
400
436
|
'deleted': 'Deleted “{title}”',
|
|
401
437
|
'hint.keys': 'Keys: / search · n new · Esc collapse',
|
|
438
|
+
'list.truncated': 'Showing the first {shown} of {matched}; narrow the search or filter to see the rest',
|
|
402
439
|
'justNow': 'just now',
|
|
403
440
|
'minutesAgo': '{n} min ago',
|
|
404
441
|
'hoursAgo': '{n} h ago',
|
|
405
442
|
'daysAgo': '{n} d ago',
|
|
443
|
+
'group.conversation': 'Conversation',
|
|
444
|
+
'detail.completion': 'Completion',
|
|
445
|
+
'detail.notes': 'Progress notes',
|
|
446
|
+
'note.placeholder': 'Add a note: where this stands, what is blocking it, who needs to act',
|
|
447
|
+
'note.add': 'Add note',
|
|
448
|
+
'note.by.agent': 'Agent',
|
|
449
|
+
'note.by.human': 'You',
|
|
450
|
+
'note.none': 'No notes yet.',
|
|
451
|
+
'note.count': '{n} notes',
|
|
452
|
+
'note.latest': 'Latest: {text}',
|
|
453
|
+
'link.missing': 'File is gone (moved or deleted)',
|
|
454
|
+
'search.hint': 'Supports tag:release / is:overdue / has:link / level:p0, space-separated',
|
|
455
|
+
'completion.by.agent': 'Finished by the agent',
|
|
456
|
+
'completion.by.human': 'Finished by you',
|
|
457
|
+
'completion.auto': 'Recorded automatically when the session finished',
|
|
458
|
+
'completion.none': 'No closing summary',
|
|
406
459
|
}
|
|
407
460
|
|
|
408
461
|
const DICTS = { zh, en }
|
|
@@ -429,7 +482,10 @@ window.__ModuleLoader__.load({
|
|
|
429
482
|
function resolveLang(ctx) {
|
|
430
483
|
try {
|
|
431
484
|
const snapshot = ctx.locale?.getLocale?.() ?? ctx.locale?.getSnapshot?.()
|
|
432
|
-
|
|
485
|
+
// LocaleSnapshot is { active, locales, revision }. The older guess looked for
|
|
486
|
+
// locale/id/language/current, so switching the language in settings never reached
|
|
487
|
+
// this panel; keep the fallbacks for a host that still answers another shape.
|
|
488
|
+
const raw = snapshot?.active ?? snapshot?.locale ?? snapshot?.id ?? snapshot?.language ?? snapshot?.current ?? ''
|
|
433
489
|
const text = typeof raw === 'string' ? raw.toLowerCase() : ''
|
|
434
490
|
if (text.startsWith('zh')) return 'zh'
|
|
435
491
|
if (text.startsWith('en')) return 'en'
|
|
@@ -651,7 +707,7 @@ window.__ModuleLoader__.load({
|
|
|
651
707
|
payload = await response.json()
|
|
652
708
|
} catch {
|
|
653
709
|
reportFailure('call', { status: response.status, reason: 'not-json', message: path })
|
|
654
|
-
throw new ApiError('internal',
|
|
710
|
+
throw new ApiError('internal', translate(activeLang, 'error.notJson', { status: response.status }))
|
|
655
711
|
}
|
|
656
712
|
if (payload?.ok === true) return payload.data
|
|
657
713
|
reportFailure('call', {
|
|
@@ -1263,6 +1319,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1263
1319
|
const onPick = typeof props.onPick === 'function' ? props.onPick : () => {}
|
|
1264
1320
|
// The note box belongs to the row that is open, so its draft lives here.
|
|
1265
1321
|
const [noteDraft, setNoteDraft] = useState('')
|
|
1322
|
+
// Opening a row near the bottom of a long list put the editor below the
|
|
1323
|
+
// viewport; the click looked like it did nothing. Bring the row into view
|
|
1324
|
+
// when it opens, without yanking the page when it closes.
|
|
1325
|
+
const rootRef = useRef(null)
|
|
1266
1326
|
const [linkValue, setLinkValue] = useState('')
|
|
1267
1327
|
const [linkTitle, setLinkTitle] = useState('')
|
|
1268
1328
|
const [draft, setDraft] = useState({
|
|
@@ -1282,6 +1342,12 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1282
1342
|
})
|
|
1283
1343
|
}, [todo.id, todo.updatedAt, todo.title, todo.description, todo.dueAt, todo.priority])
|
|
1284
1344
|
|
|
1345
|
+
useEffect(() => {
|
|
1346
|
+
if (!open) return
|
|
1347
|
+
const node = rootRef.current
|
|
1348
|
+
if (node !== null && typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'nearest' })
|
|
1349
|
+
}, [open])
|
|
1350
|
+
|
|
1285
1351
|
const dirty =
|
|
1286
1352
|
draft.title !== todo.title ||
|
|
1287
1353
|
draft.description !== todo.description ||
|
|
@@ -1340,7 +1406,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1340
1406
|
key: 'session',
|
|
1341
1407
|
className: stranded ? 'gt-chip-warn' : todo.sessionAlive === true ? 'gt-chip-ok' : '',
|
|
1342
1408
|
title: stranded
|
|
1343
|
-
? '
|
|
1409
|
+
? t('meta.session.noDirHint')
|
|
1344
1410
|
: (todo.sessionDir ?? ''),
|
|
1345
1411
|
},
|
|
1346
1412
|
stranded ? t('meta.session.noDir') : todo.sessionAlive === true ? t('meta.session.live') : t('meta.session.bound'),
|
|
@@ -1366,6 +1432,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1366
1432
|
return h(
|
|
1367
1433
|
'div',
|
|
1368
1434
|
{
|
|
1435
|
+
ref: rootRef,
|
|
1369
1436
|
className: 'gt-item',
|
|
1370
1437
|
'data-status': todo.status,
|
|
1371
1438
|
'data-overdue': overdue ? '1' : '0',
|
|
@@ -1444,7 +1511,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1444
1511
|
title:
|
|
1445
1512
|
link.missing === true
|
|
1446
1513
|
? `${link.value} —— ${t('link.missing')}`
|
|
1447
|
-
: `${link.value}
|
|
1514
|
+
: `${link.value}${t('action.copyPathHint')}`,
|
|
1448
1515
|
onClick: () => copy(link.value),
|
|
1449
1516
|
},
|
|
1450
1517
|
h(Icon, { name: 'doc', size: 11 }),
|
|
@@ -1487,7 +1554,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1487
1554
|
key: `art:${index}:${link.value}`,
|
|
1488
1555
|
type: 'button',
|
|
1489
1556
|
className: 'gt-chip gt-chip-ok',
|
|
1490
|
-
title: `${link.value}
|
|
1557
|
+
title: `${link.value}${t('action.copyPathHint')}`,
|
|
1491
1558
|
onClick: () => copy(link.value),
|
|
1492
1559
|
},
|
|
1493
1560
|
h(Icon, { name: 'check', size: 11 }),
|
|
@@ -1843,7 +1910,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1843
1910
|
const { ctx, lang } = props
|
|
1844
1911
|
const t = useCallback((key, params) => translate(lang, key, params), [lang])
|
|
1845
1912
|
|
|
1846
|
-
const [state, setState] = useState({ todos: [], counts: null, health: null })
|
|
1913
|
+
const [state, setState] = useState({ todos: [], counts: null, health: null, matched: 0, truncated: false })
|
|
1847
1914
|
const [loading, setLoading] = useState(true)
|
|
1848
1915
|
const [error, setError] = useState(null)
|
|
1849
1916
|
const [filter, setFilter] = useState('active')
|
|
@@ -2030,7 +2097,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2030
2097
|
// say so, rather than silently displaying a different conversation as selected.
|
|
2031
2098
|
const pinnedEntry =
|
|
2032
2099
|
targetValue !== '' && targetValue !== 'auto' && targetValue !== 'new' && !targetValue.startsWith('new:') && !listedIds.has(targetValue)
|
|
2033
|
-
? [{ id: targetValue, title: `${sessionTitles[targetValue] ?? targetValue}
|
|
2100
|
+
? [{ id: targetValue, title: `${sessionTitles[targetValue] ?? targetValue} · ${t('target.archived')}` }]
|
|
2034
2101
|
: []
|
|
2035
2102
|
const conversations = [...pinnedEntry, ...labelled]
|
|
2036
2103
|
const mounted = useRef(true)
|
|
@@ -2045,7 +2112,13 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2045
2112
|
try {
|
|
2046
2113
|
const data = await call('list', { query: { status, ...(search.trim() === '' ? {} : { query: search.trim() }) } })
|
|
2047
2114
|
if (!mounted.current) return
|
|
2048
|
-
setState((current) => ({
|
|
2115
|
+
setState((current) => ({
|
|
2116
|
+
...current,
|
|
2117
|
+
todos: data.todos,
|
|
2118
|
+
counts: data.counts,
|
|
2119
|
+
matched: Number.isInteger(data.matched) ? data.matched : data.todos.length,
|
|
2120
|
+
truncated: data.truncated === true,
|
|
2121
|
+
}))
|
|
2049
2122
|
setError(null)
|
|
2050
2123
|
} catch (failure) {
|
|
2051
2124
|
if (!mounted.current) return
|
|
@@ -2238,6 +2311,30 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2238
2311
|
// The two toggles belong with the other "how this panel behaves" controls, not in
|
|
2239
2312
|
// the title row.
|
|
2240
2313
|
const settingsOn = state.health?.settings?.autoCollect === true
|
|
2314
|
+
// The Host already knows why it stayed quiet; the switch's tooltip is the cheapest
|
|
2315
|
+
// place to show it, and "nothing ever happens" stops being a mystery.
|
|
2316
|
+
const autoCollectLast = state.health?.autoCollect?.lastDecision ?? null
|
|
2317
|
+
const autoCollectWaiting = Number(state.health?.autoCollect?.waiting) || 0
|
|
2318
|
+
const autoCollectDue = Number(state.health?.autoCollect?.due) || 0
|
|
2319
|
+
/** Turn a reason code into the sentence a person can read. */
|
|
2320
|
+
const reasonText = (code) => {
|
|
2321
|
+
const text = typeof code === 'string' ? code : ''
|
|
2322
|
+
const known = [
|
|
2323
|
+
'off',
|
|
2324
|
+
'no-session',
|
|
2325
|
+
'no-activity',
|
|
2326
|
+
'exempt-workspace',
|
|
2327
|
+
'unknown-dir',
|
|
2328
|
+
'retry-wait',
|
|
2329
|
+
'awaiting-human',
|
|
2330
|
+
'not-idle-yet',
|
|
2331
|
+
'cooldown',
|
|
2332
|
+
'ok',
|
|
2333
|
+
]
|
|
2334
|
+
if (known.includes(text)) return t(`autoCollect.reason.${text}`)
|
|
2335
|
+
if (text.startsWith('turn-')) return t('autoCollect.reason.turn', { kind: text.slice(5) })
|
|
2336
|
+
return text
|
|
2337
|
+
}
|
|
2241
2338
|
const togglesNodes = [
|
|
2242
2339
|
h(
|
|
2243
2340
|
'button',
|
|
@@ -2263,7 +2360,15 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2263
2360
|
className: 'gt-tab',
|
|
2264
2361
|
'data-on': settingsOn ? '1' : '0',
|
|
2265
2362
|
'aria-pressed': settingsOn,
|
|
2266
|
-
title:
|
|
2363
|
+
title:
|
|
2364
|
+
t('autoCollect.hint', { n: Number(state.health?.settings?.idleMinutes) || 30 })
|
|
2365
|
+
+ (autoCollectLast === null
|
|
2366
|
+
? ''
|
|
2367
|
+
: `\n${t('autoCollect.last', {
|
|
2368
|
+
reason: reasonText(autoCollectLast.reason),
|
|
2369
|
+
waiting: autoCollectWaiting,
|
|
2370
|
+
due: autoCollectDue,
|
|
2371
|
+
})}`),
|
|
2267
2372
|
onClick: () => void toggleAutoCollect(!settingsOn),
|
|
2268
2373
|
},
|
|
2269
2374
|
t('autoCollect.label'),
|
|
@@ -2271,7 +2376,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2271
2376
|
]
|
|
2272
2377
|
|
|
2273
2378
|
const autoCollectOn = settingsOn
|
|
2274
|
-
const
|
|
2379
|
+
const autoCollectIdle = Number(state.health?.settings?.idleMinutes) || 30
|
|
2275
2380
|
|
|
2276
2381
|
/** The switch is Host state: flipping it here writes through `/settings`. */
|
|
2277
2382
|
const toggleAutoCollect = async (next) => {
|
|
@@ -2291,7 +2396,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2291
2396
|
}))
|
|
2292
2397
|
setError(null)
|
|
2293
2398
|
setNotice({
|
|
2294
|
-
text: next ? t('notice.autoCollectOn', { n:
|
|
2399
|
+
text: next ? t('notice.autoCollectOn', { n: autoCollectIdle }) : t('notice.autoCollectOff'),
|
|
2295
2400
|
sessionId: '',
|
|
2296
2401
|
})
|
|
2297
2402
|
setTimeout(() => setNotice(null), 6000)
|
|
@@ -2670,7 +2775,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2670
2775
|
? h(
|
|
2671
2776
|
'div',
|
|
2672
2777
|
{ className: 'gt-banner', role: 'alert' },
|
|
2673
|
-
`${t('error.prefix')}
|
|
2778
|
+
`${t('error.prefix')}: ${error.message} (${error.reason ?? 'internal'})`,
|
|
2674
2779
|
h('button', { type: 'button', className: 'gt-btn', onClick: () => void reload() }, t('action.retry')),
|
|
2675
2780
|
)
|
|
2676
2781
|
: null,
|
|
@@ -3043,7 +3148,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3043
3148
|
? h(
|
|
3044
3149
|
'div',
|
|
3045
3150
|
{ className: 'gt-empty' },
|
|
3046
|
-
h('div', { className: 'gt-empty-title' },
|
|
3151
|
+
h('div', { className: 'gt-empty-title' }, (state.counts?.total ?? 0) === 0 && query.trim() === '' ? t('empty.none') : t('empty.filtered')),
|
|
3047
3152
|
h('div', { className: 'gt-sub' }, t('hint.keys')),
|
|
3048
3153
|
)
|
|
3049
3154
|
: h(
|
|
@@ -3142,6 +3247,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3142
3247
|
}),
|
|
3143
3248
|
),
|
|
3144
3249
|
|
|
3250
|
+
state.truncated === true
|
|
3251
|
+
? h(
|
|
3252
|
+
'div',
|
|
3253
|
+
{ className: 'gt-sub gt-truncated' },
|
|
3254
|
+
t('list.truncated', { shown: state.todos.length, matched: state.matched }),
|
|
3255
|
+
)
|
|
3256
|
+
: null,
|
|
3257
|
+
|
|
3145
3258
|
h(
|
|
3146
3259
|
'div',
|
|
3147
3260
|
{ className: 'gt-foot' },
|
|
@@ -3259,6 +3372,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3259
3372
|
*/
|
|
3260
3373
|
function withLocale(ctx, Component) {
|
|
3261
3374
|
return function LocaleBound(props) {
|
|
3375
|
+
activeLang = resolveLang(ctx)
|
|
3262
3376
|
const [, bump] = useState(0)
|
|
3263
3377
|
useEffect(() => {
|
|
3264
3378
|
const locale = ctx.locale
|
|
@@ -3323,6 +3437,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3323
3437
|
const { error } = this.state
|
|
3324
3438
|
if (error !== null) {
|
|
3325
3439
|
const message = error?.message ?? String(error)
|
|
3440
|
+
const lang = this.props.componentProps?.lang ?? activeLang
|
|
3326
3441
|
return h(
|
|
3327
3442
|
'div',
|
|
3328
3443
|
{ className: 'gt-root' },
|
|
@@ -3332,8 +3447,8 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3332
3447
|
h(
|
|
3333
3448
|
'div',
|
|
3334
3449
|
{ className: 'gt-banner', role: 'alert' },
|
|
3335
|
-
|
|
3336
|
-
h('button', { type: 'button', className: 'gt-btn', onClick: this.retry }, '
|
|
3450
|
+
translate(lang, 'boundary.error', { message }),
|
|
3451
|
+
h('button', { type: 'button', className: 'gt-btn', onClick: this.retry }, translate(lang, 'action.retry')),
|
|
3337
3452
|
),
|
|
3338
3453
|
),
|
|
3339
3454
|
)
|
|
@@ -3345,7 +3460,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3345
3460
|
// ------------------------------------------------------------------ apply
|
|
3346
3461
|
|
|
3347
3462
|
return {
|
|
3348
|
-
inject: ['sessions', 'uiWorkspace', '
|
|
3463
|
+
inject: ['sessions', 'uiWorkspace', 'slots', 'locale'],
|
|
3349
3464
|
|
|
3350
3465
|
apply(ctx) {
|
|
3351
3466
|
ctx.effect(() => ctx.locale.register(LOCALE_NS, DICTS), `${LOCALE_NS}: dictionaries`)
|
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import path from 'node:path'
|
|
|
25
25
|
|
|
26
26
|
import { createCompletionWatcher } from './completion.js'
|
|
27
27
|
import { createDispatcher } from './dispatch.js'
|
|
28
|
-
import { createSettings,
|
|
28
|
+
import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
|
|
29
29
|
import { createDiagnostics } from './diagnostics.js'
|
|
30
30
|
import { createMetrics } from './metrics.js'
|
|
31
31
|
import { LIMITS } from './model.js'
|
|
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
35
35
|
import { createTools } from './tools.js'
|
|
36
36
|
|
|
37
37
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
38
|
-
export const VERSION = '0.
|
|
38
|
+
export const VERSION = '0.4.0'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
|
@@ -47,6 +47,10 @@ export const DEFAULTS = Object.freeze({
|
|
|
47
47
|
activityLimit: LIMITS.activity,
|
|
48
48
|
requireExistingFiles: true,
|
|
49
49
|
guidance: true,
|
|
50
|
+
// Starting point for the built-in folder browser when the shell has no picker.
|
|
51
|
+
// Documented here because a patch override replaces `config` wholesale and has
|
|
52
|
+
// to restate every key it wants to keep.
|
|
53
|
+
browseRoot: '',
|
|
50
54
|
})
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -169,6 +173,35 @@ export function apply(ctx, config) {
|
|
|
169
173
|
// clock, so a session in use is never asked.
|
|
170
174
|
const collectPending = {}
|
|
171
175
|
const collectActivity = {}
|
|
176
|
+
// Sessions whose question failed: when the next attempt is allowed, and how many
|
|
177
|
+
// attempts have failed. Two maps because the decision layer takes timestamps only.
|
|
178
|
+
const collectRetry = {}
|
|
179
|
+
const collectAttempts = {}
|
|
180
|
+
const ASK_TIMEOUT_MS = 10_000
|
|
181
|
+
const ASK_MAX_ATTEMPTS = 3
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Bound a promise the plugin does not control.
|
|
185
|
+
*
|
|
186
|
+
* `requestCapture` may wait on `agents.resume`, and a resume that never settles left
|
|
187
|
+
* `asking` true forever — which silences the feature for every conversation, not just
|
|
188
|
+
* the broken one.
|
|
189
|
+
*/
|
|
190
|
+
const withinMs = (promise, ms) =>
|
|
191
|
+
new Promise((resolve, reject) => {
|
|
192
|
+
const timer = setTimeout(() => reject(new Error(`等待 ${Math.round(ms / 1000)} 秒没有结果`)), ms)
|
|
193
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
194
|
+
promise.then(
|
|
195
|
+
(value) => {
|
|
196
|
+
clearTimeout(timer)
|
|
197
|
+
resolve(value)
|
|
198
|
+
},
|
|
199
|
+
(error) => {
|
|
200
|
+
clearTimeout(timer)
|
|
201
|
+
reject(error)
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
})
|
|
172
205
|
// When a *person* last wrote in each conversation. The question we send is a message in
|
|
173
206
|
// the conversation too, and re-arming on it is exactly the loop this exists to break — so
|
|
174
207
|
// only `source.kind === 'user'` counts as somebody having answered.
|
|
@@ -177,24 +210,46 @@ export function apply(ctx, config) {
|
|
|
177
210
|
// The live lookup below is only a fallback for a conversation that has not been seen yet.
|
|
178
211
|
const collectDir = {}
|
|
179
212
|
|
|
180
|
-
const askSession = (sessionId) => {
|
|
213
|
+
const askSession = async (sessionId) => {
|
|
214
|
+
if (autoCollectState.asking === true) return
|
|
181
215
|
autoCollectState.asking = true
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
autoCollectState.stats.asked += 1
|
|
217
|
+
try {
|
|
218
|
+
// Book first: a question that fails must not be repeated in a burst. The booking is
|
|
219
|
+
// released below when the question did not arrive, because a failure is not an answer.
|
|
220
|
+
await settings.rememberAsked(sessionId, Date.now())
|
|
221
|
+
const result = await withinMs(dispatcher.requestCapture({ sessionId, authoritative: true }), ASK_TIMEOUT_MS)
|
|
222
|
+
autoCollectState.lastAsk = { at: Date.now(), sessionId: result?.sessionId ?? sessionId }
|
|
223
|
+
autoCollectState.error = null
|
|
224
|
+
autoCollectState.stats.succeeded += 1
|
|
225
|
+
autoCollectState.lastRetry = null
|
|
226
|
+
delete collectRetry[sessionId]
|
|
227
|
+
delete collectAttempts[sessionId]
|
|
228
|
+
delete collectPending[sessionId]
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = String(error?.message ?? error)
|
|
231
|
+
const attempts = (collectAttempts[sessionId] ?? 0) + 1
|
|
232
|
+
autoCollectState.error = message
|
|
233
|
+
autoCollectState.stats.failed += 1
|
|
234
|
+
// A failure is not an answer: leaving the booking behind made `awaiting-human` keep
|
|
235
|
+
// the conversation quiet until a person happened to speak again.
|
|
236
|
+
await settings.forgetAsked(sessionId).catch(() => {})
|
|
237
|
+
if (attempts >= ASK_MAX_ATTEMPTS) {
|
|
238
|
+
delete collectRetry[sessionId]
|
|
239
|
+
delete collectAttempts[sessionId]
|
|
196
240
|
delete collectPending[sessionId]
|
|
197
|
-
|
|
241
|
+
autoCollectState.stats.dropped += 1
|
|
242
|
+
autoCollectState.lastDrop = { at: Date.now(), sessionId, attempts, error: message }
|
|
243
|
+
} else {
|
|
244
|
+
const at = Date.now()
|
|
245
|
+
collectAttempts[sessionId] = attempts
|
|
246
|
+
collectRetry[sessionId] = at + retryDelayMs(attempts)
|
|
247
|
+
autoCollectState.lastRetry = { at, sessionId, attempts, nextAt: collectRetry[sessionId] }
|
|
248
|
+
}
|
|
249
|
+
logger?.warn?.(`dsh-global-todo: 自动抓取失败(${message}),第 ${attempts} 次`)
|
|
250
|
+
} finally {
|
|
251
|
+
autoCollectState.asking = false
|
|
252
|
+
}
|
|
198
253
|
}
|
|
199
254
|
|
|
200
255
|
ctx.effect(() => {
|
|
@@ -266,10 +321,14 @@ export function apply(ctx, config) {
|
|
|
266
321
|
// With the switch off there is nothing to ask for either, so the candidates are
|
|
267
322
|
// dropped rather than left to fire in a burst the moment it is switched on.
|
|
268
323
|
if (current.autoCollect !== true) {
|
|
269
|
-
for (const sessionId of idle)
|
|
324
|
+
for (const sessionId of idle) {
|
|
325
|
+
delete collectPending[sessionId]
|
|
326
|
+
// A retry belongs to a candidate; with the switch off there is nothing to retry.
|
|
327
|
+
delete collectRetry[sessionId]
|
|
328
|
+
delete collectAttempts[sessionId]
|
|
329
|
+
}
|
|
270
330
|
}
|
|
271
331
|
autoCollectState.waiting = Object.keys(collectPending).length
|
|
272
|
-
if (current.autoCollect !== true || autoCollectState.asking === true) return
|
|
273
332
|
// An exemption is keyed on the working directory. What the conversation's own events
|
|
274
333
|
// reported wins, because an unloaded conversation is the normal state of one that has
|
|
275
334
|
// gone quiet; the agents service is only the fallback, and it answers '' for a session
|
|
@@ -278,18 +337,30 @@ export function apply(ctx, config) {
|
|
|
278
337
|
for (const sessionId of Object.keys(collectPending)) {
|
|
279
338
|
dirAt[sessionId] = collectDir[sessionId] ?? dispatcher.sessionDir(sessionId)
|
|
280
339
|
}
|
|
281
|
-
const
|
|
340
|
+
const decisions = explainAutoCollect({
|
|
282
341
|
pending: collectPending,
|
|
283
342
|
activityAt: collectActivity,
|
|
284
343
|
humanAt: collectHuman,
|
|
285
344
|
dirAt,
|
|
345
|
+
retryAt: collectRetry,
|
|
286
346
|
settings: current,
|
|
287
347
|
recentSessionId: recentSession.id,
|
|
288
|
-
now
|
|
348
|
+
now,
|
|
289
349
|
})
|
|
350
|
+
const due = decisions.filter((decision) => decision.ask)
|
|
290
351
|
autoCollectState.waiting = Object.keys(collectPending).length
|
|
352
|
+
autoCollectState.due = due.length
|
|
353
|
+
// The reason used to be computed and dropped on the floor; keep the list so /health
|
|
354
|
+
// can answer "why was this conversation not asked?".
|
|
355
|
+
autoCollectState.decisions = decisions.slice(0, 20)
|
|
356
|
+
const shown = due[0] ?? decisions[0]
|
|
357
|
+
autoCollectState.lastDecision =
|
|
358
|
+
shown === undefined
|
|
359
|
+
? null
|
|
360
|
+
: { at: now, sessionId: shown.sessionId, ask: shown.ask === true, reason: shown.reason, idleMs: shown.idleMs }
|
|
361
|
+
if (current.autoCollect !== true || autoCollectState.asking === true) return
|
|
291
362
|
if (due.length === 0) return
|
|
292
|
-
askSession(due[0].sessionId)
|
|
363
|
+
void askSession(due[0].sessionId)
|
|
293
364
|
} catch (error) {
|
|
294
365
|
autoCollectState.error = String(error?.message ?? error)
|
|
295
366
|
}
|
|
@@ -322,7 +393,20 @@ export function apply(ctx, config) {
|
|
|
322
393
|
const diagnostics = createDiagnostics({ file: path.join(path.dirname(file), 'client-errors.log'), logger })
|
|
323
394
|
const settingsFile = path.join(path.dirname(file), 'settings.json')
|
|
324
395
|
const settings = createSettings({ file: settingsFile, logger })
|
|
325
|
-
const autoCollectState = {
|
|
396
|
+
const autoCollectState = {
|
|
397
|
+
enabled: false,
|
|
398
|
+
asking: false,
|
|
399
|
+
lastAsk: null,
|
|
400
|
+
error: null,
|
|
401
|
+
waiting: 0,
|
|
402
|
+
due: 0,
|
|
403
|
+
decisions: [],
|
|
404
|
+
lastDecision: null,
|
|
405
|
+
lastRetry: null,
|
|
406
|
+
lastDrop: null,
|
|
407
|
+
// In-memory on purpose: it describes this process, not the list. Restarting resets it.
|
|
408
|
+
stats: { asked: 0, succeeded: 0, failed: 0, dropped: 0 },
|
|
409
|
+
}
|
|
326
410
|
ctx.effect(() => {
|
|
327
411
|
void settings.load().then((loaded) => {
|
|
328
412
|
autoCollectState.enabled = loaded.autoCollect === true
|
package/lib/routes.js
CHANGED
|
@@ -358,6 +358,10 @@ export function createRoutes(deps) {
|
|
|
358
358
|
// Counted once: the size feeds the metrics snapshot and the counts the panel
|
|
359
359
|
// reads are the same numbers, so /health cannot contradict itself.
|
|
360
360
|
const counts = await service.stats()
|
|
361
|
+
// Where the automatic-collect switch came from. `false` because nobody turned it on
|
|
362
|
+
// and `false` because the file is unreadable look identical from the panel, and the
|
|
363
|
+
// second one used to be invisible everywhere.
|
|
364
|
+
const settingsInfo = deps.settings?.info?.() ?? { file: '', at: 0, source: 'default', error: null }
|
|
361
365
|
return {
|
|
362
366
|
plugin: 'dsh-global-todo',
|
|
363
367
|
version,
|
|
@@ -403,6 +407,11 @@ export function createRoutes(deps) {
|
|
|
403
407
|
// The exempted workspaces, so the panel can show which conversations are out of
|
|
404
408
|
// scope instead of leaving the person to guess why nothing was ever asked.
|
|
405
409
|
exemptWorkspaces: deps.settings?.get?.().exemptWorkspaces ?? [],
|
|
410
|
+
// Reported so "off" can be told apart from "the file did not load".
|
|
411
|
+
file: settingsInfo.file,
|
|
412
|
+
loadedAt: settingsInfo.at,
|
|
413
|
+
loadSource: settingsInfo.source,
|
|
414
|
+
loadError: settingsInfo.error,
|
|
406
415
|
},
|
|
407
416
|
autoCollect: {
|
|
408
417
|
// Derived from the setting, not from a copy kept elsewhere: two sources for one
|
|
@@ -411,6 +420,17 @@ export function createRoutes(deps) {
|
|
|
411
420
|
asking: deps.autoCollect?.asking === true,
|
|
412
421
|
lastAsk: deps.autoCollect?.lastAsk ?? null,
|
|
413
422
|
error: deps.autoCollect?.error ?? null,
|
|
423
|
+
// Why a conversation was or was not asked. The decision used to keep the reason
|
|
424
|
+
// in a local and drop it, so this question had no answer even in the Host.
|
|
425
|
+
waiting: deps.autoCollect?.waiting ?? 0,
|
|
426
|
+
due: deps.autoCollect?.due ?? 0,
|
|
427
|
+
lastDecision: deps.autoCollect?.lastDecision ?? null,
|
|
428
|
+
decisions: deps.autoCollect?.decisions ?? [],
|
|
429
|
+
stats: deps.autoCollect?.stats ?? { asked: 0, succeeded: 0, failed: 0, dropped: 0 },
|
|
430
|
+
// Set after a failed question, so a retry in flight is visible rather than
|
|
431
|
+
// looking like the feature silently stopped.
|
|
432
|
+
lastRetry: deps.autoCollect?.lastRetry ?? null,
|
|
433
|
+
lastDrop: deps.autoCollect?.lastDrop ?? null,
|
|
414
434
|
},
|
|
415
435
|
// 「交给 Agent」 resolves its target from the conversation the user last
|
|
416
436
|
// wrote in, so whether that feed attached is worth reporting too.
|