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/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 = 'ui23'
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': '完成于 {when}',
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
- const raw = snapshot?.locale ?? snapshot?.id ?? snapshot?.language ?? snapshot?.current ?? ''
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', `HTTP ${response.status}(响应不是 JSON)`)
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
- ? '这个会话没有工作目录,agent 在里面做不了事;用「派发:开新会话」重派一次'
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}(${t('target.archived')})` }]
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) => ({ ...current, todos: data.todos, counts: data.counts }))
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: t('autoCollect.hint', { n: Number(state.health?.settings?.cooldownMinutes) || 30 }),
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 autoCollectCooldown = Number(state.health?.settings?.cooldownMinutes) || 30
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: autoCollectCooldown }) : t('notice.autoCollectOff'),
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')}:${error.message}(${error.reason ?? 'internal'})`,
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' }, filter === 'active' ? t('empty.none') : t('empty.filtered')),
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
- `全局待办面板出错了:${message}`,
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', 'workspaces', 'slots', 'locale'],
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, dueAutoCollect, idleSessions } from './settings.js'
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.3.2'
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
- // Book the cooldown *before* asking: a failed question must not loop.
183
- void settings
184
- .rememberAsked(sessionId, Date.now())
185
- .then(() => dispatcher.requestCapture({ sessionId, authoritative: true }))
186
- .then((result) => {
187
- autoCollectState.lastAsk = { at: Date.now(), sessionId: result?.sessionId ?? sessionId }
188
- autoCollectState.error = null
189
- })
190
- .catch((error) => {
191
- autoCollectState.error = String(error?.message ?? error)
192
- logger?.warn?.(`dsh-global-todo: 自动抓取失败(${autoCollectState.error})`)
193
- })
194
- .finally(() => {
195
- autoCollectState.asking = false
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) delete collectPending[sessionId]
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 due = dueAutoCollect({
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: Date.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 = { enabled: false, asking: false, lastAsk: null, error: null }
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.