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
@@ -11,8 +11,9 @@
11
11
  import { useEffect, useRef, useState, type ReactNode } from 'react'
12
12
  import type { BoardController } from '../controller.ts'
13
13
  import { loadDefaultIsolation, saveDefaultIsolation } from '../controller.ts'
14
- import type { IsolationMode, Urgency } from '../../shared/protocol.ts'
15
- import { nextCronTime, parseCron } from '../../shared/protocol.ts'
14
+ import type { TaskTemplateSpec } from '../../shared/api.ts'
15
+ import type { ChecklistItem, IsolationMode, Urgency } from '../../shared/protocol.ts'
16
+ import { MAX_CHECKLIST_ITEMS, nextCronTime, parseCron } from '../../shared/protocol.ts'
16
17
  import { fmtTime } from './TaskBoard.tsx'
17
18
 
18
19
  /** One row of the configured model catalog (from llm.models). */
@@ -51,33 +52,98 @@ function Field({ label, required = false, full = false, children }: {
51
52
  )
52
53
  }
53
54
 
55
+ /** One editable checklist row (create: fresh unchecked; edit: preserved ids/flags). */
56
+ interface CheckRow {
57
+ id?: string
58
+ text: string
59
+ checked: boolean
60
+ checkedBy?: string
61
+ checkedAt?: number
62
+ note?: string
63
+ }
64
+
65
+ /**
66
+ * The checklist (DoD) editor: toggle + text + remove per row, add button,
67
+ * cap-enforced. Edit mode preserves checked state and notes (the GUI
68
+ * replaces the whole list on save).
69
+ */
70
+ function ChecklistEditor({ rows, onChange, editing }: { rows: CheckRow[]; onChange: (rows: CheckRow[]) => void; editing: boolean }) {
71
+ const setRow = (index: number, patch: Partial<CheckRow>): void => {
72
+ const next = rows.map((row, i) => i === index ? { ...row, ...patch } : row)
73
+ onChange(next)
74
+ }
75
+ const checked = rows.filter(r => r.checked).length
76
+ return (
77
+ <div className="dsh-atb-cke">
78
+ {rows.map((row, index) => (
79
+ <div key={row.id ?? `new-${index}`} className="dsh-atb-cke-row">
80
+ {editing && (
81
+ <input
82
+ type="checkbox"
83
+ className="dsh-atb-cke-box"
84
+ checked={row.checked}
85
+ title={`勾选状态随保存保留(当前勾选人:${row.checkedBy ?? '未勾选'})`}
86
+ onChange={e => setRow(index, { checked: e.target.checked })}
87
+ />
88
+ )}
89
+ <input
90
+ className="dsh-atb-cke-text"
91
+ value={row.text}
92
+ maxLength={200}
93
+ placeholder={`验收项 ${index + 1}(完成标准)`}
94
+ spellCheck={false}
95
+ onChange={e => setRow(index, { text: e.target.value })}
96
+ />
97
+ <button type="button" className="dsh-atb-cke-del" title="删除该验收项" onClick={() => onChange(rows.filter((_, i) => i !== index))}>✕</button>
98
+ </div>
99
+ ))}
100
+ {rows.length < MAX_CHECKLIST_ITEMS && (
101
+ <button type="button" className="dsh-atb-cke-add" onClick={() => onChange([...rows, { text: '', checked: false }])}>+ 添加验收项</button>
102
+ )}
103
+ {rows.length > 0 && (
104
+ <span className="dsh-atb-cke-hint">{editing ? `已勾选 ${checked}/${rows.length}(保存将整体覆盖清单,勾选状态保留)` : `共 ${rows.length} 项,执行会话按清单干活并逐项勾选,未完成项验收时高亮`}</span>
105
+ )}
106
+ </div>
107
+ )
108
+ }
109
+
54
110
  /**
55
- * The form modal. Without `task` it composes a new task; with `task` it
56
- * edits that record (project, urgency, execution, model included the GUI
57
- * is the owner surface).
111
+ * The form modal. Without `task` it composes a new task (optionally
112
+ * prefilled from a chosen template); with `task` it edits that record
113
+ * (project, urgency, execution, model included — the GUI is the owner
114
+ * surface).
58
115
  * @param controller - the controller.
59
116
  * @param task - the task being edited (create mode when absent).
60
117
  */
