dsh-taskboard 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +27 -160
  2. package/lib/client.js +2564 -678
  3. package/lib/host/execution.js +3 -0
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/routes.js +31 -1
  6. package/lib/host/routes.js.map +1 -1
  7. package/lib/host/session-sync.js +449 -0
  8. package/lib/host/session-sync.js.map +1 -0
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +109 -2
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +27 -1
  15. package/lib/shared/protocol.js.map +1 -1
  16. package/package.json +75 -75
  17. package/src/client/api.ts +8 -0
  18. package/src/client/board/AlertModal.tsx +3 -1
  19. package/src/client/board/ImportModal.tsx +26 -24
  20. package/src/client/board/SettingsModal.tsx +98 -22
  21. package/src/client/board/SlashPromptInput.tsx +272 -0
  22. package/src/client/board/TaskBoard.tsx +53 -49
  23. package/src/client/board/TaskCard.tsx +33 -21
  24. package/src/client/board/TaskDetail.tsx +169 -104
  25. package/src/client/board/TaskFormModal.tsx +254 -202
  26. package/src/client/board/TemplateManager.tsx +32 -29
  27. package/src/client/board/labels.ts +36 -27
  28. package/src/client/controller.ts +62 -2
  29. package/src/client/i18n/en.ts +455 -0
  30. package/src/client/i18n/runtime.ts +155 -0
  31. package/src/client/i18n/zh.ts +460 -0
  32. package/src/client/index.ts +182 -42
  33. package/src/client/sidebar-entry.ts +13 -3
  34. package/src/client/styles.ts +131 -0
  35. package/src/host/execution.ts +14 -1
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +650 -0
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +125 -1
  40. package/src/shared/api.ts +49 -0
  41. package/src/shared/protocol.ts +54 -0
  42. package/src/shared/version.ts +1 -1
@@ -13,7 +13,8 @@ import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
13
  import { canTransition, checklistProgress } from '../../shared/protocol.ts'
14
14
  import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './format.ts'
16
- import { MOVE_LABEL, OUTCOME_LABEL, STATUS_LABEL, URGENCY_LABEL } from './labels.ts'
16
+ import { MOVE_KEYS, OUTCOME_KEYS, STATUS_KEYS, URGENCY_KEYS } from './labels.ts'
17
+ import { useT } from '../i18n/runtime.ts'
17
18
 
18
19
  /** Statuses a user may move this task to, per the state machine. */
19
20
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -41,6 +42,62 @@ function Chip({ icon, children, tone, title }: { icon?: string; children: ReactN
41
42
  return <span className="dsh-atb-chip2" data-tone={tone} title={title}>{icon !== undefined && <span className="dsh-atb-chip2-icon">{icon}</span>}{children}</span>
42
43
  }
43
44
 
45
+ /** Render markdown text with embedded clickable images and lightbox preview. */
46
+ function MarkdownContent({ text }: { text: string }) {
47
+ const t = useT()
48
+ const [lightboxUrl, setLightboxUrl] = useState<string | null>(null)
49
+ const regex = /!\[(.*?)\]\(((?:data:image\/[^)]+)|(?:https?:\/\/[^)]+)|(?:[^)]+\.(?:png|jpg|jpeg|gif|webp|svg)))\)/gi
50
+ const parts: ReactNode[] = []
51
+ let lastIndex = 0
52
+ let match: RegExpExecArray | null
53
+ let count = 0
54
+
55
+ while ((match = regex.exec(text)) !== null) {
56
+ if (match.index > lastIndex) {
57
+ parts.push(<span key={`txt-${lastIndex}`}>{text.slice(lastIndex, match.index)}</span>)
58
+ }
59
+ const alt = match[1] || t('md.imageAlt', { n: ++count })
60
+ const url = match[2] ?? ''
61
+ parts.push(
62
+ <div key={`img-${match.index}`} className="dsh-atb-detail-img-wrap">
63
+ <img
64
+ src={url}
65
+ alt={alt}
66
+ className="dsh-atb-detail-img"
67
+ onClick={() => setLightboxUrl(url)}
68
+ title={t('md.imageTitle', { alt })}
69
+ />
70
+ <span className="dsh-atb-detail-img-caption">{alt}</span>
71
+ </div>,
72
+ )
73
+ lastIndex = regex.lastIndex
74
+ }
75
+ if (lastIndex < text.length) {
76
+ parts.push(<span key={`txt-${lastIndex}`}>{text.slice(lastIndex)}</span>)
77
+ }
78
+
79
+ return (
80
+ <>
81
+ <div className="dsh-atb-markdown-body">{parts}</div>
82
+ {lightboxUrl !== null && (
83
+ <div className="dsh-atb-lightbox-backdrop" onClick={() => setLightboxUrl(null)}>
84
+ <div className="dsh-atb-lightbox-content" onClick={e => e.stopPropagation()}>
85
+ <img src={lightboxUrl} alt={t('md.lightboxAlt')} className="dsh-atb-lightbox-img" />
86
+ <button
87
+ type="button"
88
+ className="dsh-atb-lightbox-close"
89
+ title={t('md.closePreview')}
90
+ onClick={() => setLightboxUrl(null)}
91
+ >
92
+
93
+ </button>
94
+ </div>
95
+ </div>
96
+ )}
97
+ </>
98
+ )
99
+ }
100
+
44
101
  /** The most recent execution carrying isolation facts, newest first. */
