dsh-taskboard 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +22 -6
  2. package/lib/client.js +1192 -39
  3. package/lib/host/execution.js +6 -1
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +95 -2
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +184 -2
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +202 -2
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +7 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +277 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/api.ts +28 -0
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +45 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +192 -8
  28. package/src/client/board/TaskFormModal.tsx +100 -18
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +152 -8
  31. package/src/client/styles.ts +153 -0
  32. package/src/host/execution.ts +10 -2
  33. package/src/host/git.ts +77 -0
  34. package/src/host/protocol-text.ts +5 -3
  35. package/src/host/routes.ts +215 -0
  36. package/src/host/store.ts +13 -0
  37. package/src/host/templates.ts +143 -0
  38. package/src/host/tools.ts +198 -2
  39. package/src/index.ts +6 -0
  40. package/src/shared/api.ts +54 -0
  41. package/src/shared/protocol.ts +344 -0
  42. package/src/shared/version.ts +1 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The ledger-import modal (0.4.0): pick a JSON file → dry-run preview
3
+ * (create / overwrite / invalid classification) → commit as merge or
4
+ * replace. Replace swaps the WHOLE ledger after an automatic backup and a
5
+ * double confirmation. Files exported by ⬇ JSON import as-is.
6
+ *
7
+ * @module dsh-taskboard/client/board/ImportModal
8
+ */
9
+ import { useRef, useState } from 'react'
10
+ import type { BoardController } from '../controller.ts'
11
+ import type { ImportPreviewResponse } from '../../shared/api.ts'
12
+ import { useAlert } from './AlertModal.tsx'
13
+
14
+ /** One classified row (create / overwrite). */
15
+ function PlanRow({ row }: { row: { id: string; title: string; status: string } }) {
16
+ return (
17
+ <div className="dsh-atb-imp-row" title={row.id}>
18
+ <span className="dsh-atb-imp-row-title">{row.title}</span>
19
+ <span className="dsh-atb-imp-row-status">{row.status}</span>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ /**
25
+ * The import modal.
26
+ * @param controller - the controller.
27
+ */
28
+ export function ImportModal({ controller }: { controller: BoardController }) {
29
+ const [fileName, setFileName] = useState('')
30
+ const [parsed, setParsed] = useState<unknown>(null)
31
+ const [parseError, setParseError] = useState<string | undefined>(undefined)
32
+ const [plan, setPlan] = useState<ImportPreviewResponse['plan'] | undefined>(undefined)
33
+ const [mode, setMode] = useState<'merge' | 'replace'>('merge')
34
+ const [busy, setBusy] = useState(false)
35
+ const [result, setResult] = useState<string | undefined>(undefined)
36
+ const [confirmReplace, setConfirmReplace] = useState(false)
37
+ const fileRef = useRef<HTMLInputElement>(null)
38
+ const { alert: showAlert, el: alertEl } = useAlert()
39
+
40
+ /** Read + parse the picked file, then dry-run the preview. */
41
+ const onFile = (file: File | undefined): void => {
42
+ setPlan(undefined)
43
+ setParseError(undefined)
44
+ setResult(undefined)
45
+ setConfirmReplace(false)
46
+ setFileName('')
47
+ setParsed(null)
48
+ if (file === undefined) return
49
+ void file.text().then(text => {
50
+ try {
51
+ const value: unknown = JSON.parse(text)
52
+ setParsed(value)
53
+ setFileName(file.name)
54
+ void controller.importPreview(value).then(p => {
55
+ if (p !== undefined) setPlan(p)
56
+ })
57
+ } catch {
58
+ setParseError('文件不是合法 JSON')
59
+ }
60
+ })
61
+ }
62
+
63
+ /** Commit the import (replace requires the inline double confirmation). */
64
+ const commit = (): void => {
65
+ if (parsed === null || plan === undefined || busy) return
66
+ if (mode === 'replace' && !confirmReplace) {
67
+ setConfirmReplace(true)
68
+ return
69
+ }
70
+ setBusy(true)
71
+ void controller.importCommit(mode, parsed).then(r => {
72
+ setBusy(false)
73
+ setConfirmReplace(false)
74
+ if (r === undefined) return
75
+ setResult(r.mode === 'replace'
76
+ ? `整册替换完成:导入 ${r.created + r.overwritten} 张(原 ${r.replacedTotal} 张已整册备份)`
77
+ : `合并完成:新增 ${r.created} 张、覆盖 ${r.overwritten} 张`)
78
+ })
79
+ }
80
+
81
+ const close = (): void => controller.closeImport()
82
+
83
+ return (
84
+ <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close() }}>
85
+ <div className="dsh-atb-modal dsh-atb-imp" role="dialog" aria-modal="true" aria-label="导入台账">
86
+ <div className="dsh-atb-modal-head">
87
+ <span className="dsh-atb-modal-headicon">⬆</span>
88
+ <div className="dsh-atb-modal-headtext">
89
+ <h3>导入台账</h3>
90
+ <p>选择导出的 JSON 备份文件:先预览、再合并或整册替换</p>
91
+ </div>
92
+ <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={close}>✕</button>
93
+ </div>
94
+ <div className="dsh-atb-modal-body">
95
+ <div className="dsh-atb-imp-picker">
96
+ <input
97
+ ref={fileRef}
98
+ type="file"
99
+ accept=".json,application/json"
100
+ onChange={e => onFile(e.target.files?.[0])}
101
+ />
102
+ {fileName.length > 0 && <span className="dsh-atb-imp-filename">{fileName}</span>}
103
+ </div>
104
+ <div className="dsh-atb-imp-note">⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。</div>
105
+
106
+ {parseError !== undefined && <div className="dsh-atb-imp-error">{parseError}</div>}
107
+ {plan === undefined && parseError === undefined && fileName.length > 0 && <div className="dsh-atb-empty2">预览中…</div>}
108
+
109
+ {plan !== undefined && (
110
+ <>
111
+ <div className="dsh-atb-imp-stats">
112
+ <div className="dsh-atb-imp-stat" data-tone="ok"><b>{plan.create.length}</b><span>新增</span></div>
113
+ <div className="dsh-atb-imp-stat" data-tone="warn"><b>{plan.overwrite.length}</b><span>覆盖(同 id)</span></div>
114
+ <div className="dsh-atb-imp-stat" data-tone={plan.invalid.length > 0 ? 'bad' : undefined}><b>{plan.invalid.length}</b><span>无效(跳过)</span></div>
115
+ </div>
116
+
117
+ {plan.create.length > 0 && (
118
+ <div className="dsh-atb-imp-sec">
119
+ <h4>新增任务</h4>
120
+ <div className="dsh-atb-imp-list">{plan.create.map(r => <PlanRow key={r.id} row={r} />)}</div>
121
+ </div>
122
+ )}
123
+ {plan.overwrite.length > 0 && (
124
+ <div className="dsh-atb-imp-sec">
125
+ <h4>覆盖任务(整卡替换,含执行历史与评论)</h4>
126
+ <div className="dsh-atb-imp-list">{plan.overwrite.map(r => <PlanRow key={r.id} row={r} />)}</div>
127
+ </div>
128
+ )}
129
+ {plan.invalid.length > 0 && (
130
+ <div className="dsh-atb-imp-sec">
131
+ <h4>无效条目(不会导入)</h4>
132
+ <div className="dsh-atb-imp-list">
133
+ {plan.invalid.map((r, i) => (
134
+ <div key={r.id ?? `invalid-${i}`} className="dsh-atb-imp-row" data-tone="bad" title={r.id ?? ''}>
135
+ <span className="dsh-atb-imp-row-title">{r.id ?? '(无 id)'}</span>
136
+ <span className="dsh-atb-imp-row-status">{r.reason}</span>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </div>
141
+ )}
142
+
143
+ <div className="dsh-atb-mode-picker">
144
+ <button type="button" className="dsh-atb-mode-opt" data-on={mode === 'merge'} onClick={() => { setMode('merge'); setConfirmReplace(false) }}>
145
+ <span className="dsh-atb-mode-name">⊕ 合并</span>
146
+ <span className="dsh-atb-mode-hint">新增 + 按 id 覆盖,其余不动</span>
147
+ </button>
148
+ <button type="button" className="dsh-atb-mode-opt" data-on={mode === 'replace'} onClick={() => setMode('replace')}>
149
+ <span className="dsh-atb-mode-name">💣 整册替换</span>
150
+ <span className="dsh-atb-mode-hint">清空当前台账,以导入文件为准(先自动备份)</span>
151
+ </button>
152
+ </div>
153
+
154
+ {result !== undefined && <div className="dsh-atb-imp-result">{result}</div>}
155
+ </>
156
+ )}
157
+ </div>
158
+ <div className="dsh-atb-modal-foot">
159
+ <span className="dsh-atb-modal-hint">
160
+ {mode === 'replace'
161
+ ? confirmReplace ? '⚠ 再次点击确认执行整册替换(不可撤销,已自动备份)' : '整册替换需要二次确认'
162
+ : '合并只写入预览中列出的任务'}
163
+ </span>
164
+ <span className="dsh-atb-modal-footbtns">
165
+ <button type="button" className="dsh-atb-btn" onClick={close}>{result !== undefined ? '关闭' : '取消'}</button>
166
+ <button
167
+ type="button"
168
+ className="dsh-atb-btn"
169
+ data-primary="true"
170
+ data-danger={mode === 'replace' && confirmReplace ? 'true' : undefined}
171
+ disabled={plan === undefined || busy || (result !== undefined && false)}
172
+ onClick={commit}
173
+ >
174
+ {mode === 'replace' && confirmReplace ? '确认整册替换' : '执行导入'}
175
+ </button>
176
+ </span>
177
+ </div>
178
+ </div>
179
+ {alertEl}
180
+ </div>
181
+ )
182
+ }
@@ -12,6 +12,8 @@ import { PLUGIN_VERSION } from '../../shared/version.ts'
12
12
  import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
13
13
  import { TaskDetail } from './TaskDetail.tsx'
14
14
  import { TaskFormModal } from './TaskFormModal.tsx'
15
+ import { ImportModal } from './ImportModal.tsx'
16
+ import { TemplateManager } from './TemplateManager.tsx'
15
17
  import { useAlert } from './AlertModal.tsx'
16
18
 
17
19
  /** Column labels. */
@@ -85,15 +87,50 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
85
87
  const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
86
88
  const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
87
89
  const { alert: showAlert, el: alertEl } = useAlert()
90
+ // + 新建任务 ▼ dropdown (0.4.0): blank / templates / manage / import.
91
+ const [newMenuOpen, setNewMenuOpen] = useState(false)
92
+ const closeMenu = (): void => setNewMenuOpen(false)
88
93
 
89
94
  return (
90
95
  <div className="dsh-atb-board">
91
96
  <div className="dsh-atb-toolbar">
92
97
  <h2 className="dsh-atb-title">Agent 任务看板</h2>
93
98
  <span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
94
- <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
95
- + 新建任务
96
- </button>
99
+ <div className="dsh-atb-newmenu">
100
+ <button
101
+ type="button"
102
+ className="dsh-atb-btn"
103
+ data-primary="true"
104
+ onClick={() => {
105
+ const next = !newMenuOpen
106
+ setNewMenuOpen(next)
107
+ if (next) controller.prepareTemplateMenu()
108
+ }}
109
+ >
110
+ + 新建任务 ▼
111
+ </button>
112
+ {newMenuOpen && (
113
+ <>
114
+ <div className="dsh-atb-newmenu-backdrop" onClick={closeMenu} />
115
+ <div className="dsh-atb-newmenu-list">
116
+ <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>空白任务</button>
117
+ {state.templates.map(t => (
118
+ <button
119
+ key={t.id}
120
+ type="button"
121
+ className="dsh-atb-newmenu-opt"
122
+ title={t.task.description !== undefined && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name}
123
+ onClick={() => { closeMenu(); controller.newFromTemplate(t.task) }}
124
+ >
125
+ {t.name}{t.builtin === true ? '' : ''}
126
+ </button>
127
+ ))}
128
+ <div className="dsh-atb-newmenu-sep" />
129
+ <button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>⌗ 管理模板…</button>
130
+ </div>
131
+ </>
132
+ )}
133
+ </div>
97
134
  <div className="dsh-atb-spacer" />
98
135
  <input
99
136
  className="dsh-atb-input dsh-atb-search"
@@ -138,6 +175,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
138
175
  {state.secondaryOpen ? '返回看板' : '其它任务'}
139
176
  </button>
140
177
  <button type="button" className="dsh-atb-btn" title="健康诊断:遗留 worktree、台账基本项" onClick={() => controller.openDiagnostics()}>⚙ 诊断</button>
178
+ <button type="button" className="dsh-atb-btn" title="从 JSON 备份文件导入台账(预览后合并或整册替换)" onClick={() => controller.openImport()}>⬆ 导入</button>
141
179
  <button type="button" className="dsh-atb-btn" title="下载完整台账备份(JSON)" onClick={() => controller.exportJson()}>⬇ JSON</button>
142
180
  <button type="button" className="dsh-atb-btn" title="下载任务清单(CSV)" onClick={() => controller.exportCsv()}>⬇ CSV</button>
143
181
  <a
@@ -223,6 +261,10 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
223
261
 
224
262
  {state.diagOpen && <DiagnosticsPanel controller={controller} />}
225
263
 
264
+ {state.importOpen && <ImportModal controller={controller} />}
265
+
266
+ {state.tplManagerOpen && <TemplateManager controller={controller} />}
267
+
226
268
  {alertEl}
227
269
  </div>
228
270
  )
@@ -86,6 +86,15 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
86
86
  <span className="dsh-atb-badge" data-kind="scheduled">⏰ {fmtTime(task.execution.nextRunAt)}</span>
87
87
  )}
88
88
  {task.model !== undefined && <span className="dsh-atb-badge">{task.model.model}</span>}
89
+ {task.checklist !== undefined && task.checklist.length > 0 && (
90
+ <span
91
+ className="dsh-atb-badge"
92
+ data-kind={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? 'blocked' : 'checklist'}
93
+ title={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? '待验收:清单未全部勾选' : '验收清单进度'}
94
+ >
95
+ ☑ {task.checklist.filter(i => i.checked).length}/{task.checklist.length}
96
+ </span>
97
+ )}
89
98
  {task.status === 'done' && <span className="dsh-atb-badge" data-kind="done">完成</span>}
90
99
  {last !== undefined && (
91
100
  <span className="dsh-atb-badge" data-kind={last.outcome === 'running' ? 'running' : last.outcome}>
@@ -7,10 +7,10 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/board/TaskDetail
9
9
  */
10
- import { useState, type ReactNode } from 'react'
10
+ import { useEffect, useState, type ReactNode } from 'react'
11
11
  import type { BoardController } from '../controller.ts'
12
12
  import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
13
- import { canTransition } from '../../shared/protocol.ts'
13
+ import { canTransition, checklistProgress } from '../../shared/protocol.ts'
14
14
  import { useAlert } from './AlertModal.tsx'
15
15
  import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
16
16
 
@@ -58,6 +58,129 @@ function shortHash(hash: string | undefined): string {
58
58
  return hash === undefined ? '' : hash.slice(0, 8)
59
59
  }
60
60
 
61
+ /** Extract the path from one `git status --porcelain` line (rename-aware). */
62
+ function porcelainPath(line: string): string {
63
+ let p = line.slice(3)
64
+ const arrow = p.indexOf(' -> ')
65
+ if (arrow >= 0) p = p.slice(arrow + 4)
66
+ if (p.startsWith('"') && p.endsWith('"') && p.length > 1) p = p.slice(1, -1)
67
+ return p
68
+ }
69
+
70
+ /**
71
+ * Lazy diff viewer (0.4.0): loads on mount, renders inside a capped <pre>.
72
+ * @param spec - what to show: one commit hash, or one changed path.
73
+ */
74
+ function DiffView({ controller, task, execution, commit, path }: {
75
+ controller: BoardController
76
+ task: TaskRecord
77
+ execution: ExecutionRecord
78
+ commit?: string
79
+ path?: string
80
+ }) {
81
+ const [state, setState] = useState<{ loading: boolean; diff?: string; truncated?: boolean; failed?: boolean }>({ loading: true })
82
+ useEffect(() => {
83
+ let alive = true
84
+ setState({ loading: true })
85
+ void controller.fetchDiff(task.id, { execution: execution.id, ...(commit !== undefined ? { commit } : { path: path ?? '' }) }).then(result => {
86
+ if (!alive) return
87
+ if (result === undefined) setState({ loading: false, failed: true })
88
+ else setState({ loading: false, diff: result.diff, truncated: result.truncated })
89
+ })
90
+ return () => { alive = false }
91
+ }, [controller, task.id, execution.id, commit, path])
92
+ return (
93
+ <div className="dsh-atb-diffview">
94
+ <div className="dsh-atb-diffview-head">
95
+ <span className="dsh-atb-diffview-title">{commit !== undefined ? `提交 ${shortHash(commit)}` : `文件 ${path}`}</span>
96
+ {state.loading && <span className="dsh-atb-diffview-hint">读取中…</span>}
97
+ {state.truncated === true && <span className="dsh-atb-diffview-hint">⚠ 内容过长已截断</span>}
98
+ </div>
99
+ {state.failed === true
100
+ ? <div className="dsh-atb-diffview-error">获取失败(原因见看板顶部错误条;对象可能已随 worktree 删除丢失)</div>
101
+ : <pre className="dsh-atb-diffview-pre">{state.diff ?? ''}</pre>}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ /**
107
+ * The DoD checklist block (0.4.0): user-togglable items, checker + evidence
108
+ * per row; unchecked items highlight while the task sits in in_review.
109
+ */
110
+ function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: BoardController }) {
111
+ const items = task.checklist ?? []
112
+ if (items.length === 0) return null
113
+ const { done, total } = checklistProgress(task)
114
+ const unchecked = total - done
115
+ const reviewing = task.status === 'in_review'
116
+ return (
117
+ <div className="dsh-atb-fieldcard" data-kind="checklist">
118
+ <div className="dsh-atb-fieldcard-label">
119
+ 验收清单(DoD)
120
+ <span className="dsh-atb-cl-progress" data-tone={reviewing && unchecked > 0 ? 'bad' : undefined}>
121
+ ☑ {done}/{total}{reviewing && unchecked > 0 ? ` · ${unchecked} 项未完成` : done === total ? ' · 全部完成' : ''}
122
+ </span>
123
+ </div>
124
+ <div className="dsh-atb-cl-items">
125
+ {items.map(item => (
126
+ <label
127
+ key={item.id}
128
+ className="dsh-atb-cl-item"
129
+ data-checked={item.checked ? 'true' : undefined}
130
+ data-alert={reviewing && !item.checked ? 'true' : undefined}
131
+ >
132
+ <input
133
+ type="checkbox"
134
+ checked={item.checked}
135
+ onChange={() => void controller.toggleChecklistItem(task, item.id)}
136
+ />
137
+ <span className="dsh-atb-cl-text">{item.text}</span>
138
+ <span className="dsh-atb-cl-meta">
139
+ {item.checked
140
+ ? `${item.checkedBy === 'user' ? '👤 用户' : `🤖 ${shortId(item.checkedBy)}`} · ${fmtTime(item.checkedAt)}`
141
+ : '未完成'}
142
+ {item.note !== undefined && item.note.length > 0 && <span className="dsh-atb-cl-note" title={item.note}>证据:{item.note}</span>}
143
+ </span>
144
+ </label>
145
+ ))}
146
+ </div>
147
+ </div>
148
+ )
149
+ }
150
+
151
+ /**
152
+ * The structured execution report block (0.4.0): the newest execution that
153
+ * carries one, rendered section by section for the reviewer.
154
+ */
155
+ function ReportBlock({ task }: { task: TaskRecord }) {
156
+ const execution = [...task.executions].reverse().find(e => e.report !== undefined)
157
+ const report = execution?.report
158
+ if (execution === undefined || report === undefined) return null
159
+ const section = (label: string, rows: string[] | undefined): ReactNode => rows !== undefined && rows.length > 0
160
+ ? (
161
+ <div className="dsh-atb-rpt-sec">
162
+ <div className="dsh-atb-rpt-label">{label}</div>
163
+ <ul className="dsh-atb-rpt-list">{rows.map((row, i) => <li key={i}>{row}</li>)}</ul>
164
+ </div>
165
+ )
166
+ : null
167
+ return (
168
+ <div className="dsh-atb-fieldcard" data-kind="report">
169
+ <div className="dsh-atb-fieldcard-label">执行报告<span className="dsh-atb-cl-progress">由执行会话提交 · {fmtTime(execution.endedAt ?? execution.startedAt)}</span></div>
170
+ <div className="dsh-atb-rpt-summary">{report.summary}</div>
171
+ {section('改动文件', report.changedFiles)}
172
+ {section('自验情况', report.checks)}
173
+ {section('产物', report.artifacts)}
174
+ {report.risk.length > 0 && (
175
+ <div className="dsh-atb-rpt-sec">
176
+ <div className="dsh-atb-rpt-label">剩余风险</div>
177
+ <div className="dsh-atb-rpt-risk">{report.risk}</div>
178
+ </div>
179
+ )}
180
+ </div>
181
+ )
182
+ }
183
+
61
184
  /**
62
185
  * The 0.3.0 isolation block: branch / baseline→head commits / change stats /
63
186
  * uncommitted-changes warning, plus the user-only git actions (merge /
@@ -68,6 +191,9 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
68
191
  const [confirmMerge, setConfirmMerge] = useState(false)
69
192
  const [confirmRemove, setConfirmRemove] = useState<'wt' | 'wtb' | null>(null)
70
193
  const [busy, setBusy] = useState(false)
194
+ // Diff viewer (0.4.0): which commit / changed path is expanded.
195
+ const [openDiff, setOpenDiff] = useState<{ commit?: string; path?: string } | null>(null)
196
+ const [dirtyOpen, setDirtyOpen] = useState(false)
71
197
  const execution = latestIsolated(task)
72
198
  const running = task.executions.some(e => e.outcome === 'running')
73
199
  if (execution === undefined) return null
@@ -124,9 +250,19 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
124
250
  ? (
125
251
  <div className="dsh-atb-iso-commits">
126
252
  {commits.slice(0, 10).map(c => (
127
- <div key={c.hash} className="dsh-atb-iso-commit">
128
- <code>{shortHash(c.hash)}</code>
129
- <span>{c.subject}</span>
253
+ <div key={c.hash} className="dsh-atb-iso-commit" data-open={openDiff?.commit === c.hash ? 'true' : undefined}>
254
+ <button
255
+ type="button"
256
+ className="dsh-atb-iso-commit-btn"
257
+ title="点击展开该提交的 diff"
258
+ onClick={() => setOpenDiff(openDiff?.commit === c.hash ? null : { commit: c.hash })}
259
+ >
260
+ <code>{shortHash(c.hash)}</code>
261
+ <span>{c.subject}</span>
262
+ </button>
263
+ {openDiff?.commit === c.hash && (
264
+ <DiffView controller={controller} task={task} execution={execution} commit={c.hash} />
265
+ )}
130
266
  </div>
131
267
  ))}
132
268
  {commitTotal > 10 && <div className="dsh-atb-iso-more">… 共 {commitTotal} 个提交</div>}
@@ -135,8 +271,32 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
135
271
  : <div className="dsh-atb-iso-nocommit">该次执行没有产生提交(改动可能未提交,见下方警告)</div>}
136
272
 
137
273
  {dirtyTotal > 0 && (
138
- <div className="dsh-atb-iso-dirty" title={dirty.join('\n')}>
139
- {dirtyTotal} 处未提交修改(合并前请让 agent 提交,或手动处理)
274
+ <div className="dsh-atb-iso-dirty">
275
+ <button type="button" className="dsh-atb-iso-dirty-toggle" onClick={() => setDirtyOpen(!dirtyOpen)}>
276
+ ⚠ 有 {dirtyTotal} 处未提交修改(合并前请让 agent 提交,或手动处理){dirtyOpen ? ' ▲' : ' ▼ 查看文件'}
277
+ </button>
278
+ {dirtyOpen && (
279
+ <div className="dsh-atb-iso-dirty-files">
280
+ {dirty.slice(0, 30).map((line, index) => {
281
+ const filePath = porcelainPath(line)
282
+ return (
283
+ <button
284
+ key={`${line}-${index}`}
285
+ type="button"
286
+ className="dsh-atb-iso-dirty-file"
287
+ title="点击查看该文件的未提交 diff"
288
+ onClick={() => setOpenDiff(openDiff?.path === filePath ? null : { path: filePath })}
289
+ >
290
+ <code>{line.slice(0, 2)}</code> {filePath}
291
+ </button>
292
+ )
293
+ })}
294
+ {dirtyTotal > 30 && <div className="dsh-atb-iso-more">… 共 {dirtyTotal} 处(完整列表见任务台账)</div>}
295
+ </div>
296
+ )}
297
+ {openDiff?.path !== undefined && dirtyOpen && (
298
+ <DiffView controller={controller} task={task} execution={execution} path={openDiff.path} />
299
+ )}
140
300
  </div>
141
301
  )}
142
302
 
@@ -220,6 +380,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
220
380
  const runningExecution = task.executions.find(e => e.outcome === 'running')
221
381
  const holder = task.status === 'in_progress' ? task.claimedBy : undefined
222
382
  const stale = now !== undefined && isStaleClaim(task, now)
383
+ const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
223
384
 
224
385
  /** Jump to an execution's session; prompt precisely when it cannot open. */
225
386
  const jumpToSession = (sessionId: string): void => {
@@ -247,6 +408,11 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
247
408
  <Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
248
409
  )}
249
410
  {task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
411
+ {task.checklist !== undefined && task.checklist.length > 0 && (
412
+ <Chip icon="☑" tone={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? 'urgent' : undefined}>
413
+ 清单 {checklistProgress(task).done}/{task.checklist.length}
414
+ </Chip>
415
+ )}
250
416
  {task.branch !== undefined && (
251
417
  <Chip icon="🌿" tone={undefined}>Worktree · {task.branch.length > 28 ? `${task.branch.slice(0, 28)}…` : task.branch}</Chip>
252
418
  )}
@@ -273,6 +439,18 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
273
439
  >
274
440
  ⧉ 复制
275
441
  </button>
442
+ <button
443
+ type="button"
444
+ className="dsh-atb-detail-edit"
445
+ title="把此任务的配置(含清单)保存为模板,新建任务时可用"
446
+ onClick={() => {
447
+ void controller.saveAsTemplate(task).then(ok => {
448
+ if (ok) showAlert('已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)')
449
+ })
450
+ }}
451
+ >
452
+ ⌗ 存为模板
453
+ </button>
276
454
  {canRun && task.branch !== undefined && (
277
455
  <button
278
456
  type="button"
@@ -332,13 +510,19 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
332
510
 
333
511
  <IsolationBlock task={task} controller={controller} />
334
512
 
513
+ <ReportBlock task={task} />
514
+
515
+ <ChecklistBlock task={task} controller={controller} />
516
+
335
517
  <div className="dsh-atb-detail-actions">
336
518
  <div className="dsh-atb-movebtns">
337
519
  {moveTargets(task).map(to => to === 'done'
338
520
  ? (confirmDone
339
521
  ? (
340
522
  <span key={to} className="dsh-atb-confirm">
341
- <span className="dsh-atb-confirm-label">确认完成?</span>
523
+ <span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
524
+ {unchecked > 0 ? `仍有 ${unchecked} 项清单未勾选,确认完成?` : '确认完成?'}
525
+ </span>
342
526
  <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => { void controller.move(task.id, task.version, 'done'); setConfirmDone(false) }}>确认</button>
343
527
  <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
344
528
  </span>