61
118
  export function TaskFormModal({ controller, task }: { controller: BoardController; task?: TaskRecordLike }) {
62
119
  const state = controller.getSnapshot()
120
+ const prefill: TaskTemplateSpec | undefined = state.templatePrefill
63
121
  const editing = task !== undefined
64
- const [title, setTitle] = useState(task?.title ?? '')
65
- const [description, setDescription] = useState(task?.description ?? '')
66
- const [prompt, setPrompt] = useState(task?.prompt ?? '')
122
+ const [title, setTitle] = useState(task?.title ?? prefill?.title ?? '')
123
+ const [description, setDescription] = useState(task?.description ?? prefill?.description ?? '')
124
+ const [prompt, setPrompt] = useState(task?.prompt ?? prefill?.prompt ?? '')
67
125
  const [workspaceId, setWorkspaceId] = useState(task?.workspaceId ?? state.filters.workspaceId ?? state.workspaces[0]?.id ?? '')
68
- const [urgency, setUrgency] = useState<Urgency>(task?.urgency ?? 'normal')
69
- const [mode, setMode] = useState<'claim' | 'scheduled'>(task?.execution.mode === 'scheduled' ? 'scheduled' : 'claim')
70
- const [cron, setCron] = useState(task?.execution.cron ?? '0 9 * * *')
126
+ const [urgency, setUrgency] = useState<Urgency>(task?.urgency ?? (prefill?.urgency === 'urgent' || prefill?.urgency === 'relaxed' ? prefill.urgency : 'normal'))
127
+ const [mode, setMode] = useState<'claim' | 'scheduled'>(task?.execution.mode === 'scheduled' || prefill?.execution?.mode === 'scheduled' ? 'scheduled' : 'claim')
128
+ const [cron, setCron] = useState(task?.execution.cron ?? prefill?.execution?.cron ?? '0 9 * * *')
71
129
  const [catalog, setCatalog] = useState<CatalogModel[]>([])
72
- const [model, setModel] = useState(task?.model !== undefined ? JSON.stringify(task.model) : '')
130
+ const [model, setModel] = useState(task?.model !== undefined || prefill?.model !== undefined ? JSON.stringify(task?.model ?? prefill?.model) : '')
73
131
  // Preset roster (0.3.3): create mode PRE-SELECTS the deployment default
74
132
  // (标准模式 in this deployment); '' = 跟随部署默认 (submit omits the field).
75
- const [presetId, setPresetId] = useState(task?.presetId ?? '')
133
+ const initialPreset = task?.presetId ?? prefill?.presetId ?? ''
134
+ const [presetId, setPresetId] = useState(initialPreset)
76
135
  const [presets, setPresets] = useState<Array<{ id: string; name?: string }>>([])
77
136
  const [presetDefault, setPresetDefault] = useState<string | undefined>(undefined)
78
137
  // Isolation toggle: create mode starts from the remembered choice (default
79
- // on); edit mode starts from the task and locks once execution began.
80
- const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? loadDefaultIsolation())
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()))
141
+ // Checklist (0.4.0): create = template texts / blank rows; edit = live items.
142
+ const [checkRows, setCheckRows] = useState<CheckRow[]>(
143
+ task?.checklist !== undefined && task.checklist.length > 0
144
+ ? task.checklist.map(i => ({ ...i }))
145
+ : (prefill?.checklist ?? []).map(text => ({ text, checked: false })),
146
+ )
81
147
  const titleRef = useRef<HTMLInputElement>(null)
82
148
 
83
149
  // Focus the title and close on Esc while the dialog is open.
@@ -98,16 +164,17 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
98
164
  }, [controller])
99
165
 
100
166
  // Preset roster: same lazy face; pre-select the deployment default in
101
- // create mode so executions run with a real tool set out of the box.
167
+ // create mode (unless a template pinned one) so executions run with a
168
+ // real tool set out of the box.
102
169
  useEffect(() => {
103
170
  const face = (controller as unknown as { presetCatalog?: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }> }).presetCatalog
104
171
  if (face === undefined) return
105
172
  void face().then(roster => {
106
173
  setPresets(roster.presets)
107
174
  setPresetDefault(roster.defaultId)
108
- if (task?.presetId === undefined && roster.defaultId !== undefined) setPresetId(roster.defaultId)
175
+ if (task?.presetId === undefined && initialPreset === '' && roster.defaultId !== undefined) setPresetId(roster.defaultId)
109
176
  }).catch(() => setPresets([]))
110
- }, [controller, task?.presetId])
177
+ }, [controller, task?.presetId, initialPreset])
111
178
 
112
179
  // Live cron validation + next-run preview (same math as the host).