45
102
  function latestIsolated(task: TaskRecord): ExecutionRecord | undefined {
46
103
  return [...task.executions].reverse().find(e => e.isolation !== undefined || e.worktreePath !== undefined || e.isolationNote !== undefined)
@@ -71,6 +128,7 @@ function DiffView({ controller, task, execution, commit, path }: {
71
128
  commit?: string
72
129
  path?: string
73
130
  }) {
131
+ const t = useT()
74
132
  const [state, setState] = useState<{ loading: boolean; diff?: string; truncated?: boolean; failed?: boolean }>({ loading: true })
75
133
  useEffect(() => {
76
134
  let alive = true
@@ -85,12 +143,12 @@ function DiffView({ controller, task, execution, commit, path }: {
85
143
  return (
86
144
  <div className="dsh-atb-diffview">
87
145
  <div className="dsh-atb-diffview-head">
88
- <span className="dsh-atb-diffview-title">{commit !== undefined ? `提交 ${shortHash(commit)}` : `文件 ${path}`}</span>
89
- {state.loading && <span className="dsh-atb-diffview-hint">读取中…</span>}
90
- {state.truncated === true && <span className="dsh-atb-diffview-hint">⚠ 内容过长已截断</span>}
146
+ <span className="dsh-atb-diffview-title">{commit !== undefined ? t('diff.commit', { hash: shortHash(commit) }) : t('diff.file', { path: path ?? '' })}</span>
147
+ {state.loading && <span className="dsh-atb-diffview-hint">{t('shared.loading')}</span>}
148
+ {state.truncated === true && <span className="dsh-atb-diffview-hint">{t('diff.truncated')}</span>}
91
149
  </div>
92
150
  {state.failed === true
93
- ? <div className="dsh-atb-diffview-error">获取失败(原因见看板顶部错误条;对象可能已随 worktree 删除丢失)</div>
151
+ ? <div className="dsh-atb-diffview-error">{t('diff.failed')}</div>
94
152
  : <pre className="dsh-atb-diffview-pre">{state.diff ?? ''}</pre>}
95
153
  </div>
96
154
  )
@@ -101,6 +159,7 @@ function DiffView({ controller, task, execution, commit, path }: {
101
159
  * per row; unchecked items highlight while the task sits in in_review.
102
160
  */
103
161
  function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: BoardController }) {
162
+ const t = useT()
104
163
  const items = task.checklist ?? []
105
164
  if (items.length === 0) return null
106
165
  const { done, total } = checklistProgress(task)
@@ -109,9 +168,9 @@ function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: Bo
109
168
  return (
110
169
  <div className="dsh-atb-fieldcard" data-kind="checklist">
111
170
  <div className="dsh-atb-fieldcard-label">
112
- 验收清单(DoD)
171
+ {t('checklist.title')}
113
172
  <span className="dsh-atb-cl-progress" data-tone={reviewing && unchecked > 0 ? 'bad' : undefined}>
114
- ☑ {done}/{total}{reviewing && unchecked > 0 ? ` · ${unchecked} 项未完成` : done === total ? ' · 全部完成' : ''}
173
+ ☑ {done}/{total}{reviewing && unchecked > 0 ? t('checklist.unchecked', { n: unchecked }) : done === total ? t('checklist.allDone') : ''}
115
174
  </span>
116
175
  </div>
117
176
  <div className="dsh-atb-cl-items">
@@ -130,9 +189,9 @@ function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: Bo
130
189
  <span className="dsh-atb-cl-text">{item.text}</span>
131
190
  <span className="dsh-atb-cl-meta">
132
191
  {item.checked
133
- ? `${item.checkedBy === 'user' ? '👤 用户' : `🤖 ${shortId(item.checkedBy)}`} · ${fmtTime(item.checkedAt)}`
134
- : '未完成'}
135
- {item.note !== undefined && item.note.length > 0 && <span className="dsh-atb-cl-note" title={item.note}>证据:{item.note}</span>}
192
+ ? `${item.checkedBy === 'user' ? t('checklist.byUser') : `🤖 ${shortId(item.checkedBy)}`} · ${fmtTime(item.checkedAt)}`
193
+ : t('checklist.uncheckedItem')}
194
+ {item.note !== undefined && item.note.length > 0 && <span className="dsh-atb-cl-note" title={item.note}>{t('checklist.evidence', { note: item.note })}</span>}
136
195
  </span>
137
196
  </label>
138
197
  ))}
@@ -146,6 +205,7 @@ function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: Bo
146
205
  * carries one, rendered section by section for the reviewer.
147
206
  */
148
207
  function ReportBlock({ task }: { task: TaskRecord }) {
208
+ const t = useT()
149
209
  const execution = [...task.executions].reverse().find(e => e.report !== undefined)
150
210
  const report = execution?.report
151
211
  if (execution === undefined || report === undefined) return null
@@ -159,14 +219,14 @@ function ReportBlock({ task }: { task: TaskRecord }) {
159
219
  : null
160
220
  return (
161
221
  <div className="dsh-atb-fieldcard" data-kind="report">
162
- <div className="dsh-atb-fieldcard-label">执行报告<span className="dsh-atb-cl-progress">由执行会话提交 · {fmtTime(execution.endedAt ?? execution.startedAt)}</span></div>
222
+ <div className="dsh-atb-fieldcard-label">{t('report.title')}<span className="dsh-atb-cl-progress">{t('report.submitted', { time: fmtTime(execution.endedAt ?? execution.startedAt) })}</span></div>
163
223
  <div className="dsh-atb-rpt-summary">{report.summary}</div>
164
- {section('改动文件', report.changedFiles)}
165
- {section('自验情况', report.checks)}
166
- {section('产物', report.artifacts)}
224
+ {section(t('report.changedFiles'), report.changedFiles)}
225
+ {section(t('report.checks'), report.checks)}
226
+ {section(t('report.artifacts'), report.artifacts)}
167
227
  {report.risk.length > 0 && (
168
228
  <div className="dsh-atb-rpt-sec">
169
- <div className="dsh-atb-rpt-label">剩余风险</div>
229
+ <div className="dsh-atb-rpt-label">{t('report.risk')}</div>
170
230
  <div className="dsh-atb-rpt-risk">{report.risk}</div>
171
231
  </div>
172
232
  )}
@@ -180,6 +240,7 @@ function ReportBlock({ task }: { task: TaskRecord }) {
180
240
  * remove worktree — plan §3.3).
181
241
  */
182
242
  function IsolationBlock({ task, controller }: { task: TaskRecord; controller: BoardController }) {
243
+ const t = useT()
183
244
  const { alert: showAlert, el: alertEl } = useAlert()
184
245
  const [confirmMerge, setConfirmMerge] = useState(false)
185
246
  const [confirmRemove, setConfirmRemove] = useState<'wt' | 'wtb' | null>(null)
@@ -196,8 +257,8 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
196
257
  void controller.mergeBranch(task.id).then(result => {
197
258
  setBusy(false)
198
259
  setConfirmMerge(false)
199
- if (!result.ok) showAlert(`合并失败:${result.error}`)
200
- else if (result.noop === true) showAlert('该分支没有领先主工作区的新提交,无需合并(可退回续跑或直接清理)')
260
+ if (!result.ok) showAlert(t('iso.merge.failed', { error: result.error }))
261
+ else if (result.noop === true) showAlert(t('iso.merge.noop'))
201
262
  })
202
263
  }
203
264
 
@@ -206,8 +267,8 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
206
267
  void controller.removeWorktree(task.id, deleteBranch).then(result => {
207
268
  setBusy(false)
208
269
  setConfirmRemove(null)
209
- if (!result.ok) showAlert(`删除失败:${result.error}`)
210
- else if (result.branchError !== undefined) showAlert(`worktree 已删除,但分支删除失败:${result.branchError}`)
270
+ if (!result.ok) showAlert(t('iso.remove.failed', { error: result.error }))
271
+ else if (result.branchError !== undefined) showAlert(t('iso.remove.branchFailed', { error: result.branchError }))
211
272
  })
212
273
  }
213
274
 
@@ -215,8 +276,8 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
215
276
  if (execution.isolation !== 'worktree' || execution.worktreePath === undefined) {
216
277
  return (
217
278
  <div className="dsh-atb-fieldcard" data-kind="isolation">
218
- <div className="dsh-atb-fieldcard-label">执行隔离</div>
219
- <div className="dsh-atb-iso-none">📁 原目录执行{execution.isolationNote !== undefined ? ` · ${execution.isolationNote}` : ''}</div>
279
+ <div className="dsh-atb-fieldcard-label">{t('iso.title')}</div>
280
+ <div className="dsh-atb-iso-none">{t('iso.none')}{execution.isolationNote !== undefined ? ` · ${execution.isolationNote}` : ''}</div>
220
281
  {alertEl}
221
282
  </div>
222
283
  )
@@ -229,12 +290,12 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
229
290
 
230
291
  return (
231
292
  <div className="dsh-atb-fieldcard" data-kind="isolation">
232
- <div className="dsh-atb-fieldcard-label">执行隔离 · Worktree</div>
293
+ <div className="dsh-atb-fieldcard-label">{t('iso.worktreeTitle')}</div>
233
294
  <div className="dsh-atb-iso-facts">
234
- <span className="dsh-atb-iso-fact" title={execution.worktreePath}>🌿 分支 <b>{execution.branch ?? task.branch}</b></span>
235
- <span className="dsh-atb-iso-fact">基线 {shortHash(execution.baseCommit)} {shortHash(execution.headCommit)}</span>
295
+ <span className="dsh-atb-iso-fact" title={execution.worktreePath}>{t('iso.branch')} <b>{execution.branch ?? task.branch}</b></span>
296
+ <span className="dsh-atb-iso-fact">{t('iso.baseline', { base: shortHash(execution.baseCommit), head: shortHash(execution.headCommit) })}</span>
236
297
  {execution.changedFiles !== undefined && execution.changedFiles > 0 && (
237
- <span className="dsh-atb-iso-fact">改动 {execution.changedFiles} 个文件</span>
298
+ <span className="dsh-atb-iso-fact">{t('iso.changed', { n: execution.changedFiles })}</span>
238
299
  )}
239
300
  {execution.diffStat !== undefined && <span className="dsh-atb-iso-fact" title={execution.diffStat}>{execution.diffStat}</span>}
240
301
  </div>
@@ -247,7 +308,7 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
247
308
  <button
248
309
  type="button"
249
310
  className="dsh-atb-iso-commit-btn"
250
- title="点击展开该提交的 diff"
311
+ title={t('iso.commit.openTitle')}
251
312
  onClick={() => setOpenDiff(openDiff?.commit === c.hash ? null : { commit: c.hash })}
252
313
  >
253
314
  <code>{shortHash(c.hash)}</code>
@@ -258,15 +319,15 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
258
319
  )}
259
320
  </div>
260
321
  ))}
261
- {commitTotal > 10 && <div className="dsh-atb-iso-more">… {commitTotal} 个提交</div>}
322
+ {commitTotal > 10 && <div className="dsh-atb-iso-more">{t('iso.commits.more', { n: commitTotal })}</div>}
262
323
  </div>
263
324
  )
264
- : <div className="dsh-atb-iso-nocommit">该次执行没有产生提交(改动可能未提交,见下方警告)</div>}
325
+ : <div className="dsh-atb-iso-nocommit">{t('iso.nocommit')}</div>}
265
326
 
