dsh-taskboard 0.4.5 → 0.5.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.
Files changed (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
@@ -9,48 +9,16 @@ import type { BoardController, ControllerState } from '../controller.ts'
9
9
  import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
10
10
  import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
11
11
  import { PLUGIN_VERSION } from '../../shared/version.ts'
12
+ import { COLUMN_LABELS, URGENCY_LABEL } from './labels.ts'
13
+ import { fmtTime, isStaleClaim } from './format.ts'
12
14
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
13
15
  import { TaskDetail } from './TaskDetail.tsx'
14
16
  import { TaskFormModal } from './TaskFormModal.tsx'
17
+ import { SettingsModal } from './SettingsModal.tsx'
15
18
  import { ImportModal } from './ImportModal.tsx'
16
19
  import { TemplateManager } from './TemplateManager.tsx'
17
20
  import { useAlert } from './AlertModal.tsx'
18
21
 
19
- /** Column labels. */
20
- const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
21
- backlog: '待规划',
22
- todo: '待办',
23
- in_progress: '进行中',
24
- in_review: '待验收',
25
- done: '已完成',
26
- canceled: '已取消',
27
- archived: '已归档',
28
- }
29
-
30
-
31
- /** Urgency chip labels. */
32
- const URGENCY_LABELS: Readonly<Record<Urgency, string>> = {
33
- urgent: '紧急',
34
- normal: '一般',
35
- relaxed: '不急',
36
- }
37
-
38
- /** Format an epoch ms as a short local stamp. */
39
- export function fmtTime(ms: number | undefined): string {
40
- if (ms === undefined) return ''
41
- const d = new Date(ms)
42
- const pad = (n: number) => String(n).padStart(2, '0')
43
- return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
44
- }
45
-
46
- /** A claim idle for longer than this is highlighted as stale (ms). */
47
- export const STALE_CLAIM_MS = 30 * 60_000
48
-
49
- /** Whether the task's claim is stale (in_progress, held, idle too long). */
50
- export function isStaleClaim(task: TaskRecord, now: number): boolean {
51
- return task.status === 'in_progress' && task.claimedAt !== undefined && now - task.claimedAt > STALE_CLAIM_MS
52
- }
53
-
54
22
  /** Urgency sort rank (urgent first). */
55
23
  const URGENCY_RANK: Record<Urgency, number> = { urgent: 0, normal: 1, relaxed: 2 }
56
24
 
@@ -90,6 +58,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
90
58
  // + 新建任务 ▼ dropdown (0.4.0): blank / templates / manage / import.
91
59
  const [newMenuOpen, setNewMenuOpen] = useState(false)
92
60
  const closeMenu = (): void => setNewMenuOpen(false)
61
+ // ⬇ 导出 ▼ dropdown (0.5.1): whole-ledger JSON backup or task-list CSV.
62
+ const [exportOpen, setExportOpen] = useState(false)
63
+ const closeExport = (): void => setExportOpen(false)
93
64
 
94
65
  return (
95
66
  <div className="dsh-atb-board">
@@ -122,7 +93,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
122
93
  title={t.task.description !== undefined && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name}
123
94
  onClick={() => { closeMenu(); controller.newFromTemplate(t.task) }}
124
95
  >
125
- {t.name}{t.builtin === true ? '' : ''}
96
+ {t.name}
126
97
  </button>
127
98
  ))}
128
99
  <div className="dsh-atb-newmenu-sep" />
@@ -168,16 +139,48 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
168
139
  onClick={() => controller.toggleUrgency(u)}
169
140
  >
170
141
  <span className="dsh-atb-dot" data-urgency={u} />
171
- {URGENCY_LABELS[u]}
142
+ {URGENCY_LABEL[u]}
172
143
  </button>
173
144
  ))}
174
145
  <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
175
146
  {state.secondaryOpen ? '返回看板' : '其它任务'}
176
147
  </button>
148
+ <button type="button" className="dsh-atb-btn" title="看板设置:新建任务的默认执行隔离等" onClick={() => controller.openSettings()}>🛠 设置</button>
177
149
  <button type="button" className="dsh-atb-btn" title="健康诊断:遗留 worktree、台账基本项" onClick={() => controller.openDiagnostics()}>⚙ 诊断</button>
178
150
  <button type="button" className="dsh-atb-btn" title="从 JSON 备份文件导入台账(预览后合并或整册替换)" onClick={() => controller.openImport()}>⬆ 导入</button>
