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.
@@ -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 { fmtTime } from './TaskBoard.tsx'
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-/, '').slice(0, 8)
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)}>✓ {MOVE_LABEL[to]}</button>)
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></h4>
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 && <span className="dsh-atb-exec-session" title={e.sessionId}>🤖 {shortId(e.sessionId)}</span>}
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
@@ -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<void> {
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<void> {
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
  }
@@ -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
+ }