266
327
  {dirtyTotal > 0 && (
267
328
  <div className="dsh-atb-iso-dirty">
268
329
  <button type="button" className="dsh-atb-iso-dirty-toggle" onClick={() => setDirtyOpen(!dirtyOpen)}>
269
- {dirtyTotal} 处未提交修改(合并前请让 agent 提交,或手动处理){dirtyOpen ? '' : ' ▼ 查看文件'}
330
+ {t('iso.dirty.toggle', { n: dirtyTotal })}{dirtyOpen ? t('iso.dirty.collapse') : t('iso.dirty.expand')}
270
331
  </button>
271
332
  {dirtyOpen && (
272
333
  <div className="dsh-atb-iso-dirty-files">
@@ -277,14 +338,14 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
277
338
  key={`${line}-${index}`}
278
339
  type="button"
279
340
  className="dsh-atb-iso-dirty-file"
280
- title="点击查看该文件的未提交 diff"
341
+ title={t('iso.dirty.openTitle')}
281
342
  onClick={() => setOpenDiff(openDiff?.path === filePath ? null : { path: filePath })}
282
343
  >
283
344
  <code>{line.slice(0, 2)}</code> {filePath}
284
345
  </button>
285
346
  )
286
347
  })}
287
- {dirtyTotal > 30 && <div className="dsh-atb-iso-more">… {dirtyTotal} 处(完整列表见任务台账)</div>}
348
+ {dirtyTotal > 30 && <div className="dsh-atb-iso-more">{t('iso.dirty.more', { n: dirtyTotal })}</div>}
288
349
  </div>