113
180
  const cronMatch = mode === 'scheduled' ? parseCron(cron.trim()) : null
@@ -136,11 +203,15 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
136
203
  /** Preset payload: '' = follow the deployment default (submit omits). */
137
204
  const presetPayload = (): string | undefined => (presetId.trim().length > 0 ? presetId.trim() : undefined)
138
205
 
206
+ /** Checklist rows with non-empty text (blank rows are dropped on submit). */
207
+ const filledRows = (): CheckRow[] => checkRows.map(r => ({ ...r, text: r.text.trim() })).filter(r => r.text.length > 0)
208
+
139
209
  const submit = (): void => {
140
210
  if (!valid) return
141
211
  const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
142
212
  const isolationOut = isolationPayload()
143
213
  const presetOut = presetPayload()
214
+ const rows = filledRows()
144
215
  if (editing) {
145
216
  void controller.update(task.id, task.version, {
146
217
  title,
@@ -153,6 +224,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
153
224
  model: picked ?? null,
154
225
  ...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
155
226
  presetId: presetOut ?? null,
227
+ // [] clears the checklist (host deletes the field on empty).
228
+ checklist: rows.length > 0 ? rows : null,
156
229
  })
157
230
  } else {
158
231
  void controller.create({
@@ -165,6 +238,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
165
238
  model: picked,
166
239
  ...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
167
240
  ...(presetOut !== undefined ? { presetId: presetOut } : {}),
241
+ ...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
168
242
  })
169
243
  }
170
244
  }
@@ -175,6 +249,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
175
249
  const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
176
250
  const isolationOut = isolationPayload()
177
251
  const presetOut = presetPayload()
252
+ const rows = filledRows()
178
253
  if (editing) {
179
254
  void (async () => {
180
255
  const saved = await controller.update(task.id, task.version, {
@@ -187,6 +262,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
187
262
  model: picked ?? null,
188
263
  ...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
189
264
  presetId: presetOut ?? null,
265
+ checklist: rows.length > 0 ? rows : null,
190
266
  })
191
267
  if (saved) await controller.run(task.id)
192
268
  })()
@@ -202,6 +278,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
202
278
  model: picked,
203
279
  ...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
204
280
  ...(presetOut !== undefined ? { presetId: presetOut } : {}),
281
+ ...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
205
282
  })
206
283
  if (id !== undefined) await controller.run(id)
207
284
  })()
@@ -359,6 +436,10 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
359
436
  <span className="dsh-atb-isolation-note">当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)</span>
360
437
  )}
361
438
  </Field>
439
+
440
+ <Field label={editing ? '验收清单(DoD)' : '验收清单(DoD,可选)'} full>
441
+ <ChecklistEditor rows={checkRows} onChange={setCheckRows} editing={editing} />
442
+ </Field>
362
443
  </div>
363
444
 
364
445
  <div className="dsh-atb-modal-foot">
