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/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 = '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': '完成于 {when}',
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
- const raw = snapshot?.locale ?? snapshot?.id ?? snapshot?.language ?? snapshot?.current ?? ''
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', `HTTP ${response.status}(响应不是 JSON)`)
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
- ? '这个会话没有工作目录,agent 在里面做不了事;用「派发:开新会话」重派一次'
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}(${t('target.archived')})` }]
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) => ({ ...current, todos: data.todos, counts: data.counts }))
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: t('autoCollect.hint', { n: Number(state.health?.settings?.cooldownMinutes) || 30 }),
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 autoCollectCooldown = Number(state.health?.settings?.cooldownMinutes) || 30
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: autoCollectCooldown }) : t('notice.autoCollectOff'),
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
- const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, overdue: 0 }
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')}:${error.message}(${error.reason ?? 'internal'})`,
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' }, filter === 'active' ? t('empty.none') : t('empty.filtered')),
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
- `全局待办面板出错了:${message}`,
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', 'workspaces', 'slots', 'locale'],
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`)