289
350
  )}
290
351
  {openDiff?.path !== undefined && dirtyOpen && (
@@ -295,13 +356,13 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
295
356
 
296
357
  <div className="dsh-atb-iso-actions">
297
358
  {running
298
- ? <span className="dsh-atb-iso-hint">执行中 — 结束后可合并或清理</span>
359
+ ? <span className="dsh-atb-iso-hint">{t('iso.hint.running')}</span>
299
360
  : confirmMerge
300
361
  ? (
301
362
  <span className="dsh-atb-confirm">
302
- <span className="dsh-atb-confirm-label">将分支以 --no-ff 合并到主工作区?</span>
303
- <button type="button" className="dsh-atb-btn" data-primary="true" disabled={busy} onClick={doMerge}>确认合并</button>
304
- <button type="button" className="dsh-atb-btn" onClick={() => setConfirmMerge(false)}>取消</button>
363
+ <span className="dsh-atb-confirm-label">{t('iso.merge.confirm')}</span>
364
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={busy} onClick={doMerge}>{t('iso.merge.go')}</button>
365
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmMerge(false)}>{t('shared.cancel')}</button>
305
366
  </span>
306
367
  )
307
368
  : (
@@ -309,10 +370,10 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
309
370
  type="button"
310
371
  className="dsh-atb-btn"
311
372
  disabled={busy}
312
- title="在主工作区 git merge --no-ff 该任务分支(要求主区干净;冲突会原样报告)"
373
+ title={t('iso.merge.title')}
313
374
  onClick={() => setConfirmMerge(true)}
314
375
  >
315
- ⇥ 合并到主工作区
376
+ {t('iso.merge.button')}
316
377
  </button>
317
378
  )}
318
379
  {!running && (confirmRemove === null
@@ -323,10 +384,10 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
323
384
  className="dsh-atb-btn"
324
385
  data-danger="true"
325
386
  disabled={busy}
326
- title="git worktree remove(有未提交修改时拒绝)"
387
+ title={t('iso.remove.wtTitle')}
327
388
  onClick={() => setConfirmRemove('wt')}
328
389
  >
329
- 🗑 删除 worktree
390
+ {t('iso.remove.wt')}
330
391
  </button>
331
392
  {task.branch !== undefined && (
332
393
  <button
@@ -334,22 +395,22 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
334
395
  className="dsh-atb-btn"
335
396
  data-danger="true"
336
397
  disabled={busy}
337
- title="删除 worktree 并删除任务分支(有未提交修改时拒绝)"
398
+ title={t('iso.remove.wtbTitle')}
338
399
  onClick={() => setConfirmRemove('wtb')}
339
400
  >
340
- 🗑 删 worktree + 分支
401
+ {t('iso.remove.wtb')}
341
402
  </button>
342
403
  )}
343
404
  </>
344
405
  )
345
406
  : (
346
407
  <span className="dsh-atb-confirm">
347
- <span className="dsh-atb-confirm-label">{confirmRemove === 'wtb' ? '删除 worktree 并删除分支?' : '删除 worktree 目录?'}</span>
348
- <button type="button" className="dsh-atb-btn" data-danger="true" disabled={busy} onClick={() => doRemove(confirmRemove === 'wtb')}>确认删除</button>
349
- <button type="button" className="dsh-atb-btn" onClick={() => setConfirmRemove(null)}>取消</button>
408
+ <span className="dsh-atb-confirm-label">{confirmRemove === 'wtb' ? t('iso.remove.confirmWtb') : t('iso.remove.confirmWt')}</span>
409
+ <button type="button" className="dsh-atb-btn" data-danger="true" disabled={busy} onClick={() => doRemove(confirmRemove === 'wtb')}>{t('shared.confirmDelete')}</button>
410
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmRemove(null)}>{t('shared.cancel')}</button>
350
411
  </span>
351
412
  ))}
