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.
- package/README.md +24 -1
- package/lib/client.js +434 -193
- package/lib/host/execution.js +80 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +49 -5
- package/lib/host/git.js.map +1 -1
- package/lib/host/routes.js +210 -112
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +50 -28
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/sdk.js +7 -2
- package/lib/host/sdk.js.map +1 -1
- package/lib/host/store.js +41 -8
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +10 -3
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +128 -97
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +48 -5
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +9 -8
- package/src/client/api.ts +26 -8
- package/src/client/board/ImportModal.tsx +1 -1
- package/src/client/board/SettingsModal.tsx +84 -0
- package/src/client/board/TaskBoard.tsx +47 -40
- package/src/client/board/TaskCard.tsx +3 -5
- package/src/client/board/TaskDetail.tsx +30 -21
- package/src/client/board/TaskFormModal.tsx +39 -31
- package/src/client/board/format.ts +26 -0
- package/src/client/board/labels.ts +44 -0
- package/src/client/controller.ts +86 -34
- package/src/client/index.ts +7 -5
- package/src/client/sidebar-entry.ts +5 -1
- package/src/client/styles.ts +4 -0
- package/src/host/execution.ts +90 -16
- package/src/host/git.ts +39 -10
- package/src/host/routes.ts +263 -128
- package/src/host/scheduler.ts +62 -36
- package/src/host/sdk.ts +12 -1
- package/src/host/store.ts +53 -7
- package/src/host/templates.ts +12 -3
- package/src/host/tools.ts +187 -126
- package/src/index.ts +10 -1
- package/src/shared/api.ts +11 -2
- package/src/shared/protocol.ts +83 -6
- package/src/shared/version.ts +1 -1
- 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}
|
|
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
|
-
{
|
|
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
|
-
<
|
|
180
|
-
|
|
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
|
-
|
|
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 './
|
|
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 =
|
|
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 './
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={() => {
|
|
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 './
|
|
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
|
|
138
|
-
//
|
|
139
|
-
// once execution began.
|
|
140
|
-
const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' :
|
|
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
|
|
162
|
+
// Model catalog: the controller exposes the installed face when the runtime is up.
|
|
160
163
|
useEffect(() => {
|
|
161
|
-
const face =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
+
}
|