179
- <button type="button" className="dsh-atb-btn" title="下载完整台账备份(JSON)" onClick={() => controller.exportJson()}>⬇ JSON</button>
180
- <button type="button" className="dsh-atb-btn" title="下载任务清单(CSV)" onClick={() => controller.exportCsv()}>⬇ CSV</button>
151
+ <div className="dsh-atb-newmenu">
152
+ <button
153
+ type="button"
154
+ className="dsh-atb-btn"
155
+ title="导出台账:完整 JSON 备份或任务清单 CSV"
156
+ onClick={() => setExportOpen(!exportOpen)}
157
+ >
158
+ ⬇ 导出 ▼
159
+ </button>
160
+ {exportOpen && (
161
+ <>
162
+ <div className="dsh-atb-newmenu-backdrop" onClick={closeExport} />
163
+ <div className="dsh-atb-newmenu-list">
164
+ <button
165
+ type="button"
166
+ className="dsh-atb-newmenu-opt"
167
+ title="完整台账备份(含执行历史与看板设置),可用于导入恢复"
168
+ onClick={() => { closeExport(); controller.exportJson() }}
169
+ >
170
+ 完整台账(JSON)
171
+ </button>
172
+ <button
173
+ type="button"
174
+ className="dsh-atb-newmenu-opt"
175
+ title="任务清单表格(Excel 可直接打开,中文已加 BOM)"
176
+ onClick={() => { closeExport(); controller.exportCsv() }}
177
+ >
178
+ 任务清单(CSV)
179
+ </button>
180
+ </div>
181
+ </>
182
+ )}
183
+ </div>
181
184
  <a
182
185
  className="dsh-atb-ver"
183
186
  href="https://github.com/cloader/dsh-taskboard"
@@ -248,7 +251,9 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
248
251
 
249
252
  {selected !== undefined && (
250
253
  <div className="dsh-atb-detailpanel">
251
- <TaskDetail task={selected} controller={controller} now={now} />
254
+ {/* key: remount per task without it, confirm states and comment
255
+ drafts leak across task switches (review P0). */}
256
+ <TaskDetail key={selected.id} task={selected} controller={controller} now={now} />
252
257
  </div>
253
258
  )}
254
259
 
@@ -261,6 +266,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
261
266
 
262
267
  {state.diagOpen && <DiagnosticsPanel controller={controller} />}
263
268
 
269
+ {state.settingsOpen && <SettingsModal controller={controller} />}
270
+
264
271
  {state.importOpen && <ImportModal controller={controller} />}
265
272
 
266
273
  {state.tplManagerOpen && <TemplateManager controller={controller} />}
@@ -14,10 +14,8 @@
14
14
  import { useState } from 'react'
15
15
  import type { BoardController } from '../controller.ts'
16
16
  import type { TaskRecord } from '../../shared/protocol.ts'
17
- import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
18
-
19
- const URGENCY_LABEL: Record<TaskRecord['urgency'], string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
20
- const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
17
+ import { fmtTime, isStaleClaim } from './format.ts'
18
+ import { OUTCOME_LABEL, URGENCY_LABEL } from './labels.ts'
21
19
 
22
20
  /** dataTransfer type carrying the dragged task id. */
23
21
  export const DRAG_TYPE = 'application/x-dsh-atb-task'
@@ -57,7 +55,7 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
57
55
  // Block drag if a session is still executing this task