352
- {!running && confirmRemove === null && !confirmMerge && <span className="dsh-atb-iso-hint">分支与 worktree 保留中 — 可退回继续修改</span>}
413
+ {!running && confirmRemove === null && !confirmMerge && <span className="dsh-atb-iso-hint">{t('iso.hint.keep')}</span>}
353
414
  </div>
354
415
  {alertEl}
355
416
  </div>
@@ -363,6 +424,7 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
363
424
  * @param now - current epoch ms (stale-claim highlight).
364
425
  */
365
426
  export function TaskDetail({ task, controller, now }: { task: TaskRecord; controller: BoardController; now?: number }) {
427
+ const t = useT()
366
428
  const [comment, setComment] = useState('')
367
429
  const [confirmDone, setConfirmDone] = useState(false)
368
430
  const [confirmPurge, setConfirmPurge] = useState(false)
@@ -391,9 +453,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
391
453
  /** Jump to an execution's session; prompt precisely when it cannot open. */
392
454
  const jumpToSession = (sessionId: string): void => {
393
455
  void controller.openSession(sessionId).then(result => {
394
- if (result === 'missing') showAlert(`该会话已被删除(${shortId(sessionId)}),无法打开`)
395
- else if (result === 'archived') showAlert(`该会话已归档(${shortId(sessionId)}),已从会话列表隐藏`)
396
- else if (result === 'unavailable') showAlert(`会话导航不可用,会话 ID:${sessionId}`)
456
+ if (result === 'missing') showAlert(t('card.session.missing', { id: shortId(sessionId) }))
457
+ else if (result === 'archived') showAlert(t('card.session.archived', { id: shortId(sessionId) }))
458
+ else if (result === 'unavailable') showAlert(t('card.session.unavailable', { id: sessionId }))
397
459
  })
398
460
  }
399
461
 
@@ -403,50 +465,53 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
403
465
  <div className="dsh-atb-detail-titlewrap">
404
466
  <div className="dsh-atb-detail-titlebar">
405
467
  <h3>{task.title}</h3>
406
- <span className="dsh-atb-statuspill" data-status={task.status}>{STATUS_LABEL[task.status] ?? task.status}</span>
468
+ <span className="dsh-atb-statuspill" data-status={task.status}>{t(STATUS_KEYS[task.status] ?? task.status)}</span>
407
469
  </div>
408
470
  <div className="dsh-atb-detail-chips">
409
- <Chip tone={task.urgency}>● {URGENCY_LABEL[task.urgency] ?? task.urgency}</Chip>
471
+ <Chip tone={task.urgency}>● {t(URGENCY_KEYS[task.urgency] ?? task.urgency)}</Chip>
410
472
  <Chip icon="📁">{ws?.title ?? shortId(task.workspaceId)}</Chip>
411
473
  {task.model !== undefined && (
412
474
  <Chip
413
475
  icon="✦"
414
- title={`固定模型: ${task.model.provider}/${task.model.model}${task.model.reasoningEffort !== undefined ? ` · 思考强度: ${task.model.reasoningEffort}` : ''}`}
476
+ title={t('card.badge.modelTitle', { model: task.model.provider + '/' + task.model.model }) + (task.model.reasoningEffort !== undefined ? t('card.badge.modelEffort', { effort: task.model.reasoningEffort }) : '')}
415
477
  >
416
478
  {task.model.model}{task.model.reasoningEffort !== undefined ? ` · ${task.model.reasoningEffort}` : ''}
417
479
  </Chip>
418
480
  )}
419
481
  {task.presetId !== undefined && <Chip icon="🎛" >{task.presetId}</Chip>}
420
482
  {task.execution.mode === 'scheduled' && (
421
- <Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
483
+ <Chip icon="⏰">{t('detail.chip.nextRun', { cron: task.execution.cron ?? '', time: fmtTime(task.execution.nextRunAt) })}</Chip>
422
484
  )}
423
- {task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
485
+ {task.blocked && <Chip icon="⛔" tone="urgent">{t('shared.blocked')}</Chip>}
424
486
  {task.checklist !== undefined && task.checklist.length > 0 && (
425
487
  <Chip icon="☑" tone={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? 'urgent' : undefined}>
426
- 清单 {checklistProgress(task).done}/{task.checklist.length}
488
+ {t('detail.chip.checklist', { done: checklistProgress(task).done, total: task.checklist.length })}
427
489
  </Chip>
428
490
  )}
429
491
  {task.branch !== undefined && (
430
492
  <Chip icon="🌿" tone={undefined}>Worktree · {task.branch.length > 28 ? `${task.branch.slice(0, 28)}…` : task.branch}</Chip>
431
493
  )}
432
- {(task.isolation === undefined || task.isolation === 'worktree') && task.branch === undefined && <Chip icon="🌿">Worktree 隔离</Chip>}
494
+ {(task.isolation === undefined || task.isolation === 'worktree') && task.branch === undefined && <Chip icon="🌿">{t('detail.chip.isolated')}</Chip>}
495
+ {task.permission === 'read-only' && <Chip icon="🔒" tone="urgent">{t('detail.chip.permReadOnly')}</Chip>}
496
+ {task.permission === 'danger-full-access' && <Chip icon="⚡" tone="urgent">{t('detail.chip.permFull')}</Chip>}
497
+ {(task.permission === 'workspace-write' || task.permission === undefined) && <Chip icon="📁">{t('detail.chip.permWrite')}</Chip>}
433
498
  {holder !== undefined && (
434
499
  <button
435
500
  type="button"
436
501
  className="dsh-atb-chip2 dsh-atb-chip-btn"
437
502
  data-tone={stale ? 'urgent' : undefined}
438
- title={`点击跳转至该会话:${holder}`}
503
+ title={t('detail.chip.holderTitle', { id: holder })}
439
504
  onClick={() => jumpToSession(holder)}
440
505
  >
441
506
  <span className="dsh-atb-chip2-icon">{stale ? '⏱' : '🤖'}</span>
442
- {stale ? '认领超时 · ' : ''}{shortId(holder)} 持有 ↗
507
+ {stale ? t('detail.chip.holderStale') : t('detail.chip.holderBy')}{shortId(holder)}{t('detail.chip.holderSuffix')}
443
508
  </button>
444
509
  )}
445
- {task.trashedAt !== undefined && <Chip icon="🗑" tone="urgent">已删除待清除</Chip>}
510
+ {task.trashedAt !== undefined && <Chip icon="🗑" tone="urgent">{t('detail.chip.trashed')}</Chip>}
446
511
  <Chip>v{task.version}</Chip>
447
512
  </div>
448
513
  <div className="dsh-atb-detail-sub">
449
- 更新 {fmtTime(task.updatedAt)} · 最近操作 {task.updatedBy.kind === 'agent' ? `🤖 ${shortId(task.updatedBy.sessionId)}` : task.updatedBy.kind === 'system' ? '⚙️ 系统' : '👤 用户'}
514
+ {t('detail.sub.line', { time: fmtTime(task.updatedAt), who: task.updatedBy.kind === 'agent' ? `🤖 ${shortId(task.updatedBy.sessionId)}` : task.updatedBy.kind === 'system' ? t('detail.updatedBy.system') : t('detail.updatedBy.user') })}
450
515
  </div>
451
516
  </div>
452
517
  <div className="dsh-atb-detail-topbtns">
@@ -454,62 +519,62 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
454
519
  <button
455
520
  type="button"
456
521
  className="dsh-atb-detail-session"
457
- title={`一键跳转到对应会话:${targetSessionId}`}
522
+ title={t('detail.session.jumpTitle', { id: targetSessionId })}
458
523
  onClick={() => jumpToSession(targetSessionId)}
459
524
  >
460
- 🤖 跳转会话 ↗
525
+ {t('detail.session.jump')}
461
526
  </button>
462
527
  )}
