dsh-taskboard 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/lib/client.js +886 -139
- package/lib/host/execution.js +160 -33
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +30 -3
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +10 -1
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/store.js +31 -18
- package/lib/host/store.js.map +1 -1
- package/lib/host/tools.js +14 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +27 -4
- package/lib/index.js.map +1 -1
- package/lib/shared/protocol.js +53 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -0
- package/src/client/board/AlertModal.tsx +36 -0
- package/src/client/board/TaskBoard.tsx +126 -44
- package/src/client/board/TaskCard.tsx +16 -3
- package/src/client/board/TaskDetail.tsx +93 -14
- package/src/client/board/TaskFormModal.tsx +47 -1
- package/src/client/controller.ts +171 -8
- package/src/client/index.ts +10 -0
- package/src/client/session-jump.ts +93 -0
- package/src/client/sidebar-entry.ts +98 -1
- package/src/client/styles.ts +83 -6
- package/src/host/execution.ts +202 -18
- package/src/host/routes.ts +38 -11
- package/src/host/scheduler.ts +20 -4
- package/src/host/store.ts +34 -13
- package/src/host/tools.ts +30 -8
- package/src/index.ts +37 -3
- package/src/shared/protocol.ts +72 -3
- package/src/shared/version.ts +9 -0
|
@@ -11,7 +11,8 @@ import { useState, type ReactNode } from 'react'
|
|
|
11
11
|
import type { BoardController } from '../controller.ts'
|
|
12
12
|
import type { TaskRecord } from '../../shared/protocol.ts'
|
|
13
13
|
import { canTransition } from '../../shared/protocol.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { useAlert } from './AlertModal.tsx'
|
|
15
|
+
import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
|
|
15
16
|
|
|
16
17
|
/** Statuses a user may move this task to, per the state machine. */
|
|
17
18
|
function moveTargets(task: TaskRecord): TaskRecord['status'][] {
|
|
@@ -27,10 +28,10 @@ const STATUS_LABEL: Record<string, string> = { ...MOVE_LABEL }
|
|
|
27
28
|
const URGENCY_LABEL: Record<string, string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
|
|
28
29
|
const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
|
|
29
30
|
|
|
30
|
-
/** Compact session-id display. */
|
|
31
|
+
/** Compact session-id display (execution sessions carry the taskboard infix). */
|
|
31
32
|
function shortId(id: string | undefined): string {
|
|
32
33
|
if (id === undefined) return ''
|
|
33
|
-
return id.replace(/^session
|
|
34
|
+
return id.replace(/^session-(taskboard-)?/, '').slice(0, 8)
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** Execution duration between start and end. */
|
|
@@ -51,13 +52,28 @@ function Chip({ icon, children, tone }: { icon?: string; children: ReactNode; to
|
|
|
51
52
|
* The detail view.
|
|
52
53
|
* @param task - the task record.
|
|
53
54
|
* @param controller - the controller.
|
|
55
|
+
* @param now - current epoch ms (stale-claim highlight).
|
|
54
56
|
*/
|
|
55
|
-
export function TaskDetail({ task, controller }: { task: TaskRecord; controller: BoardController }) {
|
|
57
|
+
export function TaskDetail({ task, controller, now }: { task: TaskRecord; controller: BoardController; now?: number }) {
|
|
56
58
|
const [comment, setComment] = useState('')
|
|
57
59
|
const [confirmDone, setConfirmDone] = useState(false)
|
|
58
60
|
const [confirmPurge, setConfirmPurge] = useState(false)
|
|
61
|
+
const [confirmCancel, setConfirmCancel] = useState(false)
|
|
62
|
+
const { alert: showAlert, el: alertEl } = useAlert()
|
|
59
63
|
const ws = controller.getSnapshot().workspaces.find(w => w.id === task.workspaceId)
|
|
60
64
|
const canRun = task.status !== 'in_progress' && task.status !== 'done' && task.status !== 'archived'
|
|
65
|
+
const runningExecution = task.executions.find(e => e.outcome === 'running')
|
|
66
|
+
const holder = task.status === 'in_progress' ? task.claimedBy : undefined
|
|
67
|
+
const stale = now !== undefined && isStaleClaim(task, now)
|
|
68
|
+
|
|
69
|
+
/** Jump to an execution's session; prompt precisely when it cannot open. */
|
|
70
|
+
const jumpToSession = (sessionId: string): void => {
|
|
71
|
+
void controller.openSession(sessionId).then(result => {
|
|
72
|
+
if (result === 'missing') showAlert(`该会话已被删除(${shortId(sessionId)}),无法打开`)
|
|
73
|
+
else if (result === 'archived') showAlert(`该会话已归档(${shortId(sessionId)}),已从会话列表隐藏`)
|
|
74
|
+
else if (result === 'unavailable') showAlert(`会话导航不可用,会话 ID:${sessionId}`)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
61
77
|
|
|
62
78
|
return (
|
|
63
79
|
<div className="dsh-atb-detail" data-urgency={task.urgency}>
|
|
@@ -75,6 +91,11 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
75
91
|
<Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
|
|
76
92
|
)}
|
|
77
93
|
{task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
|
|
94
|
+
{holder !== undefined && (
|
|
95
|
+
<Chip icon={stale ? '⏱' : '🔑'} tone={stale ? 'urgent' : undefined}>
|
|
96
|
+
{stale ? '认领超时 · ' : '由 '}{shortId(holder)} 持有
|
|
97
|
+
</Chip>
|
|
98
|
+
)}
|
|
78
99
|
{task.trashedAt !== undefined && <Chip icon="🗑" tone="urgent">已删除待清除</Chip>}
|
|
79
100
|
<Chip>v{task.version}</Chip>
|
|
80
101
|
</div>
|
|
@@ -84,6 +105,43 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
84
105
|
</div>
|
|
85
106
|
<div className="dsh-atb-detail-topbtns">
|
|
86
107
|
<button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className="dsh-atb-detail-edit"
|
|
111
|
+
title="复制此任务的全部配置为一张新卡(待办列)"
|
|
112
|
+
onClick={() => void controller.duplicate(task)}
|
|
113
|
+
>
|
|
114
|
+
⧉ 复制
|
|
115
|
+
</button>
|
|
116
|
+
{canRun && (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="dsh-atb-detail-run"
|
|
120
|
+
title={task.model !== undefined ? `新会话执行(${task.model.model})` : '新会话执行(默认模型)'}
|
|
121
|
+
onClick={() => void controller.run(task.id)}
|
|
122
|
+
>
|
|
123
|
+
▶ 立即执行
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
{runningExecution !== undefined && (confirmCancel
|
|
127
|
+
? (
|
|
128
|
+
<span className="dsh-atb-confirm">
|
|
129
|
+
<span className="dsh-atb-confirm-label">停止该执行会话?</span>
|
|
130
|
+
<button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.cancel(task.id); setConfirmCancel(false) }}>停止</button>
|
|
131
|
+
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmCancel(false)}>取消</button>
|
|
132
|
+
</span>
|
|
133
|
+
)
|
|
134
|
+
: (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="dsh-atb-detail-run"
|
|
138
|
+
data-danger="true"
|
|
139
|
+
title={`停止执行会话 ${runningExecution.sessionId ?? ''}(任务回到待办)`}
|
|
140
|
+
onClick={() => setConfirmCancel(true)}
|
|
141
|
+
>
|
|
142
|
+
■ 停止执行
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
87
145
|
<button type="button" className="dsh-atb-detail-close" aria-label="关闭" onClick={() => controller.select(undefined)}>✕</button>
|
|
88
146
|
</div>
|
|
89
147
|
</div>
|
|
@@ -103,11 +161,6 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
103
161
|
)}
|
|
104
162
|
|
|
105
163
|
<div className="dsh-atb-detail-actions">
|
|
106
|
-
{canRun && (
|
|
107
|
-
<button type="button" className="dsh-atb-runbtn" onClick={() => void controller.run(task.id)}>
|
|
108
|
-
▶ 执行 · 新会话{task.model !== undefined ? `(${task.model.model})` : '(默认模型)'}
|
|
109
|
-
</button>
|
|
110
|
-
)}
|
|
111
164
|
<div className="dsh-atb-movebtns">
|
|
112
165
|
{moveTargets(task).map(to => to === 'done'
|
|
113
166
|
? (confirmDone
|
|
@@ -118,15 +171,26 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
118
171
|
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
|
|
119
172
|
</span>
|
|
120
173
|
)
|
|
121
|
-
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}
|
|
174
|
+
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>移至→{MOVE_LABEL[to]}</button>)
|
|
122
175
|
: (
|
|
123
176
|
<button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
|
|
124
|
-
{MOVE_LABEL[to]}
|
|
177
|
+
移至→{MOVE_LABEL[to]}
|
|
125
178
|
</button>
|
|
126
179
|
))}
|
|
127
180
|
<button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
|
|
128
181
|
{task.blocked ? '✓ 解除受阻' : '⛔ 标记受阻'}
|
|
129
182
|
</button>
|
|
183
|
+
{holder !== undefined && (
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className="dsh-atb-movebtn"
|
|
187
|
+
data-to="release"
|
|
188
|
+
title={`释放 ${holder} 的认领:任务回到待办(持有会话可能仍在工作,确认它已停止后再释放)`}
|
|
189
|
+
onClick={() => void controller.move(task.id, task.version, 'todo')}
|
|
190
|
+
>
|
|
191
|
+
🔓 释放认领
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
130
194
|
</div>
|
|
131
195
|
</div>
|
|
132
196
|
|
|
@@ -176,15 +240,28 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
176
240
|
|
|
177
241
|
{task.executions.length > 0 && (
|
|
178
242
|
<div className="dsh-atb-section">
|
|
179
|
-
<h4>执行记录<span className="dsh-atb-count2">{task.executions.length}</span
|
|
243
|
+
<h4>执行记录<span className="dsh-atb-count2">{task.executions.length}</span>
|
|
244
|
+
{task.executionsPruned !== undefined && task.executionsPruned > 0 && (
|
|
245
|
+
<span className="dsh-atb-count2" title={`更早的 ${task.executionsPruned} 条执行记录已按保留上限清理`}>+{task.executionsPruned} 已清理</span>
|
|
246
|
+
)}
|
|
247
|
+
</h4>
|
|
180
248
|
<div className="dsh-atb-execlist">
|
|
181
|
-
{task.executions.map(e => (
|
|
249
|
+
{[...task.executions].reverse().map(e => (
|
|
182
250
|
<div key={e.id} className="dsh-atb-exec-row">
|
|
183
251
|
<span className="dsh-atb-exec-dot" data-outcome={e.outcome} />
|
|
184
252
|
<span className="dsh-atb-exec-trigger">{e.trigger === 'manual' ? '手动' : '定时'}</span>
|
|
185
253
|
<span className="dsh-atb-exec-outcome" data-outcome={e.outcome}>{OUTCOME_LABEL[e.outcome] ?? e.outcome}</span>
|
|
186
254
|
<span className="dsh-atb-exec-time">{fmtTime(e.startedAt)}{e.endedAt !== undefined && ` · ${duration(e.startedAt, e.endedAt)}`}</span>
|
|
187
|
-
{e.sessionId !== undefined &&
|
|
255
|
+
{e.sessionId !== undefined && (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
className="dsh-atb-exec-session"
|
|
259
|
+
title={`点击打开该执行会话:${e.sessionId}`}
|
|
260
|
+
onClick={() => jumpToSession(e.sessionId!)}
|
|
261
|
+
>
|
|
262
|
+
🤖 {shortId(e.sessionId)} ↗
|
|
263
|
+
</button>
|
|
264
|
+
)}
|
|
188
265
|
{e.error !== undefined && <span className="dsh-atb-exec-error" title={e.error}>{e.error.slice(0, 80)}{e.error.length > 80 ? '…' : ''}</span>}
|
|
189
266
|
</div>
|
|
190
267
|
))}
|
|
@@ -205,6 +282,8 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
205
282
|
)
|
|
206
283
|
: <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => setConfirmPurge(true)}>🔥 物理清除(需确认)</button>)}
|
|
207
284
|
</div>
|
|
285
|
+
|
|
286
|
+
{alertEl}
|
|
208
287
|
</div>
|
|
209
288
|
)
|
|
210
289
|
}
|
|
@@ -94,6 +94,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
94
94
|
const cronBad = mode === 'scheduled' && (cronMatch === null || nextRun === null)
|
|
95
95
|
const valid = title.trim().length > 0 && workspaceId !== '' && !cronBad
|
|
96
96
|
|
|
97
|
+
// A task already in progress cannot be run again (host rejects it).
|
|
98
|
+
const runBlocked = editing && task.status === 'in_progress'
|
|
99
|
+
|
|
97
100
|
const submit = (): void => {
|
|
98
101
|
if (!valid) return
|
|
99
102
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
@@ -121,6 +124,39 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** Save the form, then immediately trigger a manual run of the task. */
|
|
128
|
+
const submitAndRun = (): void => {
|
|
129
|
+
if (!valid || runBlocked) return
|
|
130
|
+
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
131
|
+
if (editing) {
|
|
132
|
+
void (async () => {
|
|
133
|
+
const saved = await controller.update(task.id, task.version, {
|
|
134
|
+
title,
|
|
135
|
+
description,
|
|
136
|
+
prompt,
|
|
137
|
+
urgency,
|
|
138
|
+
workspaceId,
|
|
139
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
140
|
+
model: picked ?? null,
|
|
141
|
+
})
|
|
142
|
+
if (saved) await controller.run(task.id)
|
|
143
|
+
})()
|
|
144
|
+
} else {
|
|
145
|
+
void (async () => {
|
|
146
|
+
const id = await controller.create({
|
|
147
|
+
title,
|
|
148
|
+
workspaceId,
|
|
149
|
+
urgency,
|
|
150
|
+
description: description.length > 0 ? description : undefined,
|
|
151
|
+
prompt: prompt.length > 0 ? prompt : undefined,
|
|
152
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
153
|
+
model: picked,
|
|
154
|
+
})
|
|
155
|
+
if (id !== undefined) await controller.run(id)
|
|
156
|
+
})()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
const hint = !valid
|
|
125
161
|
? (title.trim().length === 0 ? '请填写标题' : workspaceId === '' ? '请选择项目' : 'Cron 表达式无效(分 时 日 月 周)')
|
|
126
162
|
: mode === 'scheduled' && nextRun !== null
|
|
@@ -186,7 +222,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
186
222
|
</Field>
|
|
187
223
|
|
|
188
224
|
<Field label={editing ? '执行 Prompt' : '执行 Prompt(可选,默认 = 标题+描述)'} full>
|
|
189
|
-
<textarea value={prompt} onChange={e => setPrompt(e.target.value)} placeholder=
|
|
225
|
+
<textarea value={prompt} onChange={e => setPrompt(e.target.value)} placeholder={'发给执行会话的完整指令。支持模板变量:{{lastExecution}}(上次执行结果)、{{lastComments}}(最近 3 条评论)'} />
|
|
190
226
|
</Field>
|
|
191
227
|
|
|
192
228
|
<Field label="执行方式" full>
|
|
@@ -233,6 +269,15 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
233
269
|
<span className="dsh-atb-modal-hint" data-tone={valid ? undefined : 'bad'}>{hint}</span>
|
|
234
270
|
<span className="dsh-atb-modal-footbtns">
|
|
235
271
|
<button type="button" className="dsh-atb-btn" onClick={() => controller.closeForm()}>取消</button>
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
className="dsh-atb-btn"
|
|
275
|
+
disabled={!valid || runBlocked}
|
|
276
|
+
title={runBlocked ? '任务正在执行中,不能重复发起' : '保存后立即发起执行(新会话)'}
|
|
277
|
+
onClick={submitAndRun}
|
|
278
|
+
>
|
|
279
|
+
⚡ 立即执行
|
|
280
|
+
</button>
|
|
236
281
|
<button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid} onClick={submit}>
|
|
237
282
|
{editing ? '保存修改' : '创建任务'}
|
|
238
283
|
</button>
|
|
@@ -247,6 +292,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
247
292
|
interface TaskRecordLike {
|
|
248
293
|
id: string
|
|
249
294
|
version: number
|
|
295
|
+
status?: string
|
|
250
296
|
title: string
|
|
251
297
|
description: string
|
|
252
298
|
prompt: string
|
package/src/client/controller.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ChangeEvent, UpdateTaskBody, WorkspaceView } from '../shared/api.t
|
|
|
11
11
|
import type { TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
|
|
12
12
|
import { emptyLedger } from '../shared/protocol.ts'
|
|
13
13
|
import type { TaskboardClient } from './api.ts'
|
|
14
|
+
import type { SessionJumpResult } from './session-jump.ts'
|
|
14
15
|
|
|
15
16
|
/** View filters over the ledger. */
|
|
16
17
|
export interface BoardFilters {
|
|
@@ -20,12 +21,39 @@ export interface BoardFilters {
|
|
|
20
21
|
urgencies: Urgency[]
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/** Column sort orders. */
|
|
25
|
+
export type SortBy = 'default' | 'updated' | 'urgency' | 'created'
|
|
26
|
+
|
|
27
|
+
/** localStorage key for persisted view state (filters + sort). */
|
|
28
|
+
const VIEW_KEY = 'dsh-taskboard-view-v1'
|
|
29
|
+
|
|
30
|
+
/** Load the persisted view state (never throws; fresh on any parse error). */
|
|
31
|
+
function loadView(): { workspaceId?: string; urgencies: Urgency[]; sortBy: SortBy } {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(VIEW_KEY)
|
|
34
|
+
if (raw === null) return { urgencies: [], sortBy: 'default' }
|
|
35
|
+
const parsed = JSON.parse(raw) as { workspaceId?: string; urgencies?: Urgency[]; sortBy?: SortBy }
|
|
36
|
+
const sortBy = parsed.sortBy === 'updated' || parsed.sortBy === 'urgency' || parsed.sortBy === 'created' ? parsed.sortBy : 'default'
|
|
37
|
+
return {
|
|
38
|
+
workspaceId: typeof parsed.workspaceId === 'string' ? parsed.workspaceId : undefined,
|
|
39
|
+
urgencies: Array.isArray(parsed.urgencies) ? parsed.urgencies.filter(u => u === 'urgent' || u === 'normal' || u === 'relaxed') : [],
|
|
40
|
+
sortBy,
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return { urgencies: [], sortBy: 'default' }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
/** Controller snapshot the views render. */
|
|
24
48
|
export interface ControllerState {
|
|
25
49
|
boardOpen: boolean
|
|
26
50
|
ledger: TaskLedger
|
|
27
51
|
workspaces: WorkspaceView[]
|
|
28
52
|
filters: BoardFilters
|
|
53
|
+
/** Free-text search over title/id (case-insensitive). */
|
|
54
|
+
search: string
|
|
55
|
+
/** Column sort order. */
|
|
56
|
+
sortBy: SortBy
|
|
29
57
|
/** Selected task id (detail view); undefined closes the detail. */
|
|
30
58
|
selectedId?: string
|
|
31
59
|
/** Task form modal visible (create when editingId is unset). */
|
|
@@ -38,13 +66,16 @@ export interface ControllerState {
|
|
|
38
66
|
error?: string
|
|
39
67
|
}
|
|
40
68
|
|
|
41
|
-
/** Instantiate the default state. */
|
|
69
|
+
/** Instantiate the default state (view state hydrated from localStorage). */
|
|
42
70
|
function initialState(): ControllerState {
|
|
71
|
+
const view = loadView()
|
|
43
72
|
return {
|
|
44
73
|
boardOpen: false,
|
|
45
74
|
ledger: emptyLedger(),
|
|
46
75
|
workspaces: [],
|
|
47
|
-
filters: { urgencies:
|
|
76
|
+
filters: { workspaceId: view.workspaceId, urgencies: view.urgencies },
|
|
77
|
+
search: '',
|
|
78
|
+
sortBy: view.sortBy,
|
|
48
79
|
composerOpen: false,
|
|
49
80
|
secondaryOpen: false,
|
|
50
81
|
}
|
|
@@ -59,6 +90,7 @@ export class BoardController {
|
|
|
59
90
|
private disposed = false
|
|
60
91
|
private disposeStream: (() => void) | undefined
|
|
61
92
|
private refreshInFlight: Promise<void> | undefined
|
|
93
|
+
private sessionJumper: ((sessionId: string) => Promise<SessionJumpResult>) | undefined
|
|
62
94
|
|
|
63
95
|
/** @param client - the route client. */
|
|
64
96
|
constructor(private readonly client: TaskboardClient) {}
|
|
@@ -137,17 +169,41 @@ export class BoardController {
|
|
|
137
169
|
/** Toggle the board. */
|
|
138
170
|
toggleBoard(): void { this.setState({ boardOpen: !this.state.boardOpen }) }
|
|
139
171
|
|
|
140
|
-
/** Set the project filter. */
|
|
172
|
+
/** Set the project filter (persisted). */
|
|
141
173
|
setWorkspaceFilter(workspaceId?: string): void {
|
|
142
174
|
this.setState({ filters: { ...this.state.filters, workspaceId } })
|
|
175
|
+
this.persistView()
|
|
143
176
|
}
|
|
144
177
|
|
|
145
|
-
/** Toggle one urgency chip. */
|
|
178
|
+
/** Toggle one urgency chip (persisted). */
|
|
146
179
|
toggleUrgency(urgency: Urgency): void {
|
|
147
180
|
const set = new Set(this.state.filters.urgencies)
|
|
148
181
|
if (set.has(urgency)) set.delete(urgency)
|
|
149
182
|
else set.add(urgency)
|
|
150
183
|
this.setState({ filters: { ...this.state.filters, urgencies: [...set] } })
|
|
184
|
+
this.persistView()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Set the free-text search (transient — not persisted). */
|
|
188
|
+
setSearch(search: string): void {
|
|
189
|
+
this.setState({ search })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Set the column sort order (persisted). */
|
|
193
|
+
setSortBy(sortBy: SortBy): void {
|
|
194
|
+
this.setState({ sortBy })
|
|
195
|
+
this.persistView()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Write the current view state to localStorage (best effort). */
|
|
199
|
+
private persistView(): void {
|
|
200
|
+
try {
|
|
201
|
+
localStorage.setItem(VIEW_KEY, JSON.stringify({
|
|
202
|
+
workspaceId: this.state.filters.workspaceId,
|
|
203
|
+
urgencies: this.state.filters.urgencies,
|
|
204
|
+
sortBy: this.state.sortBy,
|
|
205
|
+
}))
|
|
206
|
+
} catch { /* storage unavailable (private mode etc.) — view just won't persist */ }
|
|
151
207
|
}
|
|
152
208
|
|
|
153
209
|
/** Select a task (open detail). */
|
|
@@ -165,26 +221,58 @@ export class BoardController {
|
|
|
165
221
|
/** Toggle the secondary tab. */
|
|
166
222
|
toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
|
|
167
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Install the session-jump bridge (built from the runtime sessions service
|
|
226
|
+
* by the client entry). Without it openSession reports 'unavailable'.
|
|
227
|
+
* @param jumper - the jump function from createSessionJumper.
|
|
228
|
+
*/
|
|
229
|
+
installSessionJumper(jumper: (sessionId: string) => Promise<SessionJumpResult>): void {
|
|
230
|
+
this.sessionJumper = jumper
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Jump to an execution's session (open it in the GUI). On success the board
|
|
235
|
+
* closes so the conversation shows; a deleted-or-archived session reports
|
|
236
|
+
* 'missing' for the caller to prompt about.
|
|
237
|
+
* @param sessionId - the execution's session id.
|
|
238
|
+
* @returns the jump outcome.
|
|
239
|
+
*/
|
|
240
|
+
async openSession(sessionId: string): Promise<SessionJumpResult> {
|
|
241
|
+
if (this.sessionJumper === undefined) return 'unavailable'
|
|
242
|
+
let result: SessionJumpResult
|
|
243
|
+
try {
|
|
244
|
+
result = await this.sessionJumper(sessionId)
|
|
245
|
+
} catch {
|
|
246
|
+
return 'unavailable'
|
|
247
|
+
}
|
|
248
|
+
if (result === 'opened') this.closeBoard()
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
168
252
|
// ---------------------------------------------------------------- writes
|
|
169
|
-
/** Create a task (composer submit). */
|
|
170
|
-
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<
|
|
253
|
+
/** Create a task (composer submit); returns the new task id, undefined on failure. */
|
|
254
|
+
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<string | undefined> {
|
|
171
255
|
try {
|
|
172
|
-
await this.client.create(body)
|
|
256
|
+
const summary = await this.client.create(body)
|
|
173
257
|
this.setState({ composerOpen: false, error: undefined })
|
|
174
258
|
await this.refresh()
|
|
259
|
+
return summary.id
|
|
175
260
|
} catch (error) {
|
|
176
261
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
262
|
+
return undefined
|
|
177
263
|
}
|
|
178
264
|
}
|
|
179
265
|
|
|
180
266
|
/** Edit task fields (form modal submit; the GUI is the owner surface). */
|
|
181
|
-
async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<
|
|
267
|
+
async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<boolean> {
|
|
182
268
|
try {
|
|
183
269
|
await this.client.update(id, { ifVersion, ...body })
|
|
184
270
|
this.setState({ composerOpen: false, editingId: undefined, error: undefined })
|
|
185
271
|
await this.refresh()
|
|
272
|
+
return true
|
|
186
273
|
} catch (error) {
|
|
187
274
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
275
|
+
return false
|
|
188
276
|
}
|
|
189
277
|
}
|
|
190
278
|
|
|
@@ -228,6 +316,16 @@ export class BoardController {
|
|
|
228
316
|
}
|
|
229
317
|
}
|
|
230
318
|
|
|
319
|
+
/** Cancel the running execution (stops the agent session; task returns to todo). */
|
|
320
|
+
async cancel(id: string): Promise<void> {
|
|
321
|
+
try {
|
|
322
|
+
await this.client.cancel(id)
|
|
323
|
+
await this.refresh()
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
231
329
|
/** Soft-delete (agent parity) then optional purge. */
|
|
232
330
|
async remove(id: string, ifVersion: number, purge: boolean): Promise<void> {
|
|
233
331
|
try {
|
|
@@ -238,4 +336,69 @@ export class BoardController {
|
|
|
238
336
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
239
337
|
}
|
|
240
338
|
}
|
|
339
|
+
|
|
340
|
+
/** Duplicate a task into a fresh todo card (same project/urgency/prompt/execution/model). */
|
|
341
|
+
async duplicate(task: TaskRecord): Promise<void> {
|
|
342
|
+
try {
|
|
343
|
+
await this.client.create({
|
|
344
|
+
title: `${task.title}(副本)`,
|
|
345
|
+
workspaceId: task.workspaceId,
|
|
346
|
+
urgency: task.urgency,
|
|
347
|
+
description: task.description.length > 0 ? task.description : undefined,
|
|
348
|
+
prompt: task.prompt.length > 0 ? task.prompt : undefined,
|
|
349
|
+
execution: task.execution.mode === 'scheduled' && task.execution.cron !== undefined
|
|
350
|
+
? { mode: 'scheduled', cron: task.execution.cron }
|
|
351
|
+
: { mode: 'claim' },
|
|
352
|
+
model: task.model,
|
|
353
|
+
})
|
|
354
|
+
await this.refresh()
|
|
355
|
+
} catch (error) {
|
|
356
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Download the whole ledger as a JSON backup file. */
|
|
361
|
+
exportJson(): void {
|
|
362
|
+
const stamp = new Date()
|
|
363
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
364
|
+
const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.json`
|
|
365
|
+
const body = JSON.stringify(this.state.ledger, null, 2)
|
|
366
|
+
this.download(name, body, 'application/json')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Download the task list as a CSV (BOM-prefixed for Excel + Chinese text). */
|
|
370
|
+
exportCsv(): void {
|
|
371
|
+
const esc = (v: unknown): string => {
|
|
372
|
+
const s = String(v ?? '')
|
|
373
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
374
|
+
}
|
|
375
|
+
const header = ['id', 'title', 'status', 'urgency', 'blocked', 'project', 'claimedBy', 'mode', 'cron', 'nextRunAt', 'model', 'createdAt', 'updatedAt', 'comments', 'executions']
|
|
376
|
+
const rows = this.state.ledger.tasks.map(t => [
|
|
377
|
+
t.id, t.title, t.status, t.urgency, t.blocked ? 'yes' : 'no', t.workspaceId,
|
|
378
|
+
t.claimedBy ?? '', t.execution.mode, t.execution.cron ?? '',
|
|
379
|
+
t.execution.nextRunAt !== undefined ? new Date(t.execution.nextRunAt).toISOString() : '',
|
|
380
|
+
t.model !== undefined ? `${t.model.provider}/${t.model.model}` : '',
|
|
381
|
+
new Date(t.createdAt).toISOString(), new Date(t.updatedAt).toISOString(),
|
|
382
|
+
t.comments.length, t.executions.length,
|
|
383
|
+
].map(esc).join(','))
|
|
384
|
+
const stamp = new Date()
|
|
385
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
386
|
+
const name = `dsh-taskboard-${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}.csv`
|
|
387
|
+
this.download(name, `\uFEFF${[header.join(','), ...rows].join('\r\n')}`, 'text/csv')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Trigger a browser download (no-op when the DOM is unavailable). */
|
|
391
|
+
private download(filename: string, body: string, type: string): void {
|
|
392
|
+
try {
|
|
393
|
+
const blob = new Blob([body], { type })
|
|
394
|
+
const url = URL.createObjectURL(blob)
|
|
395
|
+
const a = document.createElement('a')
|
|
396
|
+
a.href = url
|
|
397
|
+
a.download = filename
|
|
398
|
+
a.click()
|
|
399
|
+
setTimeout(() => URL.revokeObjectURL(url), 5_000)
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
241
404
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { BoardController } from './controller.ts'
|
|
|
16
16
|
import { injectStyles } from './styles.ts'
|
|
17
17
|
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
18
18
|
import { mountBoard } from './board-mount.tsx'
|
|
19
|
+
import { createSessionJumper, type SessionsServiceFace, type WorkspacesServiceFace } from './session-jump.ts'
|
|
19
20
|
|
|
20
21
|
/** Client plugin name. */
|
|
21
22
|
export const name = 'dsh-taskboard/client'
|
|
@@ -65,6 +66,15 @@ export function apply(ctx: ClientContextFace): void {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Session navigation for execution rows: resolved LAZILY on every jump —
|
|
70
|
+
// apply may run before the runtime provides the services, and a captured
|
|
71
|
+
// undefined would permanently disable the jump. On a platform without
|
|
72
|
+
// them the jump degrades to an 'unavailable' notice instead of failing.
|
|
73
|
+
controller.installSessionJumper(createSessionJumper({
|
|
74
|
+
getSessions: () => ctx.get?.('sessions') as SessionsServiceFace | undefined,
|
|
75
|
+
getWorkspaces: () => ctx.get?.('workspaces') as WorkspacesServiceFace | undefined,
|
|
76
|
+
}))
|
|
77
|
+
|
|
68
78
|
controller.start()
|
|
69
79
|
const disposers: Array<() => void> = []
|
|
70
80
|
try {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session jump: resolve an execution's session against the runtime's live
|
|
3
|
+
* session list and open it in the GUI. The runtime's `sessions` service owns
|
|
4
|
+
* the list mirror (`list.getSnapshot().byId`) and staging (`open`); the
|
|
5
|
+
* `workspaces` service carries the registry-global archive set.
|
|
6
|
+
*
|
|
7
|
+
* Outcomes are split so the UI can prompt precisely:
|
|
8
|
+
* - `opened` — staged and opened; the board closes over it.
|
|
9
|
+
* - `archived` — in the list but archived (hidden from the sidebar; its log
|
|
10
|
+
* survives, so it is distinguishable from deletion).
|
|
11
|
+
* - `missing` — absent from the live list: deleted.
|
|
12
|
+
* - `unavailable`— runtime session services absent (service timing / errors).
|
|
13
|
+
*
|
|
14
|
+
* Service resolution is deliberately LAZY (per click): plugin apply may run
|
|
15
|
+
* before the runtime provides `sessions`, and a once-captured undefined would
|
|
16
|
+
* permanently disable the jump. When the id misses, the list mirror may also
|
|
17
|
+
* simply lag (reconnect re-pull, late mount): one `refresh()` is awaited and
|
|
18
|
+
* the lookup retried before deciding.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-taskboard/client/session-jump
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Outcome of one jump attempt. */
|
|
24
|
+
export type SessionJumpResult =
|
|
25
|
+
| 'opened'
|
|
26
|
+
| 'archived'
|
|
27
|
+
| 'missing'
|
|
28
|
+
| 'unavailable'
|
|
29
|
+
|
|
30
|
+
/** Narrow face of the runtime `sessions` service this module needs. */
|
|
31
|
+
export interface SessionsServiceFace {
|
|
32
|
+
/** Select a listed session as current (the window opens with it). */
|
|
33
|
+
open(id: string): void
|
|
34
|
+
/** Re-pull the session list baseline (mirror catch-up). */
|
|
35
|
+
refresh(): Promise<void>
|
|
36
|
+
/** Live session list snapshot. */
|
|
37
|
+
list: {
|
|
38
|
+
getSnapshot(): {
|
|
39
|
+
byId: Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Narrow face of the runtime `workspaces` service this module needs. */
|
|
45
|
+
export interface WorkspacesServiceFace {
|
|
46
|
+
/** Workspace list snapshot (carries the archive set). */
|
|
47
|
+
list: {
|
|
48
|
+
getSnapshot(): {
|
|
49
|
+
archivedSessionIds: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lazy per-click service resolution (services may appear after apply). */
|
|
55
|
+
export interface SessionServiceAccess {
|
|
56
|
+
/** The runtime sessions service, when currently provided. */
|
|
57
|
+
getSessions(): SessionsServiceFace | undefined
|
|
58
|
+
/** The runtime workspaces service, when currently provided (optional). */
|
|
59
|
+
getWorkspaces(): WorkspacesServiceFace | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the jump function the controller installs.
|
|
64
|
+
* @param access - lazy service accessors, consulted on every jump.
|
|
65
|
+
* @returns the jump function: `(sessionId) => Promise<SessionJumpResult>`.
|
|
66
|
+
*/
|
|
67
|
+
export function createSessionJumper(access: SessionServiceAccess): (sessionId: string) => Promise<SessionJumpResult> {
|
|
68
|
+
const lookup = (sessions: SessionsServiceFace, workspaces: WorkspacesServiceFace | undefined, sessionId: string): 'openable' | 'archived' | 'absent' => {
|
|
69
|
+
const list = sessions.list.getSnapshot()
|
|
70
|
+
if (list.byId[sessionId] === undefined) return 'absent'
|
|
71
|
+
const archived = workspaces?.list.getSnapshot().archivedSessionIds.includes(sessionId) ?? false
|
|
72
|
+
return archived ? 'archived' : 'openable'
|
|
73
|
+
}
|
|
74
|
+
return async (sessionId: string): Promise<SessionJumpResult> => {
|
|
75
|
+
const sessions = access.getSessions()
|
|
76
|
+
if (sessions === undefined) return 'unavailable'
|
|
77
|
+
try {
|
|
78
|
+
let state = lookup(sessions, access.getWorkspaces(), sessionId)
|
|
79
|
+
if (state === 'absent') {
|
|
80
|
+
// Only the absent case can be a lagging mirror (reconnect re-pull,
|
|
81
|
+
// late mount); archived is a definitive verdict. One refresh, re-check.
|
|
82
|
+
try { await sessions.refresh() } catch { /* keep the pre-refresh verdict */ }
|
|
83
|
+
state = lookup(sessions, access.getWorkspaces(), sessionId)
|
|
84
|
+
}
|
|
85
|
+
if (state === 'archived') return 'archived'
|
|
86
|
+
if (state === 'absent') return 'missing'
|
|
87
|
+
sessions.open(sessionId)
|
|
88
|
+
return 'opened'
|
|
89
|
+
} catch {
|
|
90
|
+
return 'unavailable'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|