@@ -398,5 +479,6 @@ interface TaskRecordLike {
398
479
  model?: { provider: string; model: string }
399
480
  isolation?: IsolationMode
400
481
  presetId?: string
482
+ checklist?: ChecklistItem[]
401
483
  executions?: unknown[]
402
484
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The template-manager modal (0.4.0): rename / delete / use the stored task
3
+ * templates. Templates live host-side (side file next to the ledger) and
4
+ * prefill the create form from the + 新建任务 ▼ dropdown.
5
+ *
6
+ * @module dsh-taskboard/client/board/TemplateManager
7
+ */
8
+ import { useState } from 'react'
9
+ import type { BoardController } from '../controller.ts'
10
+ import { useAlert } from './AlertModal.tsx'
11
+
12
+ /**
13
+ * The template manager modal.
14
+ * @param controller - the controller.
15
+ */
16
+ export function TemplateManager({ controller }: { controller: BoardController }) {
17
+ const state = controller.getSnapshot()
18
+ const [edits, setEdits] = useState<Record<string, string>>({})
19
+ const [confirmId, setConfirmId] = useState<string | undefined>(undefined)
20
+ const { alert: showAlert, el: alertEl } = useAlert()
21
+
22
+ const close = (): void => controller.closeTemplateManager()
23
+
24
+ const nameOf = (id: string, fallback: string): string => edits[id] ?? fallback
25
+
26
+ /** Save one template's rename. */
27
+ const save = (id: string, name: string): void => {
28
+ const template = state.templates.find(t => t.id === id)
29
+ if (template === undefined || name === template.name) return
30
+ void controller.upsertTemplate({ id, name, task: template.task }).then(ok => {
31
+ if (ok) {
32
+ setEdits(prev => { const next = { ...prev }; delete next[id]; return next })
33
+ showAlert('模板已改名')
34
+ }
35
+ })
36
+ }
37
+
38
+ return (
39
+ <div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close() }}>
40
+ <div className="dsh-atb-modal dsh-atb-tplm" role="dialog" aria-modal="true" aria-label="管理模板">
41
+ <div className="dsh-atb-modal-head">
42
+ <span className="dsh-atb-modal-headicon">⌗</span>
43
+ <div className="dsh-atb-modal-headtext">
44
+ <h3>任务模板</h3>
45
+ <p>新建任务 ▼ 下拉的模板:改名 / 删除 / 直接使用;任务详情页「存为模板」可新增</p>
46
+ </div>
47
+ <button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={close}>✕</button>
48
+ </div>
49
+ <div className="dsh-atb-modal-body">
50
+ {state.templates.length === 0
51
+ ? <div className="dsh-atb-empty2">暂无模板 — 在任务详情页点「存为模板」把常用配置沉淀下来</div>
52
+ : (
53
+ <div className="dsh-atb-tplm-list">
54
+ {state.templates.map(t => (
55
+ <div key={t.id} className="dsh-atb-tplm-row">
56
+ <input
57
+ className="dsh-atb-tplm-name"
58
+ value={nameOf(t.id, t.name)}
59
+ maxLength={60}
60
+ spellCheck={false}
61
+ aria-label={`模板名 ${t.name}`}
62
+ onChange={e => setEdits(prev => ({ ...prev, [t.id]: e.target.value }))}
63
+ onKeyDown={e => {
64
+ if (e.key === 'Enter') save(t.id, nameOf(t.id, t.name))
65
+ }}
66
+ />
67
+ <span className="dsh-atb-tplm-meta">
68
+ {t.builtin === true ? '内置' : '自建'}
69
+ {t.task.checklist !== undefined && t.task.checklist.length > 0 ? ` · 清单 ${t.task.checklist.length} 项` : ''}
70
+ {t.task.urgency !== undefined ? ` · ${t.task.urgency}` : ''}
71
+ </span>
72
+ <span className="dsh-atb-tplm-btns">
73
+ <button
74
+ type="button"
75
+ className="dsh-atb-btn"
76
+ disabled={nameOf(t.id, t.name) === t.name || nameOf(t.id, t.name).trim().length === 0}
77
+ title="保存改名"
78
+ onClick={() => save(t.id, nameOf(t.id, t.name))}
79
+ >
80
+ 改名
81
+ </button>
82
+ <button
83
+ type="button"
84
+ className="dsh-atb-btn"
85
+ title="用此模板打开新建表单"
86
+ onClick={() => {
87
+ close()
88
+ controller.newFromTemplate(t.task)
89
+ }}
90
+ >
91
+ 用此新建
92
+ </button>
93
+ {confirmId === t.id
94
+ ? (
95
+ <>
96
+ <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.deleteTemplate(t.id); setConfirmId(undefined) }}>确认删除</button>
97
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmId(undefined)}>取消</button>
98
+ </>
99
+ )
100
+ : (
101
+ <button type="button" className="dsh-atb-btn" data-danger="true" title="删除该模板" onClick={() => setConfirmId(t.id)}>
102
+ 🗑
103
+ </button>
104
+ )}
105
+ </span>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ <div className="dsh-atb-modal-foot">
112
+ <span className="dsh-atb-modal-hint">模板随台账一同保存在 DSH 主目录,升级不丢</span>
113
+ <span className="dsh-atb-modal-footbtns">
114
+ <button type="button" className="dsh-atb-btn" onClick={close}>关闭</button>
115
+ </span>
116
+ </div>
117
+ </div>
118
+ {alertEl}
119
+ </div>
120
+ )
121
+ }
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/controller
9
9
  */
10
- import type { ChangeEvent, DiagnosticsResponse, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
- import type { IsolationMode, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
10
+ import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
+ import type { ChecklistItem, IsolationMode, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
12
  import { emptyLedger } from '../shared/protocol.ts'
13
13
  import type { TaskboardClient } from './api.ts'
14
14
  import type { SessionJumpResult } from './session-jump.ts'
@@ -86,6 +86,14 @@ export interface ControllerState {
86
86
  diagOpen: boolean
87
87
  /** Last fetched diagnostics payload (⚙ panel). */
88
88
  diagnostics?: DiagnosticsResponse
89
+ /** Task templates (0.4.0), lazy-loaded when the new-task menu opens. */
90
+ templates: TaskTemplate[]
91
+ /** Template manager modal visible. */
92
+ tplManagerOpen: boolean
93
+ /** Import modal visible (0.4.0). */
94
+ importOpen: boolean
95
+ /** Fields a chosen template prefills into the create form (consumed on open). */
96
+ templatePrefill?: TaskTemplateSpec
89
97
  /** Transient error surface (action failures); cleared on next success. */
90
98
  error?: string
91
99
  }
@@ -103,6 +111,9 @@ function initialState(): ControllerState {
103
111
  composerOpen: false,
104
112
  secondaryOpen: false,
105
113
  diagOpen: false,
114
+ templates: [],
115
+ tplManagerOpen: false,
116
+ importOpen: false,
106
117
  }
107
118
  }
108
119
 
@@ -234,14 +245,19 @@ export class BoardController {
234
245
  /** Select a task (open detail). */
235
246
  select(id?: string): void { this.setState({ selectedId: id }) }
236
247
 
237
- /** Show/hide the task form (create mode when opening). */
238
- setComposer(open: boolean): void { this.setState({ composerOpen: open, editingId: undefined }) }
248
+ /** Show/hide the task form (create mode when opening); always blank (no template prefill). */
249
+ setComposer(open: boolean): void { this.setState({ composerOpen: open, editingId: undefined, templatePrefill: undefined }) }
239
250
 
240
- /** Open the form modal editing an existing task. */
241
- openEditor(id: string): void { this.setState({ composerOpen: true, editingId: id }) }
251
+ /** Open the create form prefilled from a chosen template (0.4.0). */
252
+ newFromTemplate(spec: TaskTemplateSpec): void {
253
+ this.setState({ composerOpen: true, editingId: undefined, templatePrefill: spec })
254
+ }
255
+
256
+ /** Open the form modal editing an existing task (clears any template prefill). */
257
+ openEditor(id: string): void { this.setState({ composerOpen: true, editingId: id, templatePrefill: undefined }) }
242
258
 
243
259
  /** Close the form modal whatever its mode. */
244
- closeForm(): void { this.setState({ composerOpen: false, editingId: undefined }) }
260
+ closeForm(): void { this.setState({ composerOpen: false, editingId: undefined, templatePrefill: undefined }) }
245
261
 
246
262
  /** Toggle the secondary tab. */
247
263
  toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
@@ -346,6 +362,34 @@ export class BoardController {
346
362
  }
347
363
  }
348
364
 
365
+ /**
366
+ * Toggle one checklist item as the USER (0.4.0): flips the item, records
367
+ * `checkedBy: 'user'`, keeps other items as they are (one update call).
368
+ */
369
+ async toggleChecklistItem(task: TaskRecord, itemId: string): Promise<void> {
370
+ const items: ChecklistItem[] = (task.checklist ?? []).map(item => item.id === itemId
371
+ ? (item.checked
372
+ ? { id: item.id, text: item.text, checked: false }
373
+ : { id: item.id, text: item.text, checked: true, checkedBy: 'user', checkedAt: Date.now(), ...(item.note !== undefined ? { note: item.note } : {}) })
374
+ : item)
375
+ try {
376
+ await this.client.update(task.id, { ifVersion: task.version, checklist: items })
377
+ await this.refresh()
378
+ } catch (error) {
379
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
380
+ }
381
+ }
382
+
383
+ /** Diff view (0.4.0): one execution's commit or changed path; errors surface via throw. */
384
+ async fetchDiff(taskId: string, query: { execution: string; commit?: string; path?: string }): Promise<DiffResponse | undefined> {
385
+ try {
386
+ return await this.client.diff(taskId, query)
387
+ } catch (error) {
388
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
389
+ return undefined
390
+ }
391
+ }
392
+
349
393
  /** Append a user comment. */
350
394
  async comment(id: string, body: string): Promise<void> {
351
395
  try {
@@ -437,7 +481,7 @@ export class BoardController {
437
481
  }
438
482
  }
439
483
 
440
- /** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model/isolation). */
484
+ /** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model/isolation/checklist). */
441
485
  async duplicate(task: TaskRecord): Promise<void> {
442
486
  try {
443
487
  await this.client.create({
@@ -452,6 +496,7 @@ export class BoardController {
452
496
  model: task.model,
453
497
  isolation: task.isolation,
454
498
  ...(task.presetId !== undefined ? { presetId: task.presetId } : {}),
499
+ ...(task.checklist !== undefined && task.checklist.length > 0 ? { checklist: task.checklist.map(i => i.text) } : {}),
455
500
  })
456
501
  await this.refresh()
457
502
  } catch (error) {
@@ -459,6 +504,105 @@ export class BoardController {
459
504
  }
460
505
  }
461
506
 
507
+ // ------------------------------------------------ templates (0.4.0)
508
+ /** Load the template list (best effort; errors surface). */
509
+ async loadTemplates(): Promise<TaskTemplate[]> {
510
+ try {
511
+ const value = await this.client.templates()
512
+ this.setState({ templates: value.templates, error: undefined })
513
+ return value.templates
514
+ } catch (error) {
515
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
516
+ return []
517
+ }
518
+ }
519
+
520
+ /** Open the + 新建任务 dropdown's template list fresh (called on menu open). */
521
+ prepareTemplateMenu(): void {
522
+ if (this.state.templates.length === 0) void this.loadTemplates()
523
+ }
524
+
525
+ /** Open the template manager modal. */
526
+ openTemplateManager(): void {
527
+ this.setState({ tplManagerOpen: true })
528
+ void this.loadTemplates()
529
+ }
530
+
531
+ /** Close the template manager modal. */
532
+ closeTemplateManager(): void { this.setState({ tplManagerOpen: false }) }
533
+
534
+ /** Create or replace a template; refreshes the list. */
535
+ async upsertTemplate(body: { id?: string; name: string; task: TaskTemplateSpec }): Promise<boolean> {
536
+ try {
537
+ await this.client.templateUpsert(body)
538
+ await this.loadTemplates()
539
+ return true
540
+ } catch (error) {
541
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
542
+ return false
543
+ }
544
+ }
545
+
546
+ /** Delete a template by id; refreshes the list. */
547
+ async deleteTemplate(id: string): Promise<void> {
548
+ try {
549
+ await this.client.templateDelete(id)
550
+ await this.loadTemplates()
551
+ } catch (error) {
552
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
553
+ }
554
+ }
555
+
556
+ /** 存为模板 from a task card: carries every configurable field incl. checklist texts. */
557
+ async saveAsTemplate(task: TaskRecord): Promise<boolean> {
558
+ return this.upsertTemplate({
559
+ name: task.title.slice(0, 60),
560
+ task: {
561
+ title: task.title,
562
+ description: task.description.length > 0 ? task.description : undefined,
563
+ prompt: task.prompt.length > 0 ? task.prompt : undefined,
564
+ urgency: task.urgency,
565
+ execution: task.execution.mode === 'scheduled' && task.execution.cron !== undefined
566
+ ? { mode: 'scheduled', cron: task.execution.cron }
567
+ : { mode: 'claim' },
568
+ model: task.model,
569
+ isolation: task.isolation,
570
+ ...(task.presetId !== undefined ? { presetId: task.presetId } : {}),
571
+ ...(task.checklist !== undefined && task.checklist.length > 0 ? { checklist: task.checklist.map(i => i.text) } : {}),
572
+ },
573
+ })
574
+ }
575
+
576
+ // ------------------------------------------------ import (0.4.0)
577
+ /** Open the import modal. */
578
+ openImport(): void { this.setState({ importOpen: true }) }
579
+
580
+ /** Close the import modal. */
581
+ closeImport(): void { this.setState({ importOpen: false }) }
582
+
583
+ /** Dry-run an import file: classify its tasks against the live ledger. */
584
+ async importPreview(file: unknown): Promise<ImportPreviewResponse['plan'] | undefined> {
585
+ try {
586
+ const value = await this.client.importPreview(file)
587
+ return value.plan
588
+ } catch (error) {
589
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
590
+ return undefined
591
+ }
592
+ }
593
+
594
+ /** Commit an import; refreshes the ledger afterwards. */
595
+ async importCommit(mode: 'merge' | 'replace', ledger: unknown): Promise<ImportCommitResponse | undefined> {
596
+ try {
597
+ const value = await this.client.importCommit(mode, ledger)
598
+ await this.refresh()
599
+ return value
600
+ } catch (error) {
601
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
602
+ return undefined
603
+ }
604
+ }
605
+
462
606
  /** Download the whole ledger as a JSON backup file. */
463
607
  exportJson(): void {
464
608
  const stamp = new Date()