58
56
  if (running !== undefined) {
59
57
  e.preventDefault()
60
- const msg = `该任务正在由【${task.title}】会话执行,不能拖动`
58
+ const msg = `该任务正由会话执行中(${task.title}),不能拖动`
61
59
  if (onAlert !== undefined) onAlert(msg)
62
60
  else alert(msg)
63
61
  return
@@ -12,7 +12,8 @@ import type { BoardController } from '../controller.ts'
12
12
  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
- import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
15
+ import { fmtTime, isStaleClaim } from './format.ts'
16
+ import { MOVE_LABEL, OUTCOME_LABEL, STATUS_LABEL, URGENCY_LABEL } from './labels.ts'
16
17
 
17
18
  /** Statuses a user may move this task to, per the state machine. */
18
19
  function moveTargets(task: TaskRecord): TaskRecord['status'][] {
@@ -20,14 +21,6 @@ function moveTargets(task: TaskRecord): TaskRecord['status'][] {
20
21
  return all.filter(to => canTransition(task.status, to))
21
22
  }
22
23
 
23
- const MOVE_LABEL: Record<string, string> = {
24
- backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '待验收',
25
- done: '完成', canceled: '取消', archived: '归档',
26
- }
27
- const STATUS_LABEL: Record<string, string> = { ...MOVE_LABEL }
28
- const URGENCY_LABEL: Record<string, string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
29
- const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
30
-
31
24
  /** Compact session-id display (execution sessions carry the taskboard infix). */
32
25
  function shortId(id: string | undefined): string {
33
26
  if (id === undefined) return ''
@@ -374,6 +367,10 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
374
367
  const [confirmDone, setConfirmDone] = useState(false)
375
368
  const [confirmPurge, setConfirmPurge] = useState(false)
376
369
  const [confirmCancel, setConfirmCancel] = useState(false)
370
+ // Top action buttons (duplicate / save-as-template / run / reuse-run)
371
+ // share one in-flight guard: a double click used to fire duplicate runs or
372
+ // copies while the first round-trip was still pending (review P0).
373
+ const [actionBusy, setActionBusy] = useState(false)
377
374
  const { alert: showAlert, el: alertEl } = useAlert()
378
375
  const ws = controller.getSnapshot().workspaces.find(w => w.id === task.workspaceId)
379
376
  const canRun = task.status !== 'in_progress' && task.status !== 'done' && task.status !== 'archived'
@@ -382,6 +379,13 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
382
379
  const stale = now !== undefined && isStaleClaim(task, now)
383
380
  const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
384
381
 
382
+ /** Fire one top action under the shared busy guard; re-enable on settle. */
383
+ const runAction = (action: () => Promise<unknown>): void => {
384
+ if (actionBusy) return
385
+ setActionBusy(true)
386
+ void action().catch(() => undefined).finally(() => setActionBusy(false))
387
+ }
388
+
385
389
  /** Jump to an execution's session; prompt precisely when it cannot open. */
386
390
  const jumpToSession = (sessionId: string): void => {
387
391
  void controller.openSession(sessionId).then(result => {
@@ -426,7 +430,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
426
430
  <Chip>v{task.version}</Chip>
427
431
  </div>
428
432
  <div className="dsh-atb-detail-sub">
429
- 更新 {fmtTime(task.updatedAt)} · 最近操作 {task.updatedBy.kind === 'agent' ? `🤖 ${shortId(task.updatedBy.sessionId)}` : '👤 用户'}
433
+ 更新 {fmtTime(task.updatedAt)} · 最近操作 {task.updatedBy.kind === 'agent' ? `🤖 ${shortId(task.updatedBy.sessionId)}` : task.updatedBy.kind === 'system' ? '⚙️ 系统' : '👤 用户'}
430
434
  </div>
431
435
  </div>
432
436
  <div className="dsh-atb-detail-topbtns">
@@ -435,7 +439,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
435
439
  type="button"
436
440
  className="dsh-atb-detail-edit"
437
441
  title="复制此任务的全部配置为一张新卡(待办列)"
438
- onClick={() => void controller.duplicate(task)}
442
+ disabled={actionBusy}
443
+ onClick={() => runAction(() => controller.duplicate(task))}
439
444
  >
440
445
  ⧉ 复制
441
446
  </button>
@@ -443,11 +448,11 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
443
448
  type="button"
444
449
  className="dsh-atb-detail-edit"
445
450
  title="把此任务的配置(含清单)保存为模板,新建任务时可用"
446
- onClick={() => {
447
- void controller.saveAsTemplate(task).then(ok => {
448
- if (ok) showAlert('已存为模板(新建任务 下拉可用,可在模板管理中改名)')
449
- })
450
- }}
451
+ disabled={actionBusy}
452
+ onClick={() => runAction(async () => {
453
+ const ok = await controller.saveAsTemplate(task)
454
+ if (ok) showAlert('已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)')
455
+ })}
451
456
  >
452
457
  ⌗ 存为模板
453
458
  </button>
@@ -456,7 +461,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
456
461
  type="button"
457
462
  className="dsh-atb-detail-run"
458
463
  title="续跑:保留现有 worktree 与分支(上次的改动和提交都在原处),在其上继续执行;默认「立即执行」会重置为全新基线"
459
- onClick={() => void controller.run(task.id, true)}
464
+ disabled={actionBusy}
465
+ onClick={() => runAction(() => controller.run(task.id, true))}
460
466
  >
461
467
  ↻ 续跑
462
468
  </button>
@@ -466,7 +472,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
466
472
  type="button"
467
473
  className="dsh-atb-detail-run"
468
474
  title={task.model !== undefined ? `新会话执行(${task.model.model})` : '新会话执行(默认模型)'}
469
- onClick={() => void controller.run(task.id)}
475
+ disabled={actionBusy}
476
+ onClick={() => runAction(() => controller.run(task.id))}
470
477
  >
471
478
  ▶ 立即执行
472
479
  </button>
@@ -578,8 +585,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
578
585
  onChange={e => setComment(e.target.value)}
579
586
  onKeyDown={e => {
580
587
  if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0) {
581
- void controller.comment(task.id, comment)
582
- setComment('')
588
+ // T13: keep the draft when the post fails (reject 表单同样保留).
589
+ void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
583
590
  }
584
591
  }}
585
592
  />
@@ -587,7 +594,9 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
587
594
  type="button"
588
595
  className="dsh-atb-composer-send"
589
596
  disabled={comment.trim().length === 0}
590
- onClick={() => { void controller.comment(task.id, comment); setComment('') }}
597
+ onClick={() => {
598
+ void controller.comment(task.id, comment).then(ok => { if (ok) setComment('') })
599
+ }}
591
600
  >
592
601
  发表
593
602
  </button>
@@ -10,11 +10,10 @@
10
10
  */
11
11
  import { useEffect, useRef, useState, type ReactNode } from 'react'
12
12
  import type { BoardController } from '../controller.ts'
13
- import { loadDefaultIsolation, saveDefaultIsolation } from '../controller.ts'
14
13
  import type { TaskTemplateSpec } from '../../shared/api.ts'
15
14
  import type { ChecklistItem, IsolationMode, Urgency } from '../../shared/protocol.ts'
16
- import { MAX_CHECKLIST_ITEMS, nextCronTime, parseCron } from '../../shared/protocol.ts'
17
- import { fmtTime } from './TaskBoard.tsx'
15
+ import { MAX_CHECKLIST_ITEMS, defaultIsolationOf, nextCronTime, parseCron } from '../../shared/protocol.ts'
16
+ import { fmtTime } from './format.ts'
18
17
 
19
18
  /** One row of the configured model catalog (from llm.models). */
20
19
  export interface CatalogModel { provider: string; model: string; name?: string }
@@ -134,10 +133,10 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
134
133
  const [presetId, setPresetId] = useState(initialPreset)
135
134
  const [presets, setPresets] = useState<Array<{ id: string; name?: string }>>([])
136
135
  const [presetDefault, setPresetDefault] = useState<string | undefined>(undefined)
137
- // Isolation toggle: create mode starts from the remembered choice (default
138
- // on) or the template's choice; edit mode starts from the task and locks
139
- // once execution began.
140
- const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' : loadDefaultIsolation()))
136
+ // Isolation toggle: create mode starts from the board setting (0.5.0
137
+ // 看板设置 → 默认执行隔离) or the template's choice; edit mode starts from
138
+ // the task and locks once execution began.
139
+ const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' : defaultIsolationOf(state.ledger.settings)))
141
140
  // Checklist (0.4.0): create = template texts / blank rows; edit = live items.
142
141
  const [checkRows, setCheckRows] = useState<CheckRow[]>(
143
142
  task?.checklist !== undefined && task.checklist.length > 0
@@ -145,6 +144,10 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
145
144
  : (prefill?.checklist ?? []).map(text => ({ text, checked: false })),
146
145
  )
147
146
  const titleRef = useRef<HTMLInputElement>(null)
147
+ // One in-flight write at a time: the foot buttons disable while a
148
+ // create/update/run round-trip is pending — a double click used to fire
149
+ // duplicate creates (and runs) before the first one returned (review P0).
150
+ const [busy, setBusy] = useState(false)
148
151
 
149
152
  // Focus the title and close on Esc while the dialog is open.
150
153
  useEffect(() => {
@@ -156,9 +159,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
156
159
  return () => document.removeEventListener('keydown', onKey)
157
160
  }, [controller])
158
161
 
159
- // Model catalog: the plugin face provides it when the runtime is up.
162
+ // Model catalog: the controller exposes the installed face when the runtime is up.
160
163
  useEffect(() => {
161
- const face = (controller as unknown as { modelCatalog?: () => Promise<CatalogModel[]> }).modelCatalog
164
+ const face = controller.modelCatalog
162
165
  if (face === undefined) return
163
166
  void face().then(setCatalog).catch(() => setCatalog([]))
164
167
  }, [controller])
@@ -167,14 +170,18 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
167
170
  // create mode (unless a template pinned one) so executions run with a
168
171
  // real tool set out of the box.
169
172
  useEffect(() => {
170
- const face = (controller as unknown as { presetCatalog?: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }> }).presetCatalog
173
+ const face = controller.presetCatalog
171
174
  if (face === undefined) return
172
175
  void face().then(roster => {
173
176
  setPresets(roster.presets)
174
177
  setPresetDefault(roster.defaultId)
175
- if (task?.presetId === undefined && initialPreset === '' && roster.defaultId !== undefined) setPresetId(roster.defaultId)
178
+ // CREATE mode only (review P1): pre-selecting in edit mode would
179
+ // silently pin the deployment default onto tasks that deliberately
180
+ // follow it. In create mode `task` is undefined, so checking
181
+ // `initialPreset` (template pin) alone is sufficient.
182
+ if (!editing && initialPreset === '' && roster.defaultId !== undefined) setPresetId(roster.defaultId)
176
183
  }).catch(() => setPresets([]))
177
- }, [controller, task?.presetId, initialPreset])
184
+ }, [controller, editing, task?.presetId, initialPreset])
178
185
 
179
186
  // Live cron validation + next-run preview (same math as the host).
180
187
  const cronMatch = mode === 'scheduled' ? parseCron(cron.trim()) : null
@@ -193,10 +200,12 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
193
200
  // default (runtime auto-degrades with a note) instead of persisting 'none'.
194
201
  const isolationDisabled = isolationLocked || !gitOk
195
202
 
196
- /** Isolation payload for submit: undefined keeps the default (degrades naturally). */
203
+ /**
204
+ * Isolation payload for submit: undefined lets the HOST materialize the
205
+ * current board default at creation (non-git projects degrade naturally).
206
+ */
197
207
  const isolationPayload = (): string | undefined => {
198
208
  if (!gitOk) return undefined
199
- if (!editing) saveDefaultIsolation(isolation)
200
209
  return isolation
201
210
  }
202
211
 
@@ -207,13 +216,14 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
207
216
  const filledRows = (): CheckRow[] => checkRows.map(r => ({ ...r, text: r.text.trim() })).filter(r => r.text.length > 0)
208
217
 
209
218
  const submit = (): void => {
210
- if (!valid) return
219
+ if (!valid || busy) return
211
220
  const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
212
221
  const isolationOut = isolationPayload()
213
222
  const presetOut = presetPayload()
214
223
  const rows = filledRows()
215
- if (editing) {
216
- void controller.update(task.id, task.version, {
224
+ setBusy(true)
225
+ const action = editing
226
+ ? controller.update(task.id, task.version, {
217
227
  title,
218
228
  description,
219
229
  prompt,
@@ -227,8 +237,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
227
237
  // [] clears the checklist (host deletes the field on empty).
228
238
  checklist: rows.length > 0 ? rows : null,
229
239
  })
230
- } else {
231
- void controller.create({
240
+ : controller.create({
232
241
  title,
233
242
  workspaceId,
234
243
  urgency,
@@ -240,18 +249,19 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
240
249
  ...(presetOut !== undefined ? { presetId: presetOut } : {}),
241
250
  ...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
242
251
  })
243
- }
252
+ void action.catch(() => undefined).finally(() => setBusy(false))
244
253
  }
245
254
 
246
255
  /** Save the form, then immediately trigger a manual run of the task. */
247
256
  const submitAndRun = (): void => {
248
- if (!valid || runBlocked) return
257
+ if (!valid || runBlocked || busy) return
249
258
  const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
250
259
  const isolationOut = isolationPayload()
251
260
  const presetOut = presetPayload()
252
261
  const rows = filledRows()
253
- if (editing) {
254
- void (async () => {
262
+ setBusy(true)
263
+ void (async () => {
264
+ if (editing) {
255
265
  const saved = await controller.update(task.id, task.version, {
256
266
  title,
257
267
  description,
@@ -265,9 +275,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
265
275
  checklist: rows.length > 0 ? rows : null,
266
276
  })
267
277
  if (saved) await controller.run(task.id)
268
- })()
269
- } else {
270
- void (async () => {
278
+ } else {
271
279
  const id = await controller.create({
272
280
  title,
273
281
  workspaceId,
@@ -281,8 +289,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
281
289
  ...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
282
290
  })
283
291
  if (id !== undefined) await controller.run(id)
284
- })()
285
- }
292
+ }
293
+ })().catch(() => undefined).finally(() => setBusy(false))
286
294
  }
287
295
 
288
296
  const hint = !valid
@@ -449,13 +457,13 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
449
457
  <button
450
458
  type="button"
451
459
  className="dsh-atb-btn"
452
- disabled={!valid || runBlocked}
453
- title={runBlocked ? '任务正在执行中,不能重复发起' : '保存后立即发起执行(新会话)'}
460
+ disabled={!valid || runBlocked || busy}
461
+ title={runBlocked ? '任务正在执行中,不能重复发起' : busy ? '正在提交…' : '保存后立即发起执行(新会话)'}
454
462
  onClick={submitAndRun}
455
463
  >
456
464
  ⚡ 立即执行
457
465
  </button>
458
- <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid} onClick={submit}>
466
+ <button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid || busy} onClick={submit}>
459
467
  {editing ? '保存修改' : '创建任务'}
460
468
  </button>
461
469
  </span>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure display helpers shared by the board components.
3
+ *
4
+ * Review P2: these lived in TaskBoard.tsx and were imported back out by
5
+ * TaskCard/TaskDetail/TaskFormModal, forming import cycles with the view
6
+ * root. They have no component dependencies — they belong here.
7
+ *
8
+ * @module dsh-taskboard/client/board/format
9
+ */
10
+ import type { TaskRecord } from '../../shared/protocol.ts'
11
+
12
+ /** Format an epoch ms as a short local stamp. */
13
+ export function fmtTime(ms: number | undefined): string {
14
+ if (ms === undefined) return ''
15
+ const d = new Date(ms)
16
+ const pad = (n: number) => String(n).padStart(2, '0')
17
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
18
+ }
19
+
20
+ /** A claim idle for longer than this is highlighted as stale (ms). */
21
+ export const STALE_CLAIM_MS = 30 * 60_000
22
+
23
+ /** Whether the task's claim is stale (in_progress, held, idle too long). */
24
+ export function isStaleClaim(task: TaskRecord, now: number): boolean {
25
+ return task.status === 'in_progress' && task.claimedAt !== undefined && now - task.claimedAt > STALE_CLAIM_MS
26
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Centralized zh-CN display labels for the board UI.
3
+ *
4
+ * Review P2: the same status/urgency/outcome text used to live in three
5
+ * components (TaskBoard / TaskCard / TaskDetail) and drifted — adding a
6
+ * status meant three edits, missing one leaked the raw English key.
7
+ *
8
+ * @module dsh-taskboard/client/board/labels
9
+ */
10
+ import type { TaskStatus, Urgency } from '../../shared/protocol.ts'
11
+
12
+ /** Column headers on the five-column main board (+ secondary tab). */
13
+ export const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
14
+ backlog: '待规划',
15
+ todo: '待办',
16
+ in_progress: '进行中',
17
+ in_review: '待验收',
18
+ done: '已完成',
19
+ canceled: '已取消',
20
+ archived: '已归档',
21
+ }
22
+
23
+ /** Status pill text (detail pane) — historical wording kept verbatim:
24
+ * terminal states read short here, the column headers carry the full forms. */
25
+ export const STATUS_LABEL: Readonly<Record<TaskStatus, string>> = {
26
+ backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '待验收',
27
+ done: '完成', canceled: '取消', archived: '归档',
28
+ }
29
+
30
+ /** Move-button verbs (shorter than the pill text). */
31
+ export const MOVE_LABEL: Readonly<Record<TaskStatus, string>> = {
32
+ backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '待验收',
33
+ done: '完成', canceled: '取消', archived: '归档',
34
+ }
35
+
36
+ /** Urgency chip labels. */
37
+ export const URGENCY_LABEL: Readonly<Record<Urgency, string>> = {
38
+ urgent: '紧急', normal: '一般', relaxed: '不急',
39
+ }
40
+
41
+ /** Execution outcome labels. */
42
+ export const OUTCOME_LABEL: Readonly<Record<string, string>> = {
43
+ running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消',
44
+ }