@linxin666/dsh-client-ui-task-board 0.1.1
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/LICENSE +29 -0
- package/README.md +211 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +2592 -0
- package/lib/index.js +54 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts +77 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -0
- package/lib/types/client/board/TaskBoard.d.ts +5 -0
- package/lib/types/client/board/TaskCard.d.ts +12 -0
- package/lib/types/client/board/TaskDetail.d.ts +7 -0
- package/lib/types/client/board-mount.d.ts +10 -0
- package/lib/types/client/index.d.ts +43 -0
- package/lib/types/client/locales.d.ts +105 -0
- package/lib/types/client/settings-form.d.ts +118 -0
- package/lib/types/client/sidebar-entry.d.ts +25 -0
- package/lib/types/core/controller.d.ts +129 -0
- package/lib/types/core/execution.d.ts +136 -0
- package/lib/types/core/schedule.d.ts +40 -0
- package/lib/types/core/scheduler.d.ts +49 -0
- package/lib/types/core/store.d.ts +36 -0
- package/lib/types/core/tasks.d.ts +111 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/package.json +85 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/TaskBoardSettingsCard.tsx +124 -0
- package/src/client/board/ConfirmDialog.tsx +40 -0
- package/src/client/board/NewTaskModal.tsx +82 -0
- package/src/client/board/TaskBoard.tsx +107 -0
- package/src/client/board/TaskCard.tsx +62 -0
- package/src/client/board/TaskDetail.tsx +286 -0
- package/src/client/board-mount.tsx +74 -0
- package/src/client/board.module.css +849 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +211 -0
- package/src/client/settings-card.module.css +261 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/sidebar-entry.ts +118 -0
- package/src/core/controller.ts +379 -0
- package/src/core/execution.ts +259 -0
- package/src/core/schedule.ts +140 -0
- package/src/core/scheduler.ts +104 -0
- package/src/core/store.ts +183 -0
- package/src/core/tasks.ts +204 -0
- package/src/index.ts +94 -0
- package/src/invariant.ts +4 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task detail: the full view of one task — content, prompt, execution
|
|
3
|
+
* history — and the only place execution can be triggered. Also offers
|
|
4
|
+
* delete (with confirmation), manual status moves, and a jump to the
|
|
5
|
+
* execution's session transcript.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useState } from 'react'
|
|
8
|
+
import type { BoardController } from '../../core/controller.ts'
|
|
9
|
+
import { isValidCron } from '../../core/schedule.ts'
|
|
10
|
+
import { MANUAL_STATUSES, type ExecutionRecord, type TaskRecord, type TaskStatus } from '../../core/tasks.ts'
|
|
11
|
+
import { t, type TaskBoardKey } from '../locales.ts'
|
|
12
|
+
import css from '../board.module.css'
|
|
13
|
+
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
14
|
+
import { formatTime } from './TaskCard.tsx'
|
|
15
|
+
|
|
16
|
+
/** Execution outcome → locale key. */
|
|
17
|
+
const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
|
|
18
|
+
succeeded: 'detail.result.succeeded',
|
|
19
|
+
failed: 'detail.result.failed',
|
|
20
|
+
cancelled: 'detail.result.cancelled',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Status → locale key (detail badge). */
|
|
24
|
+
const STATUS_KEY: Record<TaskStatus, TaskBoardKey> = {
|
|
25
|
+
backlog: 'board.status.backlog',
|
|
26
|
+
todo: 'board.status.todo',
|
|
27
|
+
running: 'board.status.running',
|
|
28
|
+
done: 'board.status.done',
|
|
29
|
+
failed: 'board.status.failed',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One execution-history row. */
|
|
33
|
+
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
34
|
+
const result = execution.result
|
|
35
|
+
return (
|
|
36
|
+
<li className={css.executionRow} data-result={result}>
|
|
37
|
+
<span className={css.executionBadge} data-result={result}>
|
|
38
|
+
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
39
|
+
</span>
|
|
40
|
+
<span className={css.executionTimes}>
|
|
41
|
+
{t('detail.executionStarted')} {formatTime(execution.startedAt)}
|
|
42
|
+
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt)}`}
|
|
43
|
+
</span>
|
|
44
|
+
{execution.sessionId !== undefined && (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
className={css.linkButton}
|
|
48
|
+
onClick={() => { onOpen(execution.sessionId as string) }}
|
|
49
|
+
title={execution.sessionId}
|
|
50
|
+
>
|
|
51
|
+
{t('detail.viewSession')} ⌁
|
|
52
|
+
</button>
|
|
53
|
+
)}
|
|
54
|
+
{execution.error !== undefined && execution.error !== '' && (
|
|
55
|
+
<span className={css.executionError}>{execution.error}</span>
|
|
56
|
+
)}
|
|
57
|
+
</li>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Common scheduled-run presets (cron → locale label). */
|
|
62
|
+
const SCHEDULE_PRESETS: ReadonlyArray<{ cron: string; label: TaskBoardKey }> = [
|
|
63
|
+
{ cron: '0 9 * * *', label: 'detail.schedule.preset.daily9' },
|
|
64
|
+
{ cron: '0 * * * *', label: 'detail.schedule.preset.hourly' },
|
|
65
|
+
{ cron: '*/10 * * * *', label: 'detail.schedule.preset.tenMin' },
|
|
66
|
+
{ cron: '0 9 * * 1', label: 'detail.schedule.preset.weeklyMon9' },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
70
|
+
function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
71
|
+
const schedule = task.schedule
|
|
72
|
+
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
73
|
+
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
74
|
+
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
75
|
+
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
76
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
77
|
+
|
|
78
|
+
// Keep the editor in sync when the task record changes underneath (the
|
|
79
|
+
// schedule rolls forward as runs trigger).
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
setCron(schedule?.cron ?? '0 9 * * *')
|
|
82
|
+
setEnabled(schedule?.enabled ?? false)
|
|
83
|
+
setNextRunAt(schedule?.nextRunAt)
|
|
84
|
+
setLastTriggeredAt(schedule?.lastTriggeredAt)
|
|
85
|
+
setError(undefined)
|
|
86
|
+
}, [task.id, schedule?.enabled, schedule?.cron, schedule?.nextRunAt, schedule?.lastTriggeredAt])
|
|
87
|
+
|
|
88
|
+
/** Validate + persist the current cron text (Enter or blur). */
|
|
89
|
+
const saveCron = (value: string): void => {
|
|
90
|
+
const trimmed = value.trim()
|
|
91
|
+
setCron(trimmed)
|
|
92
|
+
if (trimmed === '' || !isValidCron(trimmed)) {
|
|
93
|
+
setError(t('detail.schedule.invalid'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
setError(undefined)
|
|
97
|
+
controller.setSchedule(task.id, { cron: trimmed })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Arm/disarm the schedule (arming first persists the edited cron). */
|
|
101
|
+
const toggleEnabled = (next: boolean): void => {
|
|
102
|
+
const trimmed = cron.trim()
|
|
103
|
+
if (next && (trimmed === '' || !isValidCron(trimmed))) {
|
|
104
|
+
setError(t('detail.schedule.invalid'))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
setError(undefined)
|
|
108
|
+
if (next && trimmed !== schedule?.cron) controller.setSchedule(task.id, { cron: trimmed })
|
|
109
|
+
if (controller.setSchedule(task.id, { enabled: next })) setEnabled(next)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const applyPreset = (preset: string): void => {
|
|
113
|
+
if (preset === '') return
|
|
114
|
+
setCron(preset)
|
|
115
|
+
setError(undefined)
|
|
116
|
+
controller.setSchedule(task.id, { cron: preset })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const nextLabel = !enabled || nextRunAt === undefined
|
|
120
|
+
? t('detail.schedule.notScheduled')
|
|
121
|
+
: nextRunAt <= Date.now()
|
|
122
|
+
? t('detail.schedule.dueSoon')
|
|
123
|
+
: new Date(nextRunAt).toLocaleString()
|
|
124
|
+
const lastLabel = lastTriggeredAt === undefined ? '—' : new Date(lastTriggeredAt).toLocaleString()
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<section className={css.detailSection}>
|
|
128
|
+
<h4>{t('detail.schedule')}</h4>
|
|
129
|
+
<label className={css.scheduleToggle}>
|
|
130
|
+
<input
|
|
131
|
+
type="checkbox"
|
|
132
|
+
checked={enabled}
|
|
133
|
+
onChange={event => { toggleEnabled(event.target.checked) }}
|
|
134
|
+
/>
|
|
135
|
+
<span>{t('detail.schedule.enable')}</span>
|
|
136
|
+
</label>
|
|
137
|
+
<div className={css.scheduleRow}>
|
|
138
|
+
<input
|
|
139
|
+
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
140
|
+
value={cron}
|
|
141
|
+
placeholder="0 9 * * *"
|
|
142
|
+
spellCheck={false}
|
|
143
|
+
aria-label={t('detail.schedule.cron')}
|
|
144
|
+
onChange={event => { setCron(event.target.value); setError(undefined) }}
|
|
145
|
+
onBlur={() => { saveCron(cron) }}
|
|
146
|
+
onKeyDown={event => { if (event.key === 'Enter') saveCron(cron) }}
|
|
147
|
+
/>
|
|
148
|
+
<select
|
|
149
|
+
className={css.schedulePreset}
|
|
150
|
+
value=""
|
|
151
|
+
aria-label={t('detail.schedule.presets')}
|
|
152
|
+
onChange={event => { applyPreset(event.target.value) }}
|
|
153
|
+
>
|
|
154
|
+
<option value="">{t('detail.schedule.presets')}…</option>
|
|
155
|
+
{SCHEDULE_PRESETS.map(preset => (
|
|
156
|
+
<option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
|
|
157
|
+
))}
|
|
158
|
+
</select>
|
|
159
|
+
</div>
|
|
160
|
+
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
161
|
+
<p className={css.scheduleMeta}>
|
|
162
|
+
{t('detail.schedule.nextRun')} {nextLabel}
|
|
163
|
+
{' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
|
|
164
|
+
</p>
|
|
165
|
+
</section>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Task detail overlay. */
|
|
170
|
+
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
171
|
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
172
|
+
const running = task.status === 'running'
|
|
173
|
+
|
|
174
|
+
// Keep the overlay in sync if the task record changes underneath.
|
|
175
|
+
const [latest, setLatest] = useState(task)
|
|
176
|
+
useEffect(() => { setLatest(task) }, [task])
|
|
177
|
+
const current = latest
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
|
|
181
|
+
<div className={css.detail} role="dialog" aria-label={t('detail.title')}>
|
|
182
|
+
<header className={css.detailHeader}>
|
|
183
|
+
<h2 className={css.detailTitle}>{current.title}</h2>
|
|
184
|
+
<span className={css.statusBadge} data-status={current.status}>{t(STATUS_KEY[current.status])}</span>
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
className={css.iconButton}
|
|
188
|
+
aria-label={t('detail.close')}
|
|
189
|
+
onClick={() => { controller.closeTask() }}
|
|
190
|
+
>
|
|
191
|
+
×
|
|
192
|
+
</button>
|
|
193
|
+
</header>
|
|
194
|
+
|
|
195
|
+
<div className={css.detailBody}>
|
|
196
|
+
<section className={css.detailSection}>
|
|
197
|
+
<h4>{t('detail.description')}</h4>
|
|
198
|
+
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
199
|
+
</section>
|
|
200
|
+
|
|
201
|
+
<section className={css.detailSection}>
|
|
202
|
+
<h4>{t('detail.prompt')}</h4>
|
|
203
|
+
<pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
|
|
204
|
+
</section>
|
|
205
|
+
|
|
206
|
+
<ScheduleSection controller={controller} task={current} />
|
|
207
|
+
|
|
208
|
+
<section className={css.detailSection}>
|
|
209
|
+
<h4>{t('detail.execution')}</h4>
|
|
210
|
+
{current.executions.length === 0 ? (
|
|
211
|
+
<p className={css.detailText}>{t('detail.noExecution')}</p>
|
|
212
|
+
) : (
|
|
213
|
+
<ul className={css.executionList}>
|
|
214
|
+
{[...current.executions].reverse().map(execution => (
|
|
215
|
+
<ExecutionRow
|
|
216
|
+
key={execution.id}
|
|
217
|
+
execution={execution}
|
|
218
|
+
onOpen={sessionId => { controller.openSession(sessionId) }}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</ul>
|
|
222
|
+
)}
|
|
223
|
+
</section>
|
|
224
|
+
|
|
225
|
+
<section className={css.detailSection}>
|
|
226
|
+
<h4>{t('board.status')}</h4>
|
|
227
|
+
<div className={css.moveRow}>
|
|
228
|
+
{MANUAL_STATUSES.map(status => (
|
|
229
|
+
<button
|
|
230
|
+
key={status}
|
|
231
|
+
type="button"
|
|
232
|
+
className={css.ghostButton}
|
|
233
|
+
disabled={current.status === status || running}
|
|
234
|
+
onClick={() => { controller.moveTask(current.id, status) }}
|
|
235
|
+
>
|
|
236
|
+
{t(`status.move.${status}` as TaskBoardKey)}
|
|
237
|
+
</button>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
</section>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<footer className={css.detailFooter}>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
className={css.primaryButton}
|
|
247
|
+
disabled={running}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
// Running kicks off a real agent session; close the detail so
|
|
250
|
+
// the whole board stays visible while the task executes.
|
|
251
|
+
controller.closeTask()
|
|
252
|
+
void controller.rerunTask(current.id)
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
256
|
+
</button>
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
className={css.dangerButton}
|
|
260
|
+
onClick={() => { setConfirmDelete(true) }}
|
|
261
|
+
>
|
|
262
|
+
{t('detail.delete')}
|
|
263
|
+
</button>
|
|
264
|
+
<span className={css.detailMeta}>
|
|
265
|
+
{t('board.created')} {formatTime(current.createdAt)}
|
|
266
|
+
</span>
|
|
267
|
+
</footer>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{confirmDelete && (
|
|
271
|
+
<ConfirmDialog
|
|
272
|
+
title={t('delete.title')}
|
|
273
|
+
message={t('delete.confirm', { name: current.title })}
|
|
274
|
+
confirmLabel={t('delete.ok')}
|
|
275
|
+
danger
|
|
276
|
+
onCancel={() => { setConfirmDelete(false) }}
|
|
277
|
+
onConfirm={() => {
|
|
278
|
+
setConfirmDelete(false)
|
|
279
|
+
controller.deleteTask(current.id)
|
|
280
|
+
controller.closeTask()
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board view mounting.
|
|
3
|
+
*
|
|
4
|
+
* The `conversation` slot is single-occupant (ui-conversation) and external
|
|
5
|
+
* plugins cannot declare slots, so the board takes over the center column at
|
|
6
|
+
* the DOM level: a container is appended inside the `[data-pane="conversation"]`
|
|
7
|
+
* grid item (an extra trailing child React never manages), and a stylesheet
|
|
8
|
+
* rule hides the conversation content while the board is active. Toggling is
|
|
9
|
+
* a data attribute on <html> — no React involvement, so the conversation
|
|
10
|
+
* subtree underneath stays mounted and stateful.
|
|
11
|
+
*/
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
13
|
+
import type { BoardController } from '../core/controller.ts'
|
|
14
|
+
import { TaskBoard } from './board/TaskBoard.tsx'
|
|
15
|
+
import css from './board.module.css'
|
|
16
|
+
|
|
17
|
+
/** The injected board container (kept in the DOM, hidden when inactive). */
|
|
18
|
+
export const BOARD_VIEW_SELECTOR = '[data-dsh-taskboard-view]'
|
|
19
|
+
|
|
20
|
+
const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
|
|
21
|
+
const ACTIVE_ATTR = 'data-dsh-taskboard-active'
|
|
22
|
+
|
|
23
|
+
/** Find the center column, or undefined while the frame is not mounted. */
|
|
24
|
+
function conversationColumn(): HTMLElement | undefined {
|
|
25
|
+
return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mount the board React tree into the center column and bind its visibility
|
|
30
|
+
* to the controller's boardOpen state.
|
|
31
|
+
* @param controller - the board controller driving the view.
|
|
32
|
+
* @returns disposer unmounting the tree and restoring the column.
|
|
33
|
+
*/
|
|
34
|
+
export function mountBoard(controller: BoardController): () => void {
|
|
35
|
+
let root: Root | undefined
|
|
36
|
+
let container: HTMLDivElement | undefined
|
|
37
|
+
|
|
38
|
+
const ensure = (): void => {
|
|
39
|
+
if (container !== undefined) return
|
|
40
|
+
const column = conversationColumn()
|
|
41
|
+
if (column === undefined) return
|
|
42
|
+
container = document.createElement('div')
|
|
43
|
+
container.dataset.dshTaskboardView = ''
|
|
44
|
+
container.className = css.boardView
|
|
45
|
+
column.appendChild(container)
|
|
46
|
+
root = createRoot(container)
|
|
47
|
+
root.render(<TaskBoard controller={controller} />)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The frame mounts after boot settlement; watch for the column's arrival.
|
|
51
|
+
const waitObserver = new MutationObserver(() => { ensure() })
|
|
52
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
53
|
+
|
|
54
|
+
const applyActive = (): void => {
|
|
55
|
+
if (controller.getSnapshot().boardOpen) {
|
|
56
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '')
|
|
57
|
+
} else {
|
|
58
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const unsubscribe = controller.subscribe(applyActive)
|
|
62
|
+
applyActive()
|
|
63
|
+
ensure()
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
waitObserver.disconnect()
|
|
67
|
+
unsubscribe()
|
|
68
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
69
|
+
root?.unmount()
|
|
70
|
+
root = undefined
|
|
71
|
+
container?.remove()
|
|
72
|
+
container = undefined
|
|
73
|
+
}
|
|
74
|
+
}
|