dsh-taskboard 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2085 -0
  5. package/lib/host/execution.js +189 -0
  6. package/lib/host/execution.js.map +1 -0
  7. package/lib/host/protocol-text.js +37 -0
  8. package/lib/host/protocol-text.js.map +1 -0
  9. package/lib/host/routes.js +369 -0
  10. package/lib/host/routes.js.map +1 -0
  11. package/lib/host/scheduler.js +91 -0
  12. package/lib/host/scheduler.js.map +1 -0
  13. package/lib/host/sdk.js +145 -0
  14. package/lib/host/sdk.js.map +1 -0
  15. package/lib/host/store.js +112 -0
  16. package/lib/host/store.js.map +1 -0
  17. package/lib/host/tools.js +620 -0
  18. package/lib/host/tools.js.map +1 -0
  19. package/lib/index.js +91 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/invariant.js +22 -0
  22. package/lib/invariant.js.map +1 -0
  23. package/lib/shared/api.js +9 -0
  24. package/lib/shared/api.js.map +1 -0
  25. package/lib/shared/protocol.js +279 -0
  26. package/lib/shared/protocol.js.map +1 -0
  27. package/package.json +74 -0
  28. package/src/client/api.ts +90 -0
  29. package/src/client/board/NewTaskModal.tsx +8 -0
  30. package/src/client/board/TaskBoard.tsx +184 -0
  31. package/src/client/board/TaskCard.tsx +61 -0
  32. package/src/client/board/TaskDetail.tsx +210 -0
  33. package/src/client/board/TaskFormModal.tsx +257 -0
  34. package/src/client/board-mount.tsx +92 -0
  35. package/src/client/controller.ts +241 -0
  36. package/src/client/index.ts +87 -0
  37. package/src/client/sidebar-entry.ts +165 -0
  38. package/src/client/styles.ts +391 -0
  39. package/src/host/execution.ts +244 -0
  40. package/src/host/protocol-text.ts +37 -0
  41. package/src/host/routes.ts +387 -0
  42. package/src/host/scheduler.ts +107 -0
  43. package/src/host/sdk.ts +200 -0
  44. package/src/host/store.ts +139 -0
  45. package/src/host/tools.ts +631 -0
  46. package/src/index.ts +124 -0
  47. package/src/invariant.ts +22 -0
  48. package/src/shared/api.ts +98 -0
  49. package/src/shared/protocol.ts +475 -0
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "dsh-taskboard",
3
+ "description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./invariant": "./lib/invariant.js",
10
+ "./client": "./lib/client.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "dsh": {
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ },
17
+ "client": {
18
+ "inject": [],
19
+ "platform": "web"
20
+ }
21
+ },
22
+ "files": [
23
+ "lib/**/*.js",
24
+ "lib/**/*.js.map",
25
+ "src",
26
+ "cordis.patch.yml",
27
+ "LICENSE"
28
+ ],
29
+ "license": "Apache-2.0",
30
+ "author": "cloader",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/cloader/dsh-taskboard.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/cloader/dsh-taskboard/issues"
37
+ },
38
+ "homepage": "https://github.com/cloader/dsh-taskboard#readme",
39
+ "keywords": [
40
+ "dsh",
41
+ "deepseek-harness",
42
+ "dsh-plugin",
43
+ "taskboard",
44
+ "kanban",
45
+ "agent"
46
+ ],
47
+ "scripts": {
48
+ "build": "npm run build:host && npm run build:client",
49
+ "build:host": "tsdown -c tsdown.host.config.ts",
50
+ "build:client": "tsdown -c tsdown.client.config.ts && node scripts/wrap-client.mjs",
51
+ "watch": "tsdown -c tsdown.host.config.ts --watch",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run"
54
+ },
55
+ "devDependencies": {
56
+ "@deepseek-ai/cordis": "^4.0.1",
57
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
60
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
62
+ "@deepseek-ai/dsh-workspace": "^0.1.0-rc.6",
63
+ "@deepseek-ai/schemastery": "^3.18.1",
64
+ "@types/node": "^22.20.1",
65
+ "@types/react": "~18.3.1",
66
+ "@types/react-dom": "^18.3.7",
67
+ "jsdom": "^25.0.1",
68
+ "react": "^18.3.1",
69
+ "react-dom": "^18.3.1",
70
+ "tsdown": "0.22.2",
71
+ "typescript": "~5.7.2",
72
+ "vitest": "^3.0.0"
73
+ }
74
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Browser client for the /taskboard host routes: typed fetch wrappers
3
+ * (same origin as the GUI) and the SSE subscription with revision-gap
4
+ * reconciliation (a gap or a reconnect triggers one full state refetch).
5
+ *
6
+ * @module dsh-taskboard/client/api
7
+ */
8
+ import type {
9
+ ApiResult,
10
+ ChangeEvent,
11
+ CreateTaskBody,
12
+ DeleteTaskBody,
13
+ MoveTaskBody,
14
+ StateResponse,
15
+ TaskRecord,
16
+ UpdateTaskBody,
17
+ WorkspaceView,
18
+ } from '../shared/api.ts'
19
+ import type { CommentRecord, TaskSummary } from '../shared/protocol.ts'
20
+
21
+ /** Unwrap the envelope or throw a readable error. */
22
+ async function unwrap<T>(pending: Response | Promise<Response>): Promise<T> {
23
+ const res = await pending
24
+ const body = (await res.json().catch(() => null)) as ApiResult<T> | null
25
+ if (body === null) throw new Error(`taskboard: HTTP ${res.status}`)
26
+ if (!body.ok) throw new Error(`taskboard: ${body.error.code}: ${body.error.message}`)
27
+ return body.value
28
+ }
29
+
30
+ async function post<T>(path: string, body: unknown): Promise<T> {
31
+ const res = await fetch(path, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify(body),
35
+ })
36
+ return unwrap<T>(res)
37
+ }
38
+
39
+ /** Route client face (the controller consumes this narrow surface). */
40
+ export interface TaskboardClient {
41
+ state(): Promise<StateResponse>
42
+ workspaces(): Promise<WorkspaceView[]>
43
+ create(body: CreateTaskBody): Promise<TaskSummary>
44
+ get(id: string): Promise<TaskRecord>
45
+ update(id: string, body: UpdateTaskBody): Promise<TaskSummary>
46
+ move(id: string, body: MoveTaskBody): Promise<TaskSummary>
47
+ comment(id: string, bodyText: string): Promise<CommentRecord>
48
+ remove(id: string, body: DeleteTaskBody): Promise<{ trashed?: boolean; purged?: boolean }>
49
+ /** Trigger a manual run (fresh in-project session). */
50
+ run(id: string): Promise<{ executionId: string; sessionId: string }>
51
+ /** Subscribe to change frames; the disposer stops the stream. */
52
+ stream(onChange: (event: ChangeEvent) => void, onGap: () => void): () => void
53
+ }
54
+
55
+ /** Build the client over fetch + EventSource. */
56
+ export function createClient(): TaskboardClient {
57
+ return {
58
+ state: () => unwrap<StateResponse>(fetch('/dsh-taskboard/state')),
59
+ workspaces: () => unwrap<WorkspaceView[]>(fetch('/dsh-taskboard/workspaces')),
60
+ create: body => post('/dsh-taskboard/tasks', body),
61
+ get: id => unwrap<TaskRecord>(fetch(`/dsh-taskboard/tasks/${encodeURIComponent(id)}`)),
62
+ update: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/update`, body),
63
+ move: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/move`, body),
64
+ comment: (id, bodyText) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/comment`, { body: bodyText }),
65
+ remove: (id, body) => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/delete`, body),
66
+ run: id => post(`/dsh-taskboard/tasks/${encodeURIComponent(id)}/run`, {}),
67
+ stream(onChange, onGap) {
68
+ const es = new EventSource('/dsh-taskboard/events')
69
+ let revision: number | undefined
70
+ const hello = (event: MessageEvent): void => {
71
+ const payload = JSON.parse(event.data) as { revision: number }
72
+ if (revision !== undefined && payload.revision !== revision) onGap()
73
+ revision = payload.revision
74
+ }
75
+ const change = (event: MessageEvent): void => {
76
+ const payload = JSON.parse(event.data) as ChangeEvent
77
+ // A gap means we missed frames while disconnected: reconcile fully.
78
+ if (revision !== undefined && payload.revision !== revision + 1) onGap()
79
+ revision = payload.revision
80
+ onChange(payload)
81
+ }
82
+ es.addEventListener('hello', hello as EventListener)
83
+ es.addEventListener('change', change as EventListener)
84
+ es.onerror = () => { /* EventSource auto-reconnects; hello re-checks the gap */ }
85
+ return () => {
86
+ es.close()
87
+ }
88
+ },
89
+ }
90
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compatibility shim: the composer moved into TaskFormModal (create + edit
3
+ * in one dialog). Kept so existing imports keep working.
4
+ *
5
+ * @module dsh-taskboard/client/board/NewTaskModal
6
+ */
7
+ export { TaskFormModal as NewTaskModal } from './TaskFormModal.tsx'
8
+ export type { CatalogModel } from './TaskFormModal.tsx'
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The main board view: toolbar (project filter, urgency chips, secondary tab,
3
+ * composer), five status columns, the detail pane, and the new-task modal.
4
+ *
5
+ * @module dsh-taskboard/client/board/TaskBoard
6
+ */
7
+ import { useSyncExternalStore } from 'react'
8
+ import type { BoardController, ControllerState } from '../controller.ts'
9
+ import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
10
+ import { MAIN_STATUSES } from '../../shared/protocol.ts'
11
+ import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
12
+ import { TaskDetail } from './TaskDetail.tsx'
13
+ import { TaskFormModal } from './TaskFormModal.tsx'
14
+
15
+ /** Column labels. */
16
+ const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
17
+ backlog: '待规划',
18
+ todo: '待办',
19
+ in_progress: '进行中',
20
+ in_review: '待验收',
21
+ done: '已完成',
22
+ canceled: '已取消',
23
+ archived: '已归档',
24
+ }
25
+
26
+ /** The two columns between which cards may be dragged both ways. */
27
+ const DRAGGABLE_STATUSES: ReadonlySet<TaskStatus> = new Set(['backlog', 'todo'])
28
+
29
+ /** Urgency chip labels. */
30
+ const URGENCY_LABELS: Readonly<Record<Urgency, string>> = {
31
+ urgent: '紧急',
32
+ normal: '一般',
33
+ relaxed: '不急',
34
+ }
35
+
36
+ /** Format an epoch ms as a short local stamp. */
37
+ export function fmtTime(ms: number | undefined): string {
38
+ if (ms === undefined) return ''
39
+ const d = new Date(ms)
40
+ const pad = (n: number) => String(n).padStart(2, '0')
41
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
42
+ }
43
+
44
+ /** Apply the active filters to a task list. */
45
+ function filterTasks(state: ControllerState, tasks: TaskRecord[]): TaskRecord[] {
46
+ return tasks.filter(t =>
47
+ (state.filters.workspaceId === undefined || t.workspaceId === state.filters.workspaceId)
48
+ && (state.filters.urgencies.length === 0 || state.filters.urgencies.includes(t.urgency)))
49
+ }
50
+
51
+ /**
52
+ * The board view root.
53
+ * @param controller - the controller.
54
+ */
55
+ export function TaskBoard({ controller }: { controller: BoardController }) {
56
+ const state = useSyncExternalStore(
57
+ cb => controller.subscribe(cb),
58
+ () => controller.getSnapshot(),
59
+ )
60
+ const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
61
+ const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
62
+
63
+ return (
64
+ <div className="dsh-atb-board">
65
+ <div className="dsh-atb-toolbar">
66
+ <h2 className="dsh-atb-title">Agent 任务看板</h2>
67
+ <span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
68
+ <div className="dsh-atb-spacer" />
69
+ <select
70
+ className="dsh-atb-select"
71
+ value={state.filters.workspaceId ?? ''}
72
+ onChange={e => controller.setWorkspaceFilter(e.target.value === '' ? undefined : e.target.value)}
73
+ >
74
+ <option value="">全部项目</option>
75
+ {state.workspaces.map(ws => <option key={ws.id} value={ws.id}>{ws.title || ws.path}</option>)}
76
+ </select>
77
+ {(['urgent', 'normal', 'relaxed'] as const).map(u => (
78
+ <button
79
+ key={u}
80
+ type="button"
81
+ className="dsh-atb-chip"
82
+ data-urgency={u}
83
+ data-on={state.filters.urgencies.includes(u)}
84
+ onClick={() => controller.toggleUrgency(u)}
85
+ >
86
+ <span className="dsh-atb-dot" data-urgency={u} />
87
+ {URGENCY_LABELS[u]}
88
+ </button>
89
+ ))}
90
+ <button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
91
+ {state.secondaryOpen ? '返回看板' : '其它任务'}
92
+ </button>
93
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
94
+ + 新建任务
95
+ </button>
96
+ </div>
97
+
98
+ {state.error !== undefined && <div className="dsh-atb-error">{state.error}</div>}
99
+
100
+ {state.secondaryOpen
101
+ ? <SecondaryTab controller={controller} tasks={filterTasks(state, state.ledger.tasks)} />
102
+ : (
103
+ <div className="dsh-atb-columns">
104
+ {MAIN_STATUSES.map(status => {
105
+ const columnTasks = live.filter(t => t.status === status)
106
+ const dropTarget = DRAGGABLE_STATUSES.has(status)
107
+ return (
108
+ <div
109
+ className="dsh-atb-column"
110
+ key={status}
111
+ onDragOver={dropTarget
112
+ ? (e) => {
113
+ if (e.dataTransfer.types.includes(DRAG_TYPE)) {
114
+ e.preventDefault()
115
+ e.dataTransfer.dropEffect = 'move'
116
+ e.currentTarget.dataset.dragover = 'true'
117
+ }
118
+ }
119
+ : undefined}
120
+ onDragLeave={dropTarget
121
+ ? (e) => { delete e.currentTarget.dataset.dragover }
122
+ : undefined}
123
+ onDrop={dropTarget
124
+ ? (e) => {
125
+ e.preventDefault()
126
+ delete e.currentTarget.dataset.dragover
127
+ const id = e.dataTransfer.getData(DRAG_TYPE)
128
+ if (id.length === 0) return
129
+ const task = state.ledger.tasks.find(t => t.id === id)
130
+ if (task === undefined || task.status === status) return
131
+ void controller.move(id, task.version, status)
132
+ }
133
+ : undefined}
134
+ >
135
+ <div className="dsh-atb-colhead">
136
+ {COLUMN_LABELS[status]}
137
+ <span className="dsh-atb-colcount">{columnTasks.length}</span>
138
+ </div>
139
+ <div className="dsh-atb-cards">
140
+ {columnTasks.map(task => (
141
+ <TaskCard
142
+ key={task.id}
143
+ task={task}
144
+ controller={controller}
145
+ draggable={dropTarget}
146
+ />
147
+ ))}
148
+ {columnTasks.length === 0 && <div className="dsh-atb-empty">无任务</div>}
149
+ </div>
150
+ </div>
151
+ )
152
+ })}
153
+ </div>
154
+ )}
155
+
156
+ {selected !== undefined && (
157
+ <div className="dsh-atb-detailpanel">
158
+ <TaskDetail task={selected} controller={controller} />
159
+ </div>
160
+ )}
161
+
162
+ {state.composerOpen && (
163
+ <TaskFormModal
164
+ controller={controller}
165
+ task={state.editingId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.editingId)}
166
+ />
167
+ )}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ /** Secondary tab: canceled/archived/trashed rows. */
173
+ function SecondaryTab({ controller, tasks }: { controller: BoardController; tasks: TaskRecord[] }) {
174
+ const rows = tasks.filter(t => t.status === 'canceled' || t.status === 'archived' || t.trashedAt !== undefined)
175
+ return (
176
+ <div className="dsh-atb-secondary">
177
+ {rows.length === 0 && <div className="dsh-atb-empty">无已取消 / 已归档 / 已删除任务</div>}
178
+ {rows.map(task => (
179
+ <TaskCard key={task.id} task={task} controller={controller} />
180
+ ))}
181
+ {void controller}
182
+ </div>
183
+ )
184
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One board card: urgency edge, title, project/urgency/model/schedule/
3
+ * blocked/trashed badges, comment count, and the last execution outcome.
4
+ * Click opens the detail pane; cards in the backlog/todo columns are
5
+ * draggable between those two columns (HTML5 drag & drop).
6
+ *
7
+ * @module dsh-taskboard/client/board/TaskCard
8
+ */
9
+ import type { BoardController } from '../controller.ts'
10
+ import type { TaskRecord } from '../../shared/protocol.ts'
11
+ import { fmtTime } from './TaskBoard.tsx'
12
+
13
+ const URGENCY_LABEL: Record<TaskRecord['urgency'], string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
14
+ const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
15
+
16
+ /** dataTransfer type carrying the dragged task id. */
17
+ export const DRAG_TYPE = 'application/x-dsh-atb-task'
18
+
19
+ /**
20
+ * The card view.
21
+ * @param task - the task record.
22
+ * @param controller - the controller.
23
+ * @param draggable - enable dragging (backlog/todo columns only).
24
+ */
25
+ export function TaskCard({ task, controller, draggable = false }: { task: TaskRecord; controller: BoardController; draggable?: boolean }) {
26
+ const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
27
+ return (
28
+ <button
29
+ type="button"
30
+ className="dsh-atb-card"
31
+ data-urgency={task.urgency}
32
+ draggable={draggable}
33
+ onDragStart={(e) => {
34
+ e.dataTransfer.setData(DRAG_TYPE, task.id)
35
+ e.dataTransfer.effectAllowed = 'move'
36
+ e.currentTarget.dataset.dragging = 'true'
37
+ }}
38
+ onDragEnd={(e) => { delete e.currentTarget.dataset.dragging }}
39
+ onClick={() => controller.select(task.id)}
40
+ >
41
+ <div className="dsh-atb-card-title">{task.title}</div>
42
+ <div className="dsh-atb-card-meta">
43
+ <span className="dsh-atb-badge">{URGENCY_LABEL[task.urgency]}</span>
44
+ {task.blocked && <span className="dsh-atb-badge" data-kind="blocked">受阻</span>}
45
+ {task.execution.mode === 'scheduled' && (
46
+ <span className="dsh-atb-badge" data-kind="scheduled">⏰ {fmtTime(task.execution.nextRunAt)}</span>
47
+ )}
48
+ {task.model !== undefined && <span className="dsh-atb-badge">{task.model.model}</span>}
49
+ {task.status === 'done' && <span className="dsh-atb-badge" data-kind="done">完成</span>}
50
+ {last !== undefined && (
51
+ <span className="dsh-atb-badge" data-kind={last.outcome === 'running' ? 'running' : last.outcome}>
52
+ {OUTCOME_LABEL[last.outcome] ?? last.outcome}
53
+ </span>
54
+ )}
55
+ {task.comments.length > 0 && <span>💬 {task.comments.length}</span>}
56
+ {task.trashedAt !== undefined && <span className="dsh-atb-badge" data-kind="trashed">待清除</span>}
57
+ <span style={{ marginLeft: 'auto' }}>{fmtTime(task.updatedAt)}</span>
58
+ </div>
59
+ </button>
60
+ )
61
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The task detail pane — visually polished: urgency accent header with
3
+ * status pill and meta chips, card-wrapped description/prompt, chat-style
4
+ * comment bubbles distinguishing user vs agent authors, a timeline of
5
+ * executions with outcome pills, grouped actions (run / transitions /
6
+ * danger zone), and the user comment composer.
7
+ *
8
+ * @module dsh-taskboard/client/board/TaskDetail
9
+ */
10
+ import { useState, type ReactNode } from 'react'
11
+ import type { BoardController } from '../controller.ts'
12
+ import type { TaskRecord } from '../../shared/protocol.ts'
13
+ import { canTransition } from '../../shared/protocol.ts'
14
+ import { fmtTime } from './TaskBoard.tsx'
15
+
16
+ /** Statuses a user may move this task to, per the state machine. */
17
+ function moveTargets(task: TaskRecord): TaskRecord['status'][] {
18
+ const all: TaskRecord['status'][] = ['backlog', 'todo', 'in_progress', 'in_review', 'done', 'canceled', 'archived']
19
+ return all.filter(to => canTransition(task.status, to))
20
+ }
21
+
22
+ const MOVE_LABEL: Record<string, string> = {
23
+ backlog: '待规划', todo: '待办', in_progress: '进行中', in_review: '待验收',
24
+ done: '完成', canceled: '取消', archived: '归档',
25
+ }
26
+ const STATUS_LABEL: Record<string, string> = { ...MOVE_LABEL }
27
+ const URGENCY_LABEL: Record<string, string> = { urgent: '紧急', normal: '一般', relaxed: '不急' }
28
+ const OUTCOME_LABEL: Record<string, string> = { running: '执行中', succeeded: '成功', failed: '失败', cancelled: '已取消' }
29
+
30
+ /** Compact session-id display. */
31
+ function shortId(id: string | undefined): string {
32
+ if (id === undefined) return ''
33
+ return id.replace(/^session-/, '').slice(0, 8)
34
+ }
35
+
36
+ /** Execution duration between start and end. */
37
+ function duration(startedAt: number | undefined, endedAt: number | undefined): string {
38
+ if (startedAt === undefined || endedAt === undefined) return ''
39
+ const s = Math.max(0, Math.round((endedAt - startedAt) / 1000))
40
+ if (s < 60) return `${s}s`
41
+ if (s < 3600) return `${Math.floor(s / 60)}m${s % 60}s`
42
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
43
+ }
44
+
45
+ /** Small labelled meta chip. */
46
+ function Chip({ icon, children, tone }: { icon?: string; children: ReactNode; tone?: string }) {
47
+ return <span className="dsh-atb-chip2" data-tone={tone}>{icon !== undefined && <span className="dsh-atb-chip2-icon">{icon}</span>}{children}</span>
48
+ }
49
+
50
+ /**
51
+ * The detail view.
52
+ * @param task - the task record.
53
+ * @param controller - the controller.
54
+ */
55
+ export function TaskDetail({ task, controller }: { task: TaskRecord; controller: BoardController }) {
56
+ const [comment, setComment] = useState('')
57
+ const [confirmDone, setConfirmDone] = useState(false)
58
+ const [confirmPurge, setConfirmPurge] = useState(false)
59
+ const ws = controller.getSnapshot().workspaces.find(w => w.id === task.workspaceId)
60
+ const canRun = task.status !== 'in_progress' && task.status !== 'done' && task.status !== 'archived'
61
+
62
+ return (
63
+ <div className="dsh-atb-detail" data-urgency={task.urgency}>
64
+ <div className="dsh-atb-detail-head">
65
+ <div className="dsh-atb-detail-titlewrap">
66
+ <div className="dsh-atb-detail-titlebar">
67
+ <h3>{task.title}</h3>
68
+ <span className="dsh-atb-statuspill" data-status={task.status}>{STATUS_LABEL[task.status] ?? task.status}</span>
69
+ </div>
70
+ <div className="dsh-atb-detail-chips">
71
+ <Chip tone={task.urgency}>● {URGENCY_LABEL[task.urgency] ?? task.urgency}</Chip>
72
+ <Chip icon="📁">{ws?.title ?? shortId(task.workspaceId)}</Chip>
73
+ {task.model !== undefined && <Chip icon="✦">{task.model.model}</Chip>}
74
+ {task.execution.mode === 'scheduled' && (
75
+ <Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
76
+ )}
77
+ {task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
78
+ {task.trashedAt !== undefined && <Chip icon="🗑" tone="urgent">已删除待清除</Chip>}
79
+ <Chip>v{task.version}</Chip>
80
+ </div>
81
+ <div className="dsh-atb-detail-sub">
82
+ 更新 {fmtTime(task.updatedAt)} · 最近操作 {task.updatedBy.kind === 'agent' ? `🤖 ${shortId(task.updatedBy.sessionId)}` : '👤 用户'}
83
+ </div>
84
+ </div>
85
+ <div className="dsh-atb-detail-topbtns">
86
+ <button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
87
+ <button type="button" className="dsh-atb-detail-close" aria-label="关闭" onClick={() => controller.select(undefined)}>✕</button>
88
+ </div>
89
+ </div>
90
+
91
+ {task.description.length > 0 && (
92
+ <div className="dsh-atb-fieldcard">
93
+ <div className="dsh-atb-fieldcard-label">描述</div>
94
+ <div className="dsh-atb-desc">{task.description}</div>
95
+ </div>
96
+ )}
97
+
98
+ {task.prompt.length > 0 && (
99
+ <div className="dsh-atb-fieldcard" data-kind="prompt">
100
+ <div className="dsh-atb-fieldcard-label">执行 Prompt</div>
101
+ <div className="dsh-atb-promptbox">{task.prompt}</div>
102
+ </div>
103
+ )}
104
+
105
+ <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
+ <div className="dsh-atb-movebtns">
112
+ {moveTargets(task).map(to => to === 'done'
113
+ ? (confirmDone
114
+ ? (
115
+ <span key={to} className="dsh-atb-confirm">
116
+ <span className="dsh-atb-confirm-label">确认完成?</span>
117
+ <button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => { void controller.move(task.id, task.version, 'done'); setConfirmDone(false) }}>确认</button>
118
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
119
+ </span>
120
+ )
121
+ : <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>✓ {MOVE_LABEL[to]}</button>)
122
+ : (
123
+ <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]}
125
+ </button>
126
+ ))}
127
+ <button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
128
+ {task.blocked ? '✓ 解除受阻' : '⛔ 标记受阻'}
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ <div className="dsh-atb-section">
134
+ <h4>评论{task.comments.length > 0 && <span className="dsh-atb-count2">{task.comments.length}</span>}</h4>
135
+ {task.comments.length === 0
136
+ ? <div className="dsh-atb-empty2">暂无评论 — agent 交接时会在这里汇报改动与验证结果</div>
137
+ : (
138
+ <div className="dsh-atb-commentlist">
139
+ {task.comments.map(c => (
140
+ <div key={c.id} className="dsh-atb-bubble" data-from={c.threadId !== undefined ? 'agent' : 'user'}>
141
+ <div className="dsh-atb-bubble-avatar">{c.threadId !== undefined ? '🤖' : '👤'}</div>
142
+ <div className="dsh-atb-bubble-main">
143
+ <div className="dsh-atb-bubble-meta">
144
+ <b>{c.threadId !== undefined ? `agent ${shortId(c.threadId)}` : '用户'}</b>
145
+ <span>{fmtTime(c.createdAt)}</span>
146
+ </div>
147
+ <div className="dsh-atb-bubble-body">{c.body}</div>
148
+ </div>
149
+ </div>
150
+ ))}
151
+ </div>
152
+ )}
153
+ <div className="dsh-atb-composer">
154
+ <textarea
155
+ className="dsh-atb-composer-input"
156
+ value={comment}
157
+ placeholder="以用户身份留言(agent 开工前会读)…"
158
+ onChange={e => setComment(e.target.value)}
159
+ onKeyDown={e => {
160
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && comment.trim().length > 0) {
161
+ void controller.comment(task.id, comment)
162
+ setComment('')
163
+ }
164
+ }}
165
+ />
166
+ <button
167
+ type="button"
168
+ className="dsh-atb-composer-send"
169
+ disabled={comment.trim().length === 0}
170
+ onClick={() => { void controller.comment(task.id, comment); setComment('') }}
171
+ >
172
+ 发表
173
+ </button>
174
+ </div>
175
+ </div>
176
+
177
+ {task.executions.length > 0 && (
178
+ <div className="dsh-atb-section">
179
+ <h4>执行记录<span className="dsh-atb-count2">{task.executions.length}</span></h4>
180
+ <div className="dsh-atb-execlist">
181
+ {task.executions.map(e => (
182
+ <div key={e.id} className="dsh-atb-exec-row">
183
+ <span className="dsh-atb-exec-dot" data-outcome={e.outcome} />
184
+ <span className="dsh-atb-exec-trigger">{e.trigger === 'manual' ? '手动' : '定时'}</span>
185
+ <span className="dsh-atb-exec-outcome" data-outcome={e.outcome}>{OUTCOME_LABEL[e.outcome] ?? e.outcome}</span>
186
+ <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>}
188
+ {e.error !== undefined && <span className="dsh-atb-exec-error" title={e.error}>{e.error.slice(0, 80)}{e.error.length > 80 ? '…' : ''}</span>}
189
+ </div>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ <div className="dsh-atb-dangerzone">
196
+ {task.trashedAt === undefined
197
+ ? <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => void controller.remove(task.id, task.version, false)}>🗑 删除(标记待清除)</button>
198
+ : (confirmPurge
199
+ ? (
200
+ <span className="dsh-atb-confirm">
201
+ <span className="dsh-atb-confirm-label">物理清除不可恢复</span>
202
+ <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.remove(task.id, task.version, true); setConfirmPurge(false) }}>确认清除</button>
203
+ <button type="button" className="dsh-atb-btn" onClick={() => setConfirmPurge(false)}>取消</button>
204
+ </span>
205
+ )
206
+ : <button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => setConfirmPurge(true)}>🔥 物理清除(需确认)</button>)}
207
+ </div>
208
+ </div>
209
+ )
210
+ }