@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +83 -105
  3. package/README.zh.md +83 -105
  4. package/lib/client.js +902 -633
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2274 -5
  7. package/lib/types/client/PluginSettingsCard.d.ts +29 -1
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/host-api.d.ts +18 -0
  11. package/lib/types/client/locales.d.ts +10 -0
  12. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  13. package/lib/types/client/sidebar-entry.d.ts +8 -10
  14. package/lib/types/core/controller.d.ts +47 -15
  15. package/lib/types/core/schedule.d.ts +11 -2
  16. package/lib/types/core/tasks.d.ts +6 -6
  17. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  18. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  19. package/lib/types/dsh-home.d.ts +16 -0
  20. package/lib/types/host-ledger.d.ts +52 -0
  21. package/lib/types/host-routes.d.ts +26 -0
  22. package/lib/types/host-runner.d.ts +41 -0
  23. package/lib/types/host-service.d.ts +45 -0
  24. package/lib/types/index.d.ts +17 -9
  25. package/lib/types/loopback.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +78 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +229 -18
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +24 -7
  33. package/src/client/board/TaskCard.tsx +25 -8
  34. package/src/client/board/TaskDetail.tsx +83 -50
  35. package/src/client/board-mount.tsx +1 -0
  36. package/src/client/host-api.ts +104 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/client/settings-card.module.css +90 -1
  40. package/src/client/sidebar-entry-core.ts +208 -0
  41. package/src/client/sidebar-entry.ts +24 -144
  42. package/src/core/controller.ts +222 -24
  43. package/src/core/schedule.ts +77 -13
  44. package/src/core/store.ts +4 -8
  45. package/src/core/tasks.ts +6 -6
  46. package/src/core/use-cases/task-archive.ts +14 -5
  47. package/src/core/use-cases/task-schedule.ts +6 -5
  48. package/src/dsh-home.ts +36 -0
  49. package/src/host-ledger.ts +539 -0
  50. package/src/host-routes.ts +196 -0
  51. package/src/host-runner.ts +152 -0
  52. package/src/host-service.ts +226 -0
  53. package/src/index.ts +54 -11
  54. package/src/loopback.ts +63 -0
  55. package/src/power-inhibitor.ts +321 -0
  56. package/src/protocol.ts +220 -0
  57. package/lib/types/core/scheduler.d.ts +0 -70
  58. package/src/core/scheduler.ts +0 -161
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * New-task modal: title + description + the prompt that execution will send.
3
- * Creates through the controller (which persists immediately).
3
+ * Creates through the Host and closes only after the Host confirms it.
4
4
  */
5
5
  import { useEffect, useState } from 'react'
6
6
  import type { BoardController } from '../../core/controller.ts'
@@ -22,6 +22,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
22
22
  const [scheduleCron, setScheduleCron] = useState('')
23
23
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
24
24
  const [error, setError] = useState<string | undefined>(undefined)
25
+ const [pending, setPending] = useState(false)
25
26
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
26
27
 
27
28
  // The workspace list and preset roster arrive from the runtime after mount;
@@ -31,7 +32,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
31
32
  [controller],
32
33
  )
33
34
 
