dsh-taskboard 0.2.1 → 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.
@@ -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 type { Urgency } from '../../shared/protocol.ts'
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
  }
@@ -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'.
@@ -286,6 +317,25 @@ export class BoardController {
286
317
  }
287
318
  }
288
319
 
320
+ /**
321
+ * Quick-reject (card ✗ button): move back to todo with an optional user
322
+ * comment, committed atomically host-side. Returns whether the task moved.
323
+ * @param id - task id.
324
+ * @param ifVersion - optimistic version (captured at click time).
325
+ * @param comment - optional comment text; blank = move only.
326
+ */
327
+ async reject(id: string, ifVersion: number, comment?: string): Promise<boolean> {
328
+ const body = comment !== undefined && comment.trim().length > 0 ? comment.trim() : undefined
329
+ try {
330
+ await this.client.reject(id, body === undefined ? { ifVersion } : { ifVersion, body })
331
+ await this.refresh()
332
+ return true
333
+ } catch (error) {
334
+ this.setState({ error: error instanceof Error ? error.message : String(error) })
335
+ return false
336
+ }
337
+ }
338
+
289
339
  /** Toggle the blocked marker. */
290
340
  async toggleBlocked(task: TaskRecord): Promise<void> {
291
341
  try {
@@ -306,10 +356,10 @@ export class BoardController {
306
356
  }
307
357
  }
308
358
 
309
- /** Trigger a manual run (fresh in-project session, pinned model). */
310
- 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> {
311
361
  try {
312
- await this.client.run(id)
362
+ await this.client.run(id, reuse ? { reuse: true } : {})
313
363
  await this.refresh()
314
364
  } catch (error) {
315
365
  this.setState({ error: error instanceof Error ? error.message : String(error) })
@@ -326,6 +376,56 @@ export class BoardController {
326
376
  }
327
377
  }
328
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
+
329
429
  /** Soft-delete (agent parity) then optional purge. */
330
430
  async remove(id: string, ifVersion: number, purge: boolean): Promise<void> {
331
431
  try {
@@ -337,7 +437,7 @@ export class BoardController {
337
437
  }
338
438
  }
339
439
 
340
- /** 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). */
341
441
  async duplicate(task: TaskRecord): Promise<void> {
342
442
  try {
343
443
  await this.client.create({
@@ -350,6 +450,8 @@ export class BoardController {
350
450
  ? { mode: 'scheduled', cron: task.execution.cron }
351
451
  : { mode: 'claim' },
352
452
  model: task.model,
453
+ isolation: task.isolation,
454
+ ...(task.presetId !== undefined ? { presetId: task.presetId } : {}),
353
455
  })
354
456
  await this.refresh()
355
457
  } catch (error) {
@@ -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 —