463
- <button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
528
+ <button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>{t('detail.action.edit')}</button>
464
529
  <button
465
530
  type="button"
466
531
  className="dsh-atb-detail-edit"
467
- title="复制此任务的全部配置为一张新卡(待办列)"
532
+ title={t('detail.action.duplicateTitle')}
468
533
  disabled={actionBusy}
469
534
  onClick={() => runAction(() => controller.duplicate(task))}
470
535
  >
471
- ⧉ 复制
536
+ {t('detail.action.duplicate')}
472
537
  </button>
473
538
  <button
474
539
  type="button"
475
540
  className="dsh-atb-detail-edit"
476
- title="把此任务的配置(含清单)保存为模板,新建任务时可用"
541
+ title={t('detail.action.saveTplTitle')}
477
542
  disabled={actionBusy}
478
543
  onClick={() => runAction(async () => {
479
544
  const ok = await controller.saveAsTemplate(task)
480
- if (ok) showAlert('已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)')
545
+ if (ok) showAlert(t('detail.action.saveTplDone'))
481
546
  })}
482
547
  >
483
- ⌗ 存为模板
548
+ {t('detail.action.saveTpl')}
484
549
  </button>
485
550
  {canRun && task.branch !== undefined && (
486
551
  <button
487
552
  type="button"
488
553
  className="dsh-atb-detail-run"
489
- title="续跑:保留现有 worktree 与分支(上次的改动和提交都在原处),在其上继续执行;默认「立即执行」会重置为全新基线"
554
+ title={t('detail.action.reuseTitle')}
490
555
  disabled={actionBusy}
491
556
  onClick={() => runAction(() => controller.run(task.id, true))}
492
557
  >
493
- ↻ 续跑
558
+ {t('detail.action.reuse')}
494
559
  </button>
495
560
  )}
