@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.
Files changed (50) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +211 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/client.js +2592 -0
  5. package/lib/index.js +54 -0
  6. package/lib/invariant.js +6 -0
  7. package/lib/types/client/PluginSettingsCard.d.ts +77 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +51 -0
  9. package/lib/types/client/board/ConfirmDialog.d.ts +12 -0
  10. package/lib/types/client/board/NewTaskModal.d.ts +6 -0
  11. package/lib/types/client/board/TaskBoard.d.ts +5 -0
  12. package/lib/types/client/board/TaskCard.d.ts +12 -0
  13. package/lib/types/client/board/TaskDetail.d.ts +7 -0
  14. package/lib/types/client/board-mount.d.ts +10 -0
  15. package/lib/types/client/index.d.ts +43 -0
  16. package/lib/types/client/locales.d.ts +105 -0
  17. package/lib/types/client/settings-form.d.ts +118 -0
  18. package/lib/types/client/sidebar-entry.d.ts +25 -0
  19. package/lib/types/core/controller.d.ts +129 -0
  20. package/lib/types/core/execution.d.ts +136 -0
  21. package/lib/types/core/schedule.d.ts +40 -0
  22. package/lib/types/core/scheduler.d.ts +49 -0
  23. package/lib/types/core/store.d.ts +36 -0
  24. package/lib/types/core/tasks.d.ts +111 -0
  25. package/lib/types/index.d.ts +44 -0
  26. package/lib/types/invariant.d.ts +3 -0
  27. package/package.json +85 -0
  28. package/src/client/PluginSettingsCard.tsx +207 -0
  29. package/src/client/TaskBoardSettingsCard.tsx +124 -0
  30. package/src/client/board/ConfirmDialog.tsx +40 -0
  31. package/src/client/board/NewTaskModal.tsx +82 -0
  32. package/src/client/board/TaskBoard.tsx +107 -0
  33. package/src/client/board/TaskCard.tsx +62 -0
  34. package/src/client/board/TaskDetail.tsx +286 -0
  35. package/src/client/board-mount.tsx +74 -0
  36. package/src/client/board.module.css +849 -0
  37. package/src/client/css-modules.d.ts +5 -0
  38. package/src/client/index.ts +167 -0
  39. package/src/client/locales.ts +211 -0
  40. package/src/client/settings-card.module.css +261 -0
  41. package/src/client/settings-form.ts +294 -0
  42. package/src/client/sidebar-entry.ts +118 -0
  43. package/src/core/controller.ts +379 -0
  44. package/src/core/execution.ts +259 -0
  45. package/src/core/schedule.ts +140 -0
  46. package/src/core/scheduler.ts +104 -0
  47. package/src/core/store.ts +183 -0
  48. package/src/core/tasks.ts +204 -0
  49. package/src/index.ts +94 -0
  50. 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
+ }