dsh-taskboard 0.2.2 → 0.3.3
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 +44 -4
- package/lib/client.js +635 -23
- package/lib/host/execution.js +194 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +234 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/routes.js +252 -4
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +16 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +17 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +10 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +19 -3
- package/src/client/board/TaskBoard.tsx +73 -0
- package/src/client/board/TaskDetail.tsx +173 -1
- package/src/client/board/TaskFormModal.tsx +100 -1
- package/src/client/controller.ts +89 -6
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +45 -0
- package/src/host/execution.ts +291 -65
- package/src/host/git.ts +293 -0
- package/src/host/routes.ts +268 -5
- package/src/host/tools.ts +17 -0
- package/src/index.ts +24 -1
- package/src/shared/api.ts +35 -3
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { useState, type ReactNode } from 'react'
|
|
11
11
|
import type { BoardController } from '../controller.ts'
|
|
12
|
-
import type { TaskRecord } from '../../shared/protocol.ts'
|
|
12
|
+
import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
|
|
13
13
|
import { canTransition } from '../../shared/protocol.ts'
|
|
14
14
|
import { useAlert } from './AlertModal.tsx'
|
|
15
15
|
import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
|
|
@@ -48,6 +48,161 @@ function Chip({ icon, children, tone }: { icon?: string; children: ReactNode; to
|
|
|
48
48
|
return <span className="dsh-atb-chip2" data-tone={tone}>{icon !== undefined && <span className="dsh-atb-chip2-icon">{icon}</span>}{children}</span>
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** The most recent execution carrying isolation facts, newest first. */
|
|
52
|
+
function latestIsolated(task: TaskRecord): ExecutionRecord | undefined {
|
|
53
|
+
return [...task.executions].reverse().find(e => e.isolation !== undefined || e.worktreePath !== undefined || e.isolationNote !== undefined)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Short commit hash for display. */
|
|
57
|
+
function shortHash(hash: string | undefined): string {
|
|
58
|
+
return hash === undefined ? '' : hash.slice(0, 8)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The 0.3.0 isolation block: branch / baseline→head commits / change stats /
|
|
63
|
+
* uncommitted-changes warning, plus the user-only git actions (merge /
|
|
64
|
+
* remove worktree — plan §3.3).
|
|
65
|
+
*/
|
|
66
|
+
function IsolationBlock({ task, controller }: { task: TaskRecord; controller: BoardController }) {
|
|
67
|
+
const { alert: showAlert, el: alertEl } = useAlert()
|
|
68
|
+
const [confirmMerge, setConfirmMerge] = useState(false)
|
|
69
|
+
const [confirmRemove, setConfirmRemove] = useState<'wt' | 'wtb' | null>(null)
|
|
70
|
+
const [busy, setBusy] = useState(false)
|
|
71
|
+
const execution = latestIsolated(task)
|
|
72
|
+
const running = task.executions.some(e => e.outcome === 'running')
|
|
73
|
+
if (execution === undefined) return null
|
|
74
|
+
|
|
75
|
+
const doMerge = (): void => {
|
|
76
|
+
setBusy(true)
|
|
77
|
+
void controller.mergeBranch(task.id).then(result => {
|
|
78
|
+
setBusy(false)
|
|
79
|
+
setConfirmMerge(false)
|
|
80
|
+
if (!result.ok) showAlert(`合并失败:${result.error}`)
|
|
81
|
+
else if (result.noop === true) showAlert('该分支没有领先主工作区的新提交,无需合并(可退回续跑或直接清理)')
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const doRemove = (deleteBranch: boolean): void => {
|
|
86
|
+
setBusy(true)
|
|
87
|
+
void controller.removeWorktree(task.id, deleteBranch).then(result => {
|
|
88
|
+
setBusy(false)
|
|
89
|
+
setConfirmRemove(null)
|
|
90
|
+
if (!result.ok) showAlert(`删除失败:${result.error}`)
|
|
91
|
+
else if (result.branchError !== undefined) showAlert(`worktree 已删除,但分支删除失败:${result.branchError}`)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Degraded / off isolation: one quiet line explaining why.
|
|
96
|
+
if (execution.isolation !== 'worktree' || execution.worktreePath === undefined) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="dsh-atb-fieldcard" data-kind="isolation">
|
|
99
|
+
<div className="dsh-atb-fieldcard-label">执行隔离</div>
|
|
100
|
+
<div className="dsh-atb-iso-none">📁 原目录执行{execution.isolationNote !== undefined ? ` · ${execution.isolationNote}` : ''}</div>
|
|
101
|
+
{alertEl}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const commits = execution.commits ?? []
|
|
107
|
+
const commitTotal = execution.commitsTotal ?? commits.length
|
|
108
|
+
const dirty = execution.dirtyFiles ?? []
|
|
109
|
+
const dirtyTotal = execution.dirtyFilesTotal ?? dirty.length
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="dsh-atb-fieldcard" data-kind="isolation">
|
|
113
|
+
<div className="dsh-atb-fieldcard-label">执行隔离 · Worktree</div>
|
|
114
|
+
<div className="dsh-atb-iso-facts">
|
|
115
|
+
<span className="dsh-atb-iso-fact" title={execution.worktreePath}>🌿 分支 <b>{execution.branch ?? task.branch}</b></span>
|
|
116
|
+
<span className="dsh-atb-iso-fact">基线 {shortHash(execution.baseCommit)} → {shortHash(execution.headCommit)}</span>
|
|
117
|
+
{execution.changedFiles !== undefined && execution.changedFiles > 0 && (
|
|
118
|
+
<span className="dsh-atb-iso-fact">改动 {execution.changedFiles} 个文件</span>
|
|
119
|
+
)}
|
|
120
|
+
{execution.diffStat !== undefined && <span className="dsh-atb-iso-fact" title={execution.diffStat}>{execution.diffStat}</span>}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{commits.length > 0
|
|
124
|
+
? (
|
|
125
|
+
<div className="dsh-atb-iso-commits">
|
|
126
|
+
{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>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
{commitTotal > 10 && <div className="dsh-atb-iso-more">… 共 {commitTotal} 个提交</div>}
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
: <div className="dsh-atb-iso-nocommit">该次执行没有产生提交(改动可能未提交,见下方警告)</div>}
|
|
136
|
+
|
|
137
|
+
{dirtyTotal > 0 && (
|
|
138
|
+
<div className="dsh-atb-iso-dirty" title={dirty.join('\n')}>
|
|
139
|
+
⚠ 有 {dirtyTotal} 处未提交修改(合并前请让 agent 提交,或手动处理)
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<div className="dsh-atb-iso-actions">
|
|
144
|
+
{running
|
|
145
|
+
? <span className="dsh-atb-iso-hint">执行中 — 结束后可合并或清理</span>
|
|
146
|
+
: confirmMerge
|
|
147
|
+
? (
|
|
148
|
+
<span className="dsh-atb-confirm">
|
|
149
|
+
<span className="dsh-atb-confirm-label">将分支以 --no-ff 合并到主工作区?</span>
|
|
150
|
+
<button type="button" className="dsh-atb-btn" data-primary="true" disabled={busy} onClick={doMerge}>确认合并</button>
|
|
151
|
+
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmMerge(false)}>取消</button>
|
|
152
|
+
</span>
|
|
153
|
+
)
|
|
154
|
+
: (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
className="dsh-atb-btn"
|
|
158
|
+
disabled={busy}
|
|
159
|
+
title="在主工作区 git merge --no-ff 该任务分支(要求主区干净;冲突会原样报告)"
|
|
160
|
+
onClick={() => setConfirmMerge(true)}
|
|
161
|
+
>
|
|
162
|
+
⇥ 合并到主工作区
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
{!running && (confirmRemove === null
|
|
166
|
+
? (
|
|
167
|
+
<>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
className="dsh-atb-btn"
|
|
171
|
+
data-danger="true"
|
|
172
|
+
disabled={busy}
|
|
173
|
+
title="git worktree remove(有未提交修改时拒绝)"
|
|
174
|
+
onClick={() => setConfirmRemove('wt')}
|
|
175
|
+
>
|
|
176
|
+
🗑 删除 worktree
|
|
177
|
+
</button>
|
|
178
|
+
{task.branch !== undefined && (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="dsh-atb-btn"
|
|
182
|
+
data-danger="true"
|
|
183
|
+
disabled={busy}
|
|
184
|
+
title="删除 worktree 并删除任务分支(有未提交修改时拒绝)"
|
|
185
|
+
onClick={() => setConfirmRemove('wtb')}
|
|
186
|
+
>
|
|
187
|
+
🗑 删 worktree + 分支
|
|
188
|
+
</button>
|
|
189
|
+
)}
|
|
190
|
+
</>
|
|
191
|
+
)
|
|
192
|
+
: (
|
|
193
|
+
<span className="dsh-atb-confirm">
|
|
194
|
+
<span className="dsh-atb-confirm-label">{confirmRemove === 'wtb' ? '删除 worktree 并删除分支?' : '删除 worktree 目录?'}</span>
|
|
195
|
+
<button type="button" className="dsh-atb-btn" data-danger="true" disabled={busy} onClick={() => doRemove(confirmRemove === 'wtb')}>确认删除</button>
|
|
196
|
+
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmRemove(null)}>取消</button>
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
{!running && confirmRemove === null && !confirmMerge && <span className="dsh-atb-iso-hint">分支与 worktree 保留中 — 可退回继续修改</span>}
|
|
200
|
+
</div>
|
|
201
|
+
{alertEl}
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
51
206
|
/**
|
|
52
207
|
* The detail view.
|
|
53
208
|
* @param task - the task record.
|
|
@@ -87,10 +242,15 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
87
242
|
<Chip tone={task.urgency}>● {URGENCY_LABEL[task.urgency] ?? task.urgency}</Chip>
|
|
88
243
|
<Chip icon="📁">{ws?.title ?? shortId(task.workspaceId)}</Chip>
|
|
89
244
|
{task.model !== undefined && <Chip icon="✦">{task.model.model}</Chip>}
|
|
245
|
+
{task.presetId !== undefined && <Chip icon="🎛" >{task.presetId}</Chip>}
|
|
90
246
|
{task.execution.mode === 'scheduled' && (
|
|
91
247
|
<Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
|
|
92
248
|
)}
|
|
93
249
|
{task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
|
|
250
|
+
{task.branch !== undefined && (
|
|
251
|
+
<Chip icon="🌿" tone={undefined}>Worktree · {task.branch.length > 28 ? `${task.branch.slice(0, 28)}…` : task.branch}</Chip>
|
|
252
|
+
)}
|
|
253
|
+
{(task.isolation === undefined || task.isolation === 'worktree') && task.branch === undefined && <Chip icon="🌿">Worktree 隔离</Chip>}
|
|
94
254
|
{holder !== undefined && (
|
|
95
255
|
<Chip icon={stale ? '⏱' : '🔑'} tone={stale ? 'urgent' : undefined}>
|
|
96
256
|
{stale ? '认领超时 · ' : '由 '}{shortId(holder)} 持有
|
|
@@ -113,6 +273,16 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
113
273
|
>
|
|
114
274
|
⧉ 复制
|
|
115
275
|
</button>
|
|
276
|
+
{canRun && task.branch !== undefined && (
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
className="dsh-atb-detail-run"
|
|
280
|
+
title="续跑:保留现有 worktree 与分支(上次的改动和提交都在原处),在其上继续执行;默认「立即执行」会重置为全新基线"
|
|
281
|
+
onClick={() => void controller.run(task.id, true)}
|
|
282
|
+
>
|
|
283
|
+
↻ 续跑
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
116
286
|
{canRun && (
|
|
117
287
|
<button
|
|
118
288
|
type="button"
|
|
@@ -160,6 +330,8 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
160
330
|
</div>
|
|
161
331
|
)}
|
|
162
332
|
|
|
333
|
+
<IsolationBlock task={task} controller={controller} />
|
|
334
|
+
|
|
163
335
|
<div className="dsh-atb-detail-actions">
|
|
164
336
|
<div className="dsh-atb-movebtns">
|
|
165
337
|
{moveTargets(task).map(to => to === 'done'
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
12
12
|
import type { BoardController } from '../controller.ts'
|
|
13
|
-
import
|
|
13
|
+
import { loadDefaultIsolation, saveDefaultIsolation } from '../controller.ts'
|
|
14
|
+
import type { IsolationMode, Urgency } from '../../shared/protocol.ts'
|
|
14
15
|
import { nextCronTime, parseCron } from '../../shared/protocol.ts'
|
|
15
16
|
import { fmtTime } from './TaskBoard.tsx'
|
|
16
17
|
|
|
@@ -69,6 +70,14 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
69
70
|
const [cron, setCron] = useState(task?.execution.cron ?? '0 9 * * *')
|
|
70
71
|
const [catalog, setCatalog] = useState<CatalogModel[]>([])
|
|
71
72
|
const [model, setModel] = useState(task?.model !== undefined ? JSON.stringify(task.model) : '')
|
|
73
|
+
// Preset roster (0.3.3): create mode PRE-SELECTS the deployment default
|
|
74
|
+
// (标准模式 in this deployment); '' = 跟随部署默认 (submit omits the field).
|
|
75
|
+
const [presetId, setPresetId] = useState(task?.presetId ?? '')
|
|
76
|
+
const [presets, setPresets] = useState<Array<{ id: string; name?: string }>>([])
|
|
77
|
+
const [presetDefault, setPresetDefault] = useState<string | undefined>(undefined)
|
|
78
|
+
// 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())
|
|
72
81
|
const titleRef = useRef<HTMLInputElement>(null)
|
|
73
82
|
|
|
74
83
|
// Focus the title and close on Esc while the dialog is open.
|
|
@@ -88,6 +97,18 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
88
97
|
void face().then(setCatalog).catch(() => setCatalog([]))
|
|
89
98
|
}, [controller])
|
|
90
99
|
|
|
100
|
+
// 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.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const face = (controller as unknown as { presetCatalog?: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }> }).presetCatalog
|
|
104
|
+
if (face === undefined) return
|
|
105
|
+
void face().then(roster => {
|
|
106
|
+
setPresets(roster.presets)
|
|
107
|
+
setPresetDefault(roster.defaultId)
|
|
108
|
+
if (task?.presetId === undefined && roster.defaultId !== undefined) setPresetId(roster.defaultId)
|
|
109
|
+
}).catch(() => setPresets([]))
|
|
110
|
+
}, [controller, task?.presetId])
|
|
111
|
+
|
|
91
112
|
// Live cron validation + next-run preview (same math as the host).
|
|
92
113
|
const cronMatch = mode === 'scheduled' ? parseCron(cron.trim()) : null
|
|
93
114
|
const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null
|
|
@@ -97,9 +118,29 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
97
118
|
// A task already in progress cannot be run again (host rejects it).
|
|
98
119
|
const runBlocked = editing && task.status === 'in_progress'
|
|
99
120
|
|
|
121
|
+
// Isolation editability: locked once the task has execution history (the
|
|
122
|
+
// branch and its baseline depend on the choice — plan §3.1).
|
|
123
|
+
const isolationLocked = editing && ((task.executions?.length ?? 0) > 0 || task.status === 'in_progress')
|
|
124
|
+
const gitOk = controller.gitAvailable(workspaceId)
|
|
125
|
+
// Non-git project: the worktree option is disabled; submitting keeps the
|
|
126
|
+
// default (runtime auto-degrades with a note) instead of persisting 'none'.
|
|
127
|
+
const isolationDisabled = isolationLocked || !gitOk
|
|
128
|
+
|
|
129
|
+
/** Isolation payload for submit: undefined keeps the default (degrades naturally). */
|
|
130
|
+
const isolationPayload = (): string | undefined => {
|
|
131
|
+
if (!gitOk) return undefined
|
|
132
|
+
if (!editing) saveDefaultIsolation(isolation)
|
|
133
|
+
return isolation
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Preset payload: '' = follow the deployment default (submit omits). */
|
|
137
|
+
const presetPayload = (): string | undefined => (presetId.trim().length > 0 ? presetId.trim() : undefined)
|
|
138
|
+
|
|
100
139
|
const submit = (): void => {
|
|
101
140
|
if (!valid) return
|
|
102
141
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
142
|
+
const isolationOut = isolationPayload()
|
|
143
|
+
const presetOut = presetPayload()
|
|
103
144
|
if (editing) {
|
|
104
145
|
void controller.update(task.id, task.version, {
|
|
105
146
|
title,
|
|
@@ -110,6 +151,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
110
151
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
111
152
|
// '' in edit mode clears the pinned model back to the default.
|
|
112
153
|
model: picked ?? null,
|
|
154
|
+
...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
|
|
155
|
+
presetId: presetOut ?? null,
|
|
113
156
|
})
|
|
114
157
|
} else {
|
|
115
158
|
void controller.create({
|
|
@@ -120,6 +163,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
120
163
|
prompt: prompt.length > 0 ? prompt : undefined,
|
|
121
164
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
122
165
|
model: picked,
|
|
166
|
+
...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
|
|
167
|
+
...(presetOut !== undefined ? { presetId: presetOut } : {}),
|
|
123
168
|
})
|
|
124
169
|
}
|
|
125
170
|
}
|
|
@@ -128,6 +173,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
128
173
|
const submitAndRun = (): void => {
|
|
129
174
|
if (!valid || runBlocked) return
|
|
130
175
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
176
|
+
const isolationOut = isolationPayload()
|
|
177
|
+
const presetOut = presetPayload()
|
|
131
178
|
if (editing) {
|
|
132
179
|
void (async () => {
|
|
133
180
|
const saved = await controller.update(task.id, task.version, {
|
|
@@ -138,6 +185,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
138
185
|
workspaceId,
|
|
139
186
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
140
187
|
model: picked ?? null,
|
|
188
|
+
...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
|
|
189
|
+
presetId: presetOut ?? null,
|
|
141
190
|
})
|
|
142
191
|
if (saved) await controller.run(task.id)
|
|
143
192
|
})()
|
|
@@ -151,6 +200,8 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
151
200
|
prompt: prompt.length > 0 ? prompt : undefined,
|
|
152
201
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
153
202
|
model: picked,
|
|
203
|
+
...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
|
|
204
|
+
...(presetOut !== undefined ? { presetId: presetOut } : {}),
|
|
154
205
|
})
|
|
155
206
|
if (id !== undefined) await controller.run(id)
|
|
156
207
|
})()
|
|
@@ -199,6 +250,19 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
199
250
|
</select>
|
|
200
251
|
</Field>
|
|
201
252
|
|
|
253
|
+
{presets.length > 0 && (
|
|
254
|
+
<Field label="执行模式(preset)">
|
|
255
|
+
<select value={presetId} onChange={e => setPresetId(e.target.value)} title="执行会话按该 preset 组合(决定工具集与人设);默认 = 部署默认 preset">
|
|
256
|
+
<option value="">跟随部署默认{presetDefault !== undefined ? `(当前:${presets.find(p => p.id === presetDefault)?.name ?? presetDefault})` : ''}</option>
|
|
257
|
+
{presets.map(p => (
|
|
258
|
+
<option key={p.id} value={p.id}>
|
|
259
|
+
{p.name ?? p.id}{p.id === presetDefault ? '(部署默认)' : ''}
|
|
260
|
+
</option>
|
|
261
|
+
))}
|
|
262
|
+
</select>
|
|
263
|
+
</Field>
|
|
264
|
+
)}
|
|
265
|
+
|
|
202
266
|
<Field label="紧急度" full>
|
|
203
267
|
<div className="dsh-atb-urgency-picker">
|
|
204
268
|
{URGENCY_OPTIONS.map(o => (
|
|
@@ -263,6 +327,38 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
263
327
|
</span>
|
|
264
328
|
</Field>
|
|
265
329
|
)}
|
|
330
|
+
|
|
331
|
+
<Field label="执行隔离" full>
|
|
332
|
+
<div className="dsh-atb-mode-picker" data-disabled={isolationDisabled ? 'true' : undefined}>
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
className="dsh-atb-mode-opt"
|
|
336
|
+
data-on={isolation === 'worktree'}
|
|
337
|
+
disabled={isolationDisabled}
|
|
338
|
+
title={isolationLocked ? '任务已有执行记录,隔离方式已锁定' : !gitOk ? '当前项目非 git 仓库' : '每次执行在独立 worktree 分支上进行'}
|
|
339
|
+
onClick={() => setIsolation('worktree')}
|
|
340
|
+
>
|
|
341
|
+
<span className="dsh-atb-mode-name">🌿 Worktree 隔离</span>
|
|
342
|
+
<span className="dsh-atb-mode-hint">
|
|
343
|
+
{isolationLocked ? '已锁定(执行开始后不可更改)' : !gitOk ? '当前项目非 git 仓库' : '独立分支 task/标题+ID,互不污染'}
|
|
344
|
+
</span>
|
|
345
|
+
</button>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="dsh-atb-mode-opt"
|
|
349
|
+
data-on={isolation === 'none'}
|
|
350
|
+
disabled={isolationDisabled}
|
|
351
|
+
title={isolationLocked ? '任务已有执行记录,隔离方式已锁定' : '直接在项目目录执行(不使用 git)'}
|
|
352
|
+
onClick={() => setIsolation('none')}
|
|
353
|
+
>
|
|
354
|
+
<span className="dsh-atb-mode-name">📁 原目录执行</span>
|
|
355
|
+
<span className="dsh-atb-mode-hint">{isolationLocked ? '已锁定(执行开始后不可更改)' : !gitOk ? '当前项目非 git 仓库,将在原目录执行' : '不使用 git,直接在项目目录工作'}</span>
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
{!gitOk && !isolationLocked && (
|
|
359
|
+
<span className="dsh-atb-isolation-note">当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)</span>
|
|
360
|
+
)}
|
|
361
|
+
</Field>
|
|
266
362
|
</div>
|
|
267
363
|
|
|
268
364
|
<div className="dsh-atb-modal-foot">
|
|
@@ -300,4 +396,7 @@ interface TaskRecordLike {
|
|
|
300
396
|
urgency: Urgency
|
|
301
397
|
execution: { mode: 'claim' | 'scheduled'; cron?: string }
|
|
302
398
|
model?: { provider: string; model: string }
|
|
399
|
+
isolation?: IsolationMode
|
|
400
|
+
presetId?: string
|
|
401
|
+
executions?: unknown[]
|
|
303
402
|
}
|
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, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
|
|
11
|
-
import type { TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
|
|
10
|
+
import type { ChangeEvent, DiagnosticsResponse, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
|
|
11
|
+
import type { 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'
|
|
@@ -27,6 +27,26 @@ export type SortBy = 'default' | 'updated' | 'urgency' | 'created'
|
|
|
27
27
|
/** localStorage key for persisted view state (filters + sort). */
|
|
28
28
|
const VIEW_KEY = 'dsh-taskboard-view-v1'
|
|
29
29
|
|
|
30
|
+
/** localStorage key for the remembered isolation toggle choice (0.3.0). */
|
|
31
|
+
const ISOLATION_KEY = 'dsh-taskboard-isolation-v1'
|
|
32
|
+
|
|
33
|
+
/** Load the remembered default isolation (worktree unless explicitly turned off). */
|
|
34
|
+
export function loadDefaultIsolation(): IsolationMode {
|
|
35
|
+
try {
|
|
36
|
+
const raw = localStorage.getItem(ISOLATION_KEY)
|
|
37
|
+
return raw === 'none' ? 'none' : 'worktree'
|
|
38
|
+
} catch {
|
|
39
|
+
return 'worktree'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remember the isolation toggle choice across forms (best effort). */
|
|
44
|
+
export function saveDefaultIsolation(mode: IsolationMode): void {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(ISOLATION_KEY, mode)
|
|
47
|
+
} catch { /* storage unavailable — choice just won't persist */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
/** Load the persisted view state (never throws; fresh on any parse error). */
|
|
31
51
|
function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortBy } {
|
|
32
52
|
try {
|
|
@@ -62,6 +82,10 @@ export interface ControllerState {
|
|
|
62
82
|
editingId?: string
|
|
63
83
|
/** Secondary (canceled/archived/trashed) tab visible. */
|
|
64
84
|
secondaryOpen: boolean
|
|
85
|
+
/** Health-diagnostics panel (⚙) visible. */
|
|
86
|
+
diagOpen: boolean
|
|
87
|
+
/** Last fetched diagnostics payload (⚙ panel). */
|
|
88
|
+
diagnostics?: DiagnosticsResponse
|
|
65
89
|
/** Transient error surface (action failures); cleared on next success. */
|
|
66
90
|
error?: string
|
|
67
91
|
}
|
|
@@ -78,6 +102,7 @@ function initialState(): ControllerState {
|
|
|
78
102
|
sortBy: view.sortBy,
|
|
79
103
|
composerOpen: false,
|
|
80
104
|
secondaryOpen: false,
|
|
105
|
+
diagOpen: false,
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
108
|
|
|
@@ -221,6 +246,12 @@ export class BoardController {
|
|
|
221
246
|
/** Toggle the secondary tab. */
|
|
222
247
|
toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
|
|
223
248
|
|
|
249
|
+
/** Whether a workspace passed git detection (form toggle enablement). */
|
|
250
|
+
gitAvailable(workspaceId: string | undefined): boolean {
|
|
251
|
+
if (workspaceId === undefined) return true
|
|
252
|
+
return this.state.workspaces.find(w => w.id === workspaceId)?.gitAvailable === true
|
|
253
|
+
}
|
|
254
|
+
|
|
224
255
|
/**
|
|
225
256
|
* Install the session-jump bridge (built from the runtime sessions service
|
|
226
257
|
* by the client entry). Without it openSession reports 'unavailable'.
|
|
@@ -325,10 +356,10 @@ export class BoardController {
|
|
|
325
356
|
}
|
|
326
357
|
}
|
|
327
358
|
|
|
328
|
-
/** Trigger a manual run (fresh in-project session, pinned model)
|
|
329
|
-
async run(id: string): Promise<void> {
|
|
359
|
+
/** Trigger a manual run (fresh in-project session, pinned model); `reuse` = 续跑. */
|
|
360
|
+
async run(id: string, reuse = false): Promise<void> {
|
|
330
361
|
try {
|
|
331
|
-
await this.client.run(id)
|
|
362
|
+
await this.client.run(id, reuse ? { reuse: true } : {})
|
|
332
363
|
await this.refresh()
|
|
333
364
|
} catch (error) {
|
|
334
365
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
@@ -345,6 +376,56 @@ export class BoardController {
|
|
|
345
376
|
}
|
|
346
377
|
}
|
|
347
378
|
|
|
379
|
+
/**
|
|
380
|
+
* ⇥ 合并 (detail page): merge the task branch into the main worktree.
|
|
381
|
+
* @returns the outcome; `noop` means the branch had no new commits (nothing merged).
|
|
382
|
+
*/
|
|
383
|
+
async mergeBranch(id: string): Promise<{ ok: true; noop?: boolean } | { ok: false; error: string }> {
|
|
384
|
+
try {
|
|
385
|
+
const value = await this.client.mergeBranch(id)
|
|
386
|
+
await this.refresh()
|
|
387
|
+
return value.noop === true ? { ok: true, noop: true } : { ok: true }
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 🗑 删除 worktree (detail page), optionally deleting the task branch too.
|
|
395
|
+
* @returns the outcome; failures carry the git message for an alert.
|
|
396
|
+
*/
|
|
397
|
+
async removeWorktree(id: string, deleteBranch: boolean): Promise<{ ok: true; branchError?: string } | { ok: false; error: string }> {
|
|
398
|
+
try {
|
|
399
|
+
const value = await this.client.worktreeRemove(id, { deleteBranch })
|
|
400
|
+
await this.refresh()
|
|
401
|
+
return value.branchError !== undefined ? { ok: true, branchError: value.branchError } : { ok: true }
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Open the ⚙ diagnostics panel and fetch a fresh snapshot. */
|
|
408
|
+
openDiagnostics(): void {
|
|
409
|
+
this.setState({ diagOpen: true })
|
|
410
|
+
void this.client.diagnostics()
|
|
411
|
+
.then(diagnostics => this.setState({ diagnostics }))
|
|
412
|
+
.catch(error => this.setState({ error: error instanceof Error ? error.message : String(error) }))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Close the ⚙ diagnostics panel. */
|
|
416
|
+
closeDiagnostics(): void { this.setState({ diagOpen: false }) }
|
|
417
|
+
|
|
418
|
+
/** Clean one orphan worktree (⚙ panel); refreshes the diagnostics payload. */
|
|
419
|
+
async cleanupOrphan(workspaceId: string, taskId: string): Promise<void> {
|
|
420
|
+
try {
|
|
421
|
+
await this.client.worktreeCleanup(workspaceId, taskId)
|
|
422
|
+
const diagnostics = await this.client.diagnostics()
|
|
423
|
+
this.setState({ diagnostics, error: undefined })
|
|
424
|
+
} catch (error) {
|
|
425
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
348
429
|
/** Soft-delete (agent parity) then optional purge. */
|
|
349
430
|
async remove(id: string, ifVersion: number, purge: boolean): Promise<void> {
|
|
350
431
|
try {
|
|
@@ -356,7 +437,7 @@ export class BoardController {
|
|
|
356
437
|
}
|
|
357
438
|
}
|
|
358
439
|
|
|
359
|
-
/** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model). */
|
|
440
|
+
/** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model/isolation). */
|
|
360
441
|
async duplicate(task: TaskRecord): Promise<void> {
|
|
361
442
|
try {
|
|
362
443
|
await this.client.create({
|
|
@@ -369,6 +450,8 @@ export class BoardController {
|
|
|
369
450
|
? { mode: 'scheduled', cron: task.execution.cron }
|
|
370
451
|
: { mode: 'claim' },
|
|
371
452
|
model: task.model,
|
|
453
|
+
isolation: task.isolation,
|
|
454
|
+
...(task.presetId !== undefined ? { presetId: task.presetId } : {}),
|
|
372
455
|
})
|
|
373
456
|
await this.refresh()
|
|
374
457
|
} catch (error) {
|
package/src/client/index.ts
CHANGED
|
@@ -24,12 +24,15 @@ export const name = 'dsh-taskboard/client'
|
|
|
24
24
|
/** Required client services (fiber inject waiting). */
|
|
25
25
|
export const inject = ['connection']
|
|
26
26
|
|
|
27
|
-
/** Narrow connection face for the model catalog. */
|
|
27
|
+
/** Narrow connection face for the model catalog + preset roster. */
|
|
28
28
|
interface ConnectionFace {
|
|
29
29
|
api: {
|
|
30
30
|
llm: {
|
|
31
31
|
models(payload: Record<string, never>): Promise<{ result: { ok: true; value: { groups: Array<{ id: string; name: string; models: Array<{ id: string; name?: string }> }> } } | { ok: false } }>
|
|
32
32
|
}
|
|
33
|
+
agentPresets?: {
|
|
34
|
+
list(payload: Record<string, never>): Promise<{ result: { ok: true; value: { presets: Array<{ id: string; name?: string; isDefault: boolean }> } } | { ok: false } }>
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -64,6 +67,20 @@ export function apply(ctx: ClientContextFace): void {
|
|
|
64
67
|
}
|
|
65
68
|
return out
|
|
66
69
|
}
|
|
70
|
+
|
|
71
|
+
// Preset roster for the composer (0.3.3): agentPreset.list over the
|
|
72
|
+
// connection RPC — [{id, name}] plus which one is the deployment
|
|
73
|
+
// default (the form pre-selects it on create).
|
|
74
|
+
type PresetRow = { id: string; name?: string }
|
|
75
|
+
;(controller as unknown as { presetCatalog?: () => Promise<{ presets: PresetRow[]; defaultId?: string }> }).presetCatalog = async (): Promise<{ presets: PresetRow[]; defaultId?: string }> => {
|
|
76
|
+
const list = connection.api.agentPresets
|
|
77
|
+
if (list === undefined) return { presets: [] }
|
|
78
|
+
const response = await list.list({})
|
|
79
|
+
if (!response.result.ok) return { presets: [] }
|
|
80
|
+
const presets = response.result.value.presets.map((p: { id: string; name?: string }) => ({ id: p.id, name: p.name }))
|
|
81
|
+
const def = response.result.value.presets.find((p: { id: string; isDefault: boolean }) => p.isDefault)
|
|
82
|
+
return { presets, ...(def !== undefined ? { defaultId: def.id } : {}) }
|
|
83
|
+
}
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
// Session navigation for execution rows: resolved LAZILY on every jump —
|
package/src/client/styles.ts
CHANGED
|
@@ -468,6 +468,51 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
468
468
|
color: var(--dsw-alias-label-primary, inherit);
|
|
469
469
|
}
|
|
470
470
|
.dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
|
|
471
|
+
|
|
472
|
+
/* ---------- 0.3.0 isolation ---------- */
|
|
473
|
+
.dsh-atb-isolation-note { display: block; margin-top: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); }
|
|
474
|
+
.dsh-atb-mode-picker[data-disabled="true"] .dsh-atb-mode-opt { cursor: not-allowed; opacity: .55; }
|
|
475
|
+
.dsh-atb-iso-none { font-size: 12.5px; color: var(--dsw-alias-label-secondary, inherit); }
|
|
476
|
+
.dsh-atb-iso-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 8px; }
|
|
477
|
+
.dsh-atb-iso-fact { font-size: 11.5px; color: var(--dsw-alias-label-secondary, inherit); }
|
|
478
|
+
.dsh-atb-iso-fact b { font-weight: 600; color: var(--dsw-alias-state-business-primary, #3e63dd); }
|
|
479
|
+
.dsh-atb-iso-commits { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
|
|
480
|
+
.dsh-atb-iso-commit { display: flex; gap: 8px; font-size: 11.5px; align-items: baseline; }
|
|
481
|
+
.dsh-atb-iso-commit code {
|
|
482
|
+
font-family: ui-monospace, Consolas, monospace; font-size: 10.5px;
|
|
483
|
+
color: var(--dsh-alias-state-business-primary, #3e63dd); flex-shrink: 0;
|
|
484
|
+
}
|
|
485
|
+
.dsh-atb-iso-commit span { word-break: break-all; color: var(--dsw-alias-label-secondary, inherit); }
|
|
486
|
+
.dsh-atb-iso-more { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); }
|
|
487
|
+
.dsh-atb-iso-nocommit { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, gray); margin-bottom: 8px; }
|
|
488
|
+
.dsh-atb-iso-dirty {
|
|
489
|
+
font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
490
|
+
background: rgba(229,72,77,.09); border: 1px solid rgba(229,72,77,.35);
|
|
491
|
+
border-radius: 8px; padding: 6px 10px; margin-bottom: 8px;
|
|
492
|
+
}
|
|
493
|
+
.dsh-atb-iso-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
494
|
+
.dsh-atb-iso-hint { font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); }
|
|
495
|
+
|
|
496
|
+
/* ---------- 0.3.0 diagnostics ---------- */
|
|
497
|
+
.dsh-atb-diag { max-width: 520px; width: min(520px, 92vw); }
|
|
498
|
+
.dsh-atb-diag-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 14px; }
|
|
499
|
+
.dsh-atb-diag-item {
|
|
500
|
+
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
|
501
|
+
padding: 10px 6px; border-radius: 10px;
|
|
502
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
503
|
+
background: var(--dsw-alias-bg-layer-1, rgba(128,128,128,.04));
|
|
504
|
+
}
|
|
505
|
+
.dsh-atb-diag-item b { font-size: 18px; font-weight: 700; }
|
|
506
|
+
.dsh-atb-diag-item span { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, gray); }
|
|
507
|
+
.dsh-atb-diag-item[data-bad="true"] b { color: var(--dsw-alias-state-error-primary, #e5484d); }
|
|
508
|
+
.dsh-atb-diag-sec h4 { margin: 0 0 8px; font-size: 12.5px; }
|
|
509
|
+
.dsh-atb-diag-orphans { display: flex; flex-direction: column; gap: 6px; }
|
|
510
|
+
.dsh-atb-diag-orphan {
|
|
511
|
+
display: flex; align-items: center; gap: 10px; justify-content: space-between;
|
|
512
|
+
padding: 7px 10px; border-radius: 8px;
|
|
513
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
514
|
+
}
|
|
515
|
+
.dsh-atb-diag-orphan-path { font-size: 11.5px; font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
|
|
471
516
|
`
|
|
472
517
|
|
|
473
518
|
let injected = false
|