34
- const submit = (): void => {
35
+ const submit = async (): Promise<void> => {
35
36
  if (scheduleEnabled) {
36
37
  const cron = scheduleCron.trim()
37
38
  if (cron === '' || !isValidCron(cron)) {
@@ -39,7 +40,8 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
39
40
  return
40
41
  }
41
42
  }
42
- const task = controller.createTask({
43
+ setPending(true)
44
+ const task = await controller.createTaskConfirmed({
43
45
  title,
44
46
  description,
45
47
  prompt,
@@ -49,7 +51,8 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
49
51
  schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
50
52
  })
51
53
  if (task === undefined) {
52
- setError(t('new.required'))
54
+ setPending(false)
55
+ setError(controller.getSnapshot().transportError ?? t('new.required'))
53
56
  return
54
57
  }
55
58
  onClose()
@@ -66,7 +69,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
66
69
  className={css.modal}
67
70
  role="dialog"
68
71
  aria-label={t('board.new')}
69
- onSubmit={event => { event.preventDefault(); submit() }}
72
+ onSubmit={event => { event.preventDefault(); void submit() }}
70
73
  >
71
74
  <h2 className={css.modalTitle}>{t('board.new')}</h2>
72
75
 
@@ -205,7 +208,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
205
208
  <button type="button" className={css.ghostButton} onClick={onClose}>
206
209
  {t('new.cancel')}
207
210
  </button>
208
- <button type="submit" className={css.primaryButton}>
211
+ <button type="submit" className={css.primaryButton} disabled={pending}>
209
212
  {t('new.submit')}
210
213
  </button>
211
214
  </footer>
@@ -26,9 +26,9 @@ function matchesFilter(task: TaskRecord, filter: string): boolean {
26
26
  * re-renders only when its own task changes — not when a sibling card status,
27
27
  * the filter, or the selection moves.
28
28
  */
29
- const MemoTaskCard = memo(function MemoTaskCard({ task, onOpen }: { task: TaskRecord; onOpen: (id: string) => void }) {
29
+ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen }: { task: TaskRecord; pending: boolean; timeZone?: string; onOpen: (id: string) => void }) {
30
30
  const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen])
31
- return <TaskCard task={task} onClick={onClick} />
31
+ return <TaskCard task={task} pending={pending} timeZone={timeZone} onClick={onClick} />
32
32
  })
33
33
 
34
34
  /** Board component; subscribes to the controller snapshot. */
@@ -50,7 +50,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
50
50
  const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
51
51
 
52
52
  return (
53
- <div className={css.board} data-dsh-taskboard-board="">
53
+ <div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
54
54
  <header className={css.boardHeader}>
55
55
  <button
56
56
  type="button"
@@ -62,6 +62,14 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
62
62
  <span>{t('board.close')}</span>
63
63
  </button>
64
64
  <h2 className={css.boardTitle}>{t('board.title')}</h2>
65
+ {snapshot.host !== undefined && (
66
+ <span className={css.detailMeta}>
67
+ {t('board.hostMeta', {
68
+ revision: String(snapshot.host.revision),
69
+ timeZone: snapshot.host.scheduler.timeZone,
70
+ })}
71
+ </span>
72
+ )}
65
73
  <input
66
74
  className={css.search}
67
75
  type="search"
@@ -88,16 +96,25 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
88
96
  </button>
89
97
  </header>
90
98
 
99
+ {snapshot.transportError !== undefined && (
100
+ <div className={css.formError}>
101
+ {t('board.hostError', { error: snapshot.transportError })}{' '}
102
+ <button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
103
+ {t('board.retryHost')}
104
+ </button>
105
+ </div>
106
+ )}
107
+
91
108
  <div className={css.columns}>
92
109
  {archiveView ? (
93
- <section className={css.column} data-status="archived">
110
+ <section className={css.column} data-status="archived" data-dsh-part="column">
94
111
  <header className={css.columnHeader}>
95
112
  <h3 className={css.columnTitle}>{t('board.archive')}</h3>
96
113
  <span className={css.columnCount}>{visible.length}</span>
97
114
  </header>
98
115
  <div className={css.cards}>
99
116
  {visible.map(task => (
100
- <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
117
+ <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
101
118
  ))}
102
119
  {visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
103
120
  </div>
@@ -106,7 +123,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
106
123
  COLUMNS.map(column => {
107
124
  const tasks = visible.filter(task => task.status === column.status)
108
125
  return (
109
- <section key={column.status} className={css.column} data-status={column.status}>
126
+ <section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
110
127
  <header className={css.columnHeader}>
111
128
  <span className={css.statusDot} data-status={column.status} aria-hidden="true" />
112
129
  <h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
@@ -114,7 +131,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
114
131
  </header>
115
132
  <div className={css.cards}>
116
133
  {tasks.map(task => (
117
- <MemoTaskCard key={task.id} task={task} onOpen={openTask} />
134
+ <MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
118
135
  ))}
119
136
  {tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
120
137
  </div>
@@ -14,25 +14,41 @@ import { t } from '../locales.ts'
14
14
  import css from '../board.module.css'
15
15
 
16
16
  /** Compact relative/absolute time label. */
17
- export function formatTime(ms: number): string {
17
+ export function formatHostTimestamp(ms: number, timeZone?: string): string {
18
+ try {
19
+ return new Intl.DateTimeFormat(undefined, {
20
+ dateStyle: 'medium',
21
+ timeStyle: 'medium',
22
+ ...(timeZone === undefined ? {} : { timeZone }),
23
+ }).format(new Date(ms))
24
+ } catch {
25
+ return new Date(ms).toISOString()
26
+ }
27
+ }
28
+
29
+ export function formatTime(ms: number, timeZone?: string): string {
18
30
  const date = new Date(ms)
19
31
  const now = Date.now()
20
32
  const minutes = Math.floor((now - ms) / 60000)
21
33
  if (minutes < 1) return t('time.justNow')
22
34
  if (minutes < 60) return `${minutes}m`
23
35
  if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`
36
+ if (timeZone !== undefined) return formatHostTimestamp(ms, timeZone)
24
37
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
25
38
  }
26
39
 
27
40
  /** One card in a column. */
28
- function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => void }) {
41
+ function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
29
42
  const latest = task.executions[task.executions.length - 1]
30
43
  const runs = task.executions.length
44
+ const archived = task.archivedAt !== undefined
31
45
  return (
32
46
  <button
33
47
  type="button"
34
48
  className={css.card}
35
- data-status={task.status}
49
+ data-status={archived ? 'archived' : task.status}
50
+ data-dsh-part="card"
51
+ data-pending={pending || undefined}
36
52
  onClick={onClick}
37
53
  title={task.description !== '' ? task.description : task.title}
38
54
  >
@@ -40,27 +56,28 @@ function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => voi
40
56
  {task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
41
57
  <span className={css.cardMeta}>
42
58
  <span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
43
- {task.schedule?.enabled === true && (
59
+ {!archived && task.schedule?.enabled === true && (
44
60
  <span
45
61
  className={css.cardSchedule}
46
62
  title={task.schedule.nextRunAt !== undefined
47
- ? `${t('card.scheduled')} · ${new Date(task.schedule.nextRunAt).toLocaleString()}`
63
+ ? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
48
64
  : t('card.scheduled')}
49
65
  >
50
66
  {t('card.scheduled')}
51
67
  </span>
52
68
  )}
53
69
  {latest !== undefined && (
54
- <span className={css.cardRun} data-result={latest.result}>
70
+ <span className={css.cardRun} data-result={archived ? undefined : latest.result}>
55
71
  {runs} {t('board.runs')}
56
72
  </span>
57
73
  )}
58
74
  {latest?.sessionId !== undefined && (
59
75
  <span className={css.cardSession} title={latest.sessionId}>⌁</span>
60
76
  )}
61
- {task.status === 'running' && <span className={css.cardSpinner} aria-hidden="true" />}
77
+ {!archived && (task.status === 'running' || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
62
78
  </span>
63
- {latest !== undefined && executionLabel(latest) === 'running' && (
79
+ {!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
80
+ {!archived && latest !== undefined && executionLabel(latest) === 'running' && (
64
81
  <span className={css.cardRunningLabel}>{t('detail.result.running')}…</span>
65
82
  )}
66
83
  </button>
@@ -12,7 +12,7 @@ import { t, type TaskBoardKey } from '../locales.ts'
12
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
13
  import css from '../board.module.css'
14
14
  import { ConfirmDialog } from './ConfirmDialog.tsx'
15
- import { formatTime } from './TaskCard.tsx'
15
+ import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
16
16
  import { STATUS_KEY } from './status-key.ts'
17
17
 
18
18
  /** Execution outcome → locale key. */
@@ -23,7 +23,7 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
23
23
  }
24
24
 
25
25
  /** One execution-history row. */
26
- function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
26
+ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
27
27
  const result = execution.result
28
28
  return (
29
29
  <li className={css.executionRow} data-result={result}>
@@ -31,8 +31,8 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
31
31
  {result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
32
32
  </span>
33
33
  <span className={css.executionTimes}>
34
- {t('detail.executionStarted')} {formatTime(execution.startedAt)}
35
- {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt)}`}
34
+ {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
35
+ {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
36
36
  </span>
37
37
  {execution.sessionId !== undefined && (
38
38
  <button
@@ -52,7 +52,7 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
52
52
  }
53
53
 
54
54
  /** The execution-target editor: workspace / mode / permission pickers. */
55
- function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
55
+ function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
56
56
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
57
57
  useEffect(
58
58
  () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
@@ -75,6 +75,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
75
75
  <select
76
76
  className={css.select}
77
77
  value={workspaceId}
78
+ disabled={pending}
78
79
  onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
79
80
  >
80
81
  <option value="">{t('exec.workspace.recent')}</option>
@@ -89,6 +90,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
89
90
  <select
90
91
  className={css.select}
91
92
  value={mode}
93
+ disabled={pending}
92
94
  onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
93
95
  >
94
96
  <option value="">{t('exec.mode.default')}</option>
@@ -107,6 +109,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
107
109
  <select
108
110
  className={css.select}
109
111
  value={permission}
112
+ disabled={pending}
110
113
  onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
111
114
  >
112
115
  <option value="">{t('exec.permission.default')}</option>
@@ -120,13 +123,14 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
120
123
  }
121
124
 
122
125
  /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
123
- function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
126
+ function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
124
127
  const schedule = task.schedule
125
128
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
126
129
  const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
127
130
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
128
131
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
129
132
  const [error, setError] = useState<string | undefined>(undefined)
133
+ const timeZone = controller.getSnapshot().host?.scheduler.timeZone
130
134
 
131
135
  // Keep the editor in sync when the task record changes underneath (the
132
136
  // schedule rolls forward as runs trigger).
@@ -158,8 +162,11 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
158
162
  return
159
163
  }
160
164
  setError(undefined)
161
- if (next && trimmed !== schedule?.cron) controller.setSchedule(task.id, { cron: trimmed })
162
- if (controller.setSchedule(task.id, { enabled: next })) setEnabled(next)
165
+ const submitted = controller.setSchedule(task.id, {
166
+ enabled: next,
167
+ ...(next && trimmed !== schedule?.cron ? { cron: trimmed } : {}),
168
+ })
169
+ if (submitted && !controller.isHostBacked()) setEnabled(next)
163
170
  }
164
171
 
165
172
  const applyPreset = (preset: string): void => {
@@ -173,8 +180,8 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
173
180
  ? t('detail.schedule.notScheduled')
174
181
  : nextRunAt <= Date.now()
175
182
  ? t('detail.schedule.dueSoon')
176
- : new Date(nextRunAt).toLocaleString()
177
- const lastLabel = lastTriggeredAt === undefined ? '—' : new Date(lastTriggeredAt).toLocaleString()
183
+ : formatHostTimestamp(nextRunAt, timeZone)
184
+ const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
178
185
 
179
186
  return (
180
187
  <section className={css.detailSection}>
@@ -183,6 +190,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
183
190
  <input
184
191
  type="checkbox"
185
192
  checked={enabled}
193
+ disabled={pending}
186
194
  onChange={event => { toggleEnabled(event.target.checked) }}
187
195
  />
188
196
  <span>{t('detail.schedule.enable')}</span>
@@ -191,6 +199,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
191
199
  <input
192
200
  className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
193
201
  value={cron}
202
+ disabled={pending}
194
203
  placeholder="0 9 * * *"
195
204
  spellCheck={false}
196
205
  aria-label={t('detail.schedule.cron')}
@@ -201,6 +210,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
201
210
  <select
202
211
  className={css.schedulePreset}
203
212
  value=""
213
+ disabled={pending}
204
214
  aria-label={t('detail.schedule.presets')}
205
215
  onChange={event => { applyPreset(event.target.value) }}
206
216
  >
@@ -222,19 +232,26 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
222
232
  /** Task detail overlay. */
223
233
  export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
224
234
  const [confirmDelete, setConfirmDelete] = useState(false)
225
- const running = task.status === 'running'
226
235
 
227
236
  // Keep the overlay in sync if the task record changes underneath.
228
237
  const [latest, setLatest] = useState(task)
229
238
  useEffect(() => { setLatest(task) }, [task])
230
239
  const current = latest
240
+ const snapshot = controller.getSnapshot()
241
+ const running = current.status === 'running'
242
+ const archived = current.archivedAt !== undefined
243
+ const pending = snapshot.pendingTaskIds.includes(current.id)
244
+ const transportError = snapshot.transportError
245
+ const timeZone = snapshot.host?.scheduler.timeZone
231
246
 
232
247
  return (
233
248
  <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
234
249
  <div className={css.detail} role="dialog" aria-label={t('detail.title')}>
235
250
  <header className={css.detailHeader}>
236
251
  <h2 className={css.detailTitle}>{current.title}</h2>
237
- <span className={css.statusBadge} data-status={current.status}>{t(STATUS_KEY[current.status])}</span>
252
+ <span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
253
+ {archived ? t('board.archive') : t(STATUS_KEY[current.status])}
254
+ </span>
238
255
  <button
239
256
  type="button"
240
257
  className={css.iconButton}
@@ -246,6 +263,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
246
263
  </header>
247
264
 
248
265
  <div className={css.detailBody}>
266
+ {transportError !== undefined && (
267
+ <div className={css.formError}>
268
+ {t('board.hostError', { error: transportError })}{' '}
269
+ <button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
270
+ {t('board.retryHost')}
271
+ </button>
272
+ </div>
273
+ )}
249
274
  <section className={css.detailSection}>
250
275
  <h4>{t('detail.description')}</h4>
251
276
  <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
@@ -256,9 +281,12 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
256
281
  <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
257
282
  </section>
258
283
 
259
- <ExecutionSettingsSection controller={controller} task={current} />
260
-
261
- <ScheduleSection controller={controller} task={current} />
284
+ {!archived && (
285
+ <>
286
+ <ExecutionSettingsSection controller={controller} task={current} pending={pending} />
287
+ <ScheduleSection controller={controller} task={current} pending={pending} />
288
+ </>
289
+ )}
262
290
 
263
291
  <section className={css.detailSection}>
264
292
  <h4>{t('detail.execution')}</h4>
@@ -270,6 +298,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
270
298
  <ExecutionRow
271
299
  key={execution.id}
272
300
  execution={execution}
301
+ timeZone={timeZone}
273
302
  onOpen={sessionId => { controller.openSession(sessionId) }}
274
303
  />
275
304
  ))}
@@ -277,45 +306,49 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
277
306
  )}
278
307
  </section>
279
308
 
280
- <section className={css.detailSection}>
281
- <h4>{t('board.status')}</h4>
282
- <div className={css.moveRow}>
283
- {MANUAL_STATUSES.map(status => (
284
- <button
285
- key={status}
286
- type="button"
287
- className={css.ghostButton}
288
- disabled={current.status === status || running}
289
- onClick={() => { controller.moveTask(current.id, status) }}
290
- >
291
- {t(`status.move.${status}` as TaskBoardKey)}
292
- </button>
293
- ))}
294
- </div>
295
- </section>
309
+ {!archived && (
310
+ <section className={css.detailSection}>
311
+ <h4>{t('board.status')}</h4>
312
+ <div className={css.moveRow}>
313
+ {MANUAL_STATUSES.map(status => (
314
+ <button
315
+ key={status}
316
+ type="button"
317
+ className={css.ghostButton}
318
+ disabled={current.status === status || running || pending}
319
+ onClick={() => { controller.moveTask(current.id, status) }}
320
+ >
321
+ {t(`status.move.${status}` as TaskBoardKey)}
322
+ </button>
323
+ ))}
324
+ </div>
325
+ </section>
326
+ )}
296
327
  </div>
297
328
 
298
329
  <footer className={css.detailFooter}>
299
- <button
300
- type="button"
301
- className={css.primaryButton}
302
- disabled={running}
303
- onClick={() => {
304
- // Running kicks off a real agent session; close the detail so
305
- // the whole board stays visible while the task executes.
306
- controller.closeTask()
307
- void controller.rerunTask(current.id)
308
- }}
309
- >
310
- {current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
311
- </button>
312
- {current.archivedAt !== undefined ? (
330
+ {!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
331
+ {!archived && (
332
+ <button
333
+ type="button"
334
+ className={css.primaryButton}
335
+ disabled={running || pending}
336
+ onClick={() => {
337
+ void controller.rerunTask(current.id).then(() => {
338
+ if (controller.getSnapshot().transportError === undefined) controller.closeTask()
339
+ })
340
+ }}
341
+ >
342
+ {current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
343
+ </button>
344
+ )}
345
+ {archived ? (
313
346
  <button
314
347
  type="button"
315
348
  className={css.primaryButton}
349
+ disabled={pending}
316
350
  onClick={() => {
317
351
  controller.restoreTask(current.id)
318
- controller.closeTask()
319
352
  }}
320
353
  >
321
354
  {t('detail.restore')}
@@ -325,9 +358,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
325
358
  <button
326
359
  type="button"
327
360
  className={css.ghostButton}
361
+ disabled={pending}
328
362
  onClick={() => {
329
363
  controller.archiveTask(current.id)
330
- controller.closeTask()
331
364
  }}
332
365
  >
333
366
  {t('detail.archive')}
@@ -337,13 +370,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
337
370
  <button
338
371
  type="button"
339
372
  className={css.dangerButton}
373
+ disabled={pending}
340
374
  onClick={() => { setConfirmDelete(true) }}
341
375
  >
342
376
  {t('detail.delete')}
343
377
  </button>
344
378
  <span className={css.detailMeta}>
345
- {t('board.created')} {formatTime(current.createdAt)}
346
- {current.archivedAt !== undefined && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt) })}`}
379
+ {t('board.created')} {formatTime(current.createdAt, timeZone)}
380
+ {archived && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt!, timeZone) })}`}
347
381
  </span>
348
382
  </footer>
349
383
  </div>
@@ -358,7 +392,6 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
358
392
  onConfirm={() => {
359
393
  setConfirmDelete(false)
360
394
  controller.deleteTask(current.id)
361
- controller.closeTask()
362
395
  }}
363
396
  />
364
397
  )}
@@ -49,6 +49,7 @@ export function mountBoard(controller: BoardController): () => void {
49
49
  if (column === undefined) return
50
50
  container = document.createElement('div')
51
51
  container.dataset.dshTaskboardView = ''
52
+ container.dataset.dshPlugin = 'task-board'
52
53
  container.className = css.boardView
53
54
  column.appendChild(container)
54
55
  root = createRoot(container)
@@ -0,0 +1,104 @@
1
+ import type { TaskRecord } from '../core/tasks.ts'
2
+ import {
3
+ TASK_BOARD_API_PREFIX,
4
+ type TaskBoardAction,
5
+ type TaskBoardActionEnvelope,
6
+ type TaskBoardEventPayload,
7
+ type TaskBoardSnapshot,
8
+ } from '../protocol.ts'
9
+
10
+ const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
11
+ const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
12
+ const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
13
+ const REQUEST_TIMEOUT_MS = 15_000
14
+
15
+ function uuid(): string {
16
+ return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
17
+ }
18
+
19
+ async function readJson<T>(response: Response): Promise<T> {
20
+ const body = await response.json() as T & { error?: string }
21
+ if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
22
+ return body
23
+ }
24
+
25
+ export interface TaskBoardHostTransport {
26
+ bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
27
+ state(): Promise<TaskBoardSnapshot>
28
+ action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
29
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
30
+ }
31
+
32
+ export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
33
+ constructor(private readonly storage: Pick<Storage, 'getItem' | 'setItem'> | undefined = globalThis.localStorage) {}
34
+
35
+ async bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot> {
36
+ const initial = await this.state()
37
+ const ledgerId = initial.scheduler.ledgerId
38
+ if (legacy.length > 0 && ledgerId !== undefined && this.storage?.getItem(IMPORT_MARKER) !== ledgerId) {
39
+ let sourceId = this.storage?.getItem(SOURCE_KEY)
40
+ if (sourceId === null || sourceId === undefined || sourceId === '') {
41
+ sourceId = uuid()
42
+ this.storage?.setItem(SOURCE_KEY, sourceId)
43
+ }
44
+ let requestId = this.storage?.getItem(IMPORT_REQUEST_KEY)
45
+ if (requestId === null || requestId === undefined || requestId === '') {
46
+ requestId = uuid()
47
+ this.storage?.setItem(IMPORT_REQUEST_KEY, requestId)
48
+ }
49
+ const snapshot = await this.post(requestId, { kind: 'import', sourceId, tasks: [...legacy] })
50
+ this.storage?.setItem(IMPORT_MARKER, snapshot.scheduler.ledgerId ?? ledgerId)
51
+ return snapshot
52
+ }
53
+ return initial
54
+ }
55
+
56
+ async state(): Promise<TaskBoardSnapshot> {
57
+ return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
58
+ }
59
+
60
+ async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
61
+ return await this.post(uuid(), action)
62
+ }
63
+
64
+ private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
65
+ const envelope: TaskBoardActionEnvelope = { requestId, action }
66
+ return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json' },
69
+ body: JSON.stringify(envelope),
70
+ })
71
+ }
72
+
73
+ private async request(url: string, init: RequestInit): Promise<TaskBoardSnapshot> {
74
+ const controller = new AbortController()
75
+ const timeout = globalThis.setTimeout(() => { controller.abort() }, REQUEST_TIMEOUT_MS)
76
+ try {
77
+ return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
78
+ } catch (error) {
79
+ if (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
80
+ throw error
81
+ } finally {
82
+ globalThis.clearTimeout(timeout)
83
+ }
84
+ }
85
+
86
+ subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
87
+ const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
88
+ events.onmessage = (message: MessageEvent<string>): void => {
89
+ try {
90
+ const parsed = JSON.parse(message.data) as TaskBoardEventPayload
91
+ if (parsed === null || typeof parsed !== 'object' || typeof parsed.revision !== 'number') throw new Error('invalid event frame')
92
+ listener(parsed)
93
+ } catch {
94
+ listener()
95
+ }
96
+ }
97
+ const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
98
+ document.addEventListener('visibilitychange', onVisible)
99
+ return () => {
100
+ document.removeEventListener('visibilitychange', onVisible)
101
+ events.close()
102
+ }
103
+ }
104
+ }