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.
- package/README.md +22 -6
- package/lib/client.js +1192 -39
- package/lib/host/execution.js +6 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +95 -2
- package/lib/host/git.js.map +1 -1
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +184 -2
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +202 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +277 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +28 -0
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +45 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +192 -8
- package/src/client/board/TaskFormModal.tsx +100 -18
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +152 -8
- package/src/client/styles.ts +153 -0
- package/src/host/execution.ts +10 -2
- package/src/host/git.ts +77 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +215 -0
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +198 -2
- package/src/index.ts +6 -0
- package/src/shared/api.ts +54 -0
- package/src/shared/protocol.ts +344 -0
- 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 {
|
|
15
|
-
import {
|
|
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
|
|
56
|
-
*
|
|
57
|
-
* is the owner
|
|
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
|
|
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
|
|
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
|
|
80
|
-
|
|
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
|
|
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
|
+
}
|
package/src/client/controller.ts
CHANGED
|
@@ -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
|
|
241
|
-
|
|
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()
|