496
561
  {canRun && (
497
562
  <button
498
563
  type="button"
499
564
  className="dsh-atb-detail-run"
500
- title={task.model !== undefined ? `新会话执行(${task.model.model})` : '新会话执行(默认模型)'}
565
+ title={task.model !== undefined ? t('detail.action.runTitleModel', { model: task.model.model }) : t('detail.action.runTitleDefault')}
501
566
  disabled={actionBusy}
502
567
  onClick={() => runAction(() => controller.run(task.id))}
503
568
  >
504
- ▶ 立即执行
569
+ {t('detail.action.run')}
505
570
  </button>
506
571
  )}
507
572
  {runningExecution !== undefined && (confirmCancel
508
573
  ? (
509
574
  <span className="dsh-atb-confirm">
510
- <span className="dsh-atb-confirm-label">停止该执行会话?</span>
511
- <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.cancel(task.id); setConfirmCancel(false) }}>停止</button>
512
- <button type="button" className="dsh-atb-btn" onClick={() => setConfirmCancel(false)}>取消</button>
575
+ <span className="dsh-atb-confirm-label">{t('detail.action.stopConfirm')}</span>
576
+ <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.cancel(task.id); setConfirmCancel(false) }}>{t('detail.action.stop')}</button>
577
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmCancel(false)}>{t('shared.cancel')}</button>
513
578
  </span>
514
579
  )
515
580
  : (
@@ -517,27 +582,27 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
517
582
  type="button"
518
583
  className="dsh-atb-detail-run"
519
584
  data-danger="true"
520
- title={`停止执行会话 ${runningExecution.sessionId ?? ''}(任务回到待办)`}
585
+ title={t('detail.action.stopTitle', { id: runningExecution.sessionId ?? '' })}
521
586
  onClick={() => setConfirmCancel(true)}
522
587
  >
523
- ■ 停止执行
588
+ {t('detail.action.stopExec')}
524
589
  </button>
525
590
  ))}
526
- <button type="button" className="dsh-atb-detail-close" aria-label="关闭" onClick={() => controller.select(undefined)}>✕</button>
591
+ <button type="button" className="dsh-atb-detail-close" aria-label={t('shared.close')} onClick={() => controller.select(undefined)}>✕</button>
527
592
  </div>
528
593
  </div>
529
594
 
530
595
  {task.description.length > 0 && (
531
596
  <div className="dsh-atb-fieldcard">
532
- <div className="dsh-atb-fieldcard-label">描述</div>
533
- <div className="dsh-atb-desc">{task.description}</div>
597
+ <div className="dsh-atb-fieldcard-label">{t('detail.field.description')}</div>
598
+ <div className="dsh-atb-desc"><MarkdownContent text={task.description} /></div>
534
599
  </div>
535
600
  )}
536
601
 
537
602
  {task.prompt.length > 0 && (
538
603
  <div className="dsh-atb-fieldcard" data-kind="prompt">
539
- <div className="dsh-atb-fieldcard-label">执行 Prompt</div>
540
- <div className="dsh-atb-promptbox">{task.prompt}</div>
604
+ <div className="dsh-atb-fieldcard-label">{t('detail.field.prompt')}</div>
605
+ <div className="dsh-atb-promptbox"><MarkdownContent text={task.prompt} /></div>
541
606
  </div>
542
607
  )}
543
608
 
@@ -554,39 +619,39 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
554
619
  ? (
555
620
  <span key={to} className="dsh-atb-confirm">
556
621
  <span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
557
- {unchecked > 0 ? `仍有 ${unchecked} 项清单未勾选,确认完成?` : '确认完成?'}
622
+ {unchecked > 0 ? t('detail.move.confirmDoneUnchecked', { n: unchecked }) : t('detail.move.confirmDone')}
558
623
  </span>
559
- <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => { void controller.move(task.id, task.version, 'done'); setConfirmDone(false) }}>确认</button>
560
- <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
624
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => { void controller.move(task.id, task.version, 'done'); setConfirmDone(false) }}>{t('detail.move.confirm')}</button>
625
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>{t('shared.cancel')}</button>
561
626
  </span>
562
627
  )
563
- : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>移至→{MOVE_LABEL[to]}</button>)
628
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>{t('detail.move.to', { status: t(MOVE_KEYS[to]) })}</button>)
564
629
  : (
565
630
  <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
566
- 移至→{MOVE_LABEL[to]}
631
+ {t('detail.move.to', { status: t(MOVE_KEYS[to]) })}
567
632
  </button>
568
633
  ))}
569
634
  <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
570
- {task.blocked ? '✓ 解除受阻' : '⛔ 标记受阻'}
635
+ {task.blocked ? t('detail.blocked.unmark') : t('detail.blocked.mark')}
571
636
  </button>
572
637
  {holder !== undefined && (
573
638
  <button
574
639
  type="button"
575
640
  className="dsh-atb-movebtn"
576
641
  data-to="release"
577
- title={`释放 ${holder} 的认领:任务回到待办(持有会话可能仍在工作,确认它已停止后再释放)`}
642
+ title={t('detail.release.title', { id: holder })}
578
643
  onClick={() => void controller.move(task.id, task.version, 'todo')}
579
644
  >
580
- 🔓 释放认领
645
+ {t('detail.release.button')}
581
646
  </button>
582
647
  )}
583
648
  </div>
584
649
  </div>
585
650
 
586
651
  <div className="dsh-atb-section">
587
- <h4>评论{task.comments.length > 0 && <span className="dsh-atb-count2">{task.comments.length}</span>}</h4>
652
+ <h4>{t('detail.comments.title')}{task.comments.length > 0 && <span className="dsh-atb-count2">{task.comments.length}</span>}</h4>
588
653
  {task.comments.length === 0
589
- ? <div className="dsh-atb-empty2">暂无评论 — agent 交接时会在这里汇报改动与验证结果</div>
654
+ ? <div className="dsh-atb-empty2">{t('detail.comments.empty')}</div>
590
655
  : (
591
656
  <div className="dsh-atb-commentlist">
592
657
  {task.comments.map(c => (
@@ -594,7 +659,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
594
659
  <div className="dsh-atb-bubble-avatar">{c.threadId !== undefined ? '🤖' : '👤'}</div>
595
660
  <div className="dsh-atb-bubble-main">
596
661
  <div className="dsh-atb-bubble-meta">
597
- <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : '用户'}</b>
662
+ <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : t('detail.comments.user')}</b>
598
663
  <span>{fmtTime(c.createdAt)}</span>
599
664
  </div>
600
665
  <div className="dsh-atb-bubble-body">{c.body}</div>
@@ -607,7 +672,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
607
672
  <textarea
608
673
  className="dsh-atb-composer-input"
609
674
  value={comment}
610
- placeholder="以用户身份留言(agent 开工前会读)…"
675
+ placeholder={t('detail.composer.placeholder')}
611
676
  onChange={e => setComment(e.target.value)}
612
677
  onKeyDown={e => {
613
678
  if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0) {
@@ -624,30 +689,30 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
624
689
  void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
625
690
  }}
626
691
  >
627
- 发表
692
+ {t('detail.composer.send')}
628
693
  </button>
629
694
  </div>
630
695
  </div>
631
696
 
632
697
  {task.executions.length > 0 && (
633
698
  <div className="dsh-atb-section">
634
- <h4>执行记录<span className="dsh-atb-count2">{task.executions.length}</span>
699
+ <h4>{t('detail.exec.title')}<span className="dsh-atb-count2">{task.executions.length}</span>
635
700
  {task.executionsPruned !== undefined && task.executionsPruned > 0 && (
636
- <span className="dsh-atb-count2" title={`更早的 ${task.executionsPruned} 条执行记录已按保留上限清理`}>+{task.executionsPruned} 已清理</span>
701
+ <span className="dsh-atb-count2" title={t('detail.exec.prunedTitle', { n: task.executionsPruned })}>{t('detail.exec.pruned', { n: task.executionsPruned })}</span>
637
702
  )}
638
703
  </h4>
639
704
  <div className="dsh-atb-execlist">
640
705
  {[...task.executions].reverse().map(e => (
641
706
  <div key={e.id} className="dsh-atb-exec-row">
642
707
  <span className="dsh-atb-exec-dot" data-outcome={e.outcome} />
643
- <span className="dsh-atb-exec-trigger">{e.trigger === 'manual' ? '手动' : '定时'}</span>
644
- <span className="dsh-atb-exec-outcome" data-outcome={e.outcome}>{OUTCOME_LABEL[e.outcome] ?? e.outcome}</span>
708
+ <span className="dsh-atb-exec-trigger">{e.trigger === 'manual' ? t('detail.exec.trigger.manual') : t('detail.exec.trigger.scheduled')}</span>
709
+ <span className="dsh-atb-exec-outcome" data-outcome={e.outcome}>{t(OUTCOME_KEYS[e.outcome] ?? e.outcome)}</span>
645
710
  <span className="dsh-atb-exec-time">{fmtTime(e.startedAt)}{e.endedAt !== undefined && ` · ${duration(e.startedAt, e.endedAt)}`}</span>
646
711
  {e.sessionId !== undefined && (
647
712
  <button
648
713
  type="button"
649
714
  className="dsh-atb-exec-session"
650
- title={`点击打开该执行会话:${e.sessionId}`}
715
+ title={t('detail.exec.openTitle', { id: e.sessionId })}
651
716
  onClick={() => jumpToSession(e.sessionId!)}
652
717
  >
653
718
  🤖 {shortId(e.sessionId)} ↗
@@ -662,16 +727,16 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
662
727
 
663
728
  <div className="dsh-atb-dangerzone">
664
729
  {task.trashedAt === undefined
665
- ? <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.remove(task.id, task.version, false)}>🗑 删除(标记待清除)</button>
730
+ ? <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.remove(task.id, task.version, false)}>{t('detail.danger.delete')}</button>
666
731
  : (confirmPurge
667
732
  ? (
668
733
  <span className="dsh-atb-confirm">
669
- <span className="dsh-atb-confirm-label">物理清除不可恢复</span>
670
- <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.remove(task.id, task.version, true); setConfirmPurge(false) }}>确认清除</button>
671
- <button type="button" className="dsh-atb-btn" onClick={() => setConfirmPurge(false)}>取消</button>
734
+ <span className="dsh-atb-confirm-label">{t('detail.danger.purgeConfirm')}</span>
735
+ <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.remove(task.id, task.version, true); setConfirmPurge(false) }}>{t('detail.danger.purgeGo')}</button>
736
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmPurge(false)}>{t('shared.cancel')}</button>
672
737
  </span>
673
738
  )
674
- : <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => setConfirmPurge(true)}>🔥 物理清除(需确认)</button>)}
739
+ : <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => setConfirmPurge(true)}>{t('detail.danger.purge')}</button>)}
675
740
  </div>
676
741
 
677
742
  {alertEl}