@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -4,24 +4,30 @@
4
4
  * delete (with confirmation), manual status moves, and a jump to the
5
5
  * execution's session transcript.
6
6
  */
7
- import { useEffect, useState } from 'react'
7
+ import { useCallback, useEffect, useState } from 'react'
8
8
  import type { BoardController, ControllerSnapshot } from '../../core/controller.ts'
9
+ import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
10
+ import { useTaskBoardSeats } from '../seats.tsx'
9
11
  import { isValidCron } from '../../core/schedule.ts'
10
- import { MANUAL_STATUSES, TASK_PERMISSIONS, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
12
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type ScheduleMode, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
11
13
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
12
14
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
13
15
  import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
14
16
  import { t, type TaskBoardKey } from '../locales.ts'
15
17
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
16
- import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
18
+ import { budgetLabel, defaultZonedInputValue, endedLabel, nextRunLabel, parseZonedInput, runCapOption, skippedLabel, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
17
19
  import css from '../board.module.css'
18
20
  import { ConfirmDialog } from './ConfirmDialog.tsx'
19
21
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
22
+ import { IconClose, IconPlus, IconSession } from './icons.tsx'
20
23
  import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
21
24
  import { NewTaskModal } from './NewTaskModal.tsx'
25
+ import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
22
26
  import { inheritPresetLabel, presetLabel } from './preset-label.ts'
23
27
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
24
28
  import { STATUS_KEY } from './status-key.ts'
29
+ import { VerificationReport } from './VerificationReport.tsx'
30
+ import { ClampedMarkdown } from './ClampedMarkdown.tsx'
25
31
 
26
32
  /** Execution outcome → locale key. */
27
33
  const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
@@ -33,8 +39,12 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
33
39
  /** One execution-history row. */
34
40
  function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
35
41
  const result = execution.result
42
+ // An external record is one the Host never observed running: no session, no
43
+ // spinner, and a named outside agent instead of the initiating session. The
44
+ // data attribute keeps the distinction machine-readable too.
45
+ const external = execution.external === true
36
46
  return (
37
- <li className={css.executionRow} data-result={result}>
47
+ <li className={css.executionRow} data-result={result} data-external={external ? 'true' : undefined}>
38
48
  <span className={css.executionBadge} data-result={result}>
39
49
  {result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
40
50
  </span>
@@ -42,11 +52,17 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
42
52
  {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
43
53
  {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
44
54
  </span>
45
- {execution.initiatedBy !== undefined && (
55
+ {external && execution.initiatedBy !== undefined && (
56
+ <span className={css.executionTimes} title={execution.initiatedBy}>
57
+ {t('detail.execution.external', { agent: execution.initiatedBy })}
58
+ </span>
59
+ )}
60
+ {!external && execution.initiatedBy !== undefined && (
46
61
  <span className={css.executionTimes} title={execution.initiatedBy}>
47
62
  {t('detail.execution.initiator', { session: execution.initiatedBy })}
48
63
  </span>
49
64
  )}
65
+ {execution.verification !== undefined && <VerificationReport verification={execution.verification} />}
50
66
  {execution.sessionId !== undefined && (
51
67
  <button
52
68
  type="button"
@@ -54,7 +70,8 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
54
70
  onClick={() => { onOpen(execution.sessionId as string) }}
55
71
  title={execution.sessionId}
56
72
  >
57
- {t('detail.viewSession')} ⌁
73
+ {t('detail.viewSession')}
74
+ <IconSession size={13} />
58
75
  </button>
59
76
  )}
60
77
  {execution.error !== undefined && execution.error !== '' && (
@@ -65,7 +82,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
65
82
  }
66
83
 
67
84
  /** The execution-target editor: workspace / mode / permission pickers. */
68
- function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
85
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable, goalRunEnabled }: {
69
86
  controller: BoardController
70
87
  task: TaskRecord
71
88
  pending: boolean
@@ -73,6 +90,12 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
73
90
  executionOptions: ControllerSnapshot['executionOptions']
74
91
  /** Whether this deployment serves the Agent Teams service. */
75
92
  teamRunAvailable: boolean
93
+ /**
94
+ * Live GLOBAL native-/goal switch. It only decides whether the card's own
95
+ * preference takes effect: the option stays operable while it is off, the
96
+ * stored value is never rewritten by it, and the explanation below says so.
97
+ */
98
+ goalRunEnabled: boolean
76
99
  }) {
77
100
  const options = executionOptions
78
101
  const workspaceId = task.workspaceId ?? ''
@@ -165,13 +188,27 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
165
188
  <label className={css.scheduleToggle}>
166
189
  <input
167
190
  type="checkbox"
168
- checked={task.goalRun !== false}
191
+ checked={task.goalRun === true}
192
+ // Always operable (only a pending execution locks it): the global
193
+ // switch decides whether the preference takes effect, never whether
194
+ // the user may state it, and it never rewrites what is stored here.
169
195
  disabled={pending}
170
196
  onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
171
197
  />
172
198
  <span>{t('exec.goalRun')}</span>
173
199
  </label>
174
200
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
201
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
202
+ <label className={css.scheduleToggle}>
203
+ <input
204
+ type="checkbox"
205
+ checked={task.skipVerification === true}
206
+ disabled={pending}
207
+ onChange={event => { controller.updateTask(task.id, { skipVerification: event.target.checked }) }}
208
+ />
209
+ <span>{t('exec.skipVerification')}</span>
210
+ </label>
211
+ <p className={css.detailText}>{t('exec.skipVerificationHint')}</p>
175
212
  <label className={css.scheduleToggle}>
176
213
  <input
177
214
  type="checkbox"
@@ -186,31 +223,49 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
186
223
  )
187
224
  }
188
225
 
189
- /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
226
+ /**
227
+ * The scheduled-runs editor. A one-shot has one planned instant; a recurring
228
+ * rule has a cron expression, an optional run cap, and a zone. The editor
229
+ * shows the rule's own state (next target, run counter, stop/skip record) and
230
+ * can cancel a future plan.
231
+ */
190
232
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
191
233
  const schedule = task.schedule
234
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
235
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
236
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
237
+ const [mode, setMode] = useState<ScheduleMode>(schedule?.mode ?? 'cron')
192
238
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
239
+ const [atText, setAtText] = useState<string>(() => schedule?.at === undefined
240
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
241
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
242
+ const [maxRunsChoice, setMaxRunsChoice] = useState<string>(() => runCapOption(schedule?.maxRuns))
243
+ const [maxRunsText, setMaxRunsText] = useState<string>(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
193
244
  const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
194
245
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
195
246
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
196
247
  const [error, setError] = useState<string | undefined>(undefined)
197
- const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
198
- // The rule's own zone; '' means "follow the Host zone" (no stored zone).
199
- const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
200
248
  // One clock reading per render pass, so the absolute and relative halves of
201
249
  // the label always describe the same instant.
202
250
  const now = Date.now()
203
251
 
204
252
  // Keep the editor in sync when the task record changes underneath (the
205
- // schedule rolls forward as runs trigger).
253
+ // schedule rolls forward as runs trigger, ends at its budget, or is edited
254
+ // elsewhere).
206
255
  useEffect(() => {
256
+ setMode(schedule?.mode ?? 'cron')
207
257
  setCron(schedule?.cron ?? '0 9 * * *')
258
+ setZone(schedule?.timeZone ?? '')
259
+ setAtText(schedule?.at === undefined
260
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
261
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
262
+ setMaxRunsChoice(runCapOption(schedule?.maxRuns))
263
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
208
264
  setEnabled(schedule?.enabled ?? false)
209
265
  setNextRunAt(schedule?.nextRunAt)
210
266
  setLastTriggeredAt(schedule?.lastTriggeredAt)
211
- setZone(schedule?.timeZone ?? '')
212
267
  setError(undefined)
213
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
268
+ }, [task.id, hostTimeZone, schedule?.mode, schedule?.enabled, schedule?.cron, schedule?.at, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt, schedule?.maxRuns])
214
269
 
215
270
  /** Validate + persist the current cron text (Enter or blur). */
216
271
  const saveCron = (value: string): void => {
@@ -221,21 +276,101 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
221
276
  return
222
277
  }
223
278
  setError(undefined)
224
- controller.setSchedule(task.id, { cron: trimmed })
279
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
280
+ }
281
+
282
+ /** Validate + persist the one-shot's planned instant. */
283
+ const saveAt = (value: string): void => {
284
+ setAtText(value)
285
+ const at = parseZonedInput(value, effectiveZone)
286
+ if (at === undefined || at <= now) {
287
+ setError(t('detail.schedule.atInvalid'))
288
+ return
289
+ }
290
+ setError(undefined)
291
+ controller.setSchedule(task.id, { mode: 'once', at })
292
+ }
293
+
294
+ /** Switch the kind of plan, persisting a usable target for the new kind. */
295
+ const changeMode = (next: ScheduleMode): void => {
296
+ if (next === 'once') {
297
+ const at = parseZonedInput(atText, effectiveZone)
298
+ if (at === undefined || at <= now) {
299
+ setError(t('detail.schedule.atInvalid'))
300
+ return
301
+ }
302
+ setMode('once')
303
+ setError(undefined)
304
+ controller.setSchedule(task.id, { mode: 'once', at })
305
+ return
306
+ }
307
+ const trimmed = cron.trim()
308
+ if (trimmed === '' || !isValidCron(trimmed)) {
309
+ setError(t('detail.schedule.invalid'))
310
+ return
311
+ }
312
+ setMode('cron')
313
+ setError(undefined)
314
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
225
315
  }
226
316
 
227
317
  /**
228
318
  * Change the rule's zone. `''` clears the stored zone, which is how a user
229
- * returns a rule to following the Host zone.
319
+ * returns a rule to following the Host zone. A one-shot's instant is
320
+ * absolute, so only its rendered wall clock follows the new zone; a cron
321
+ * rule's wall clock itself moves with the zone.
230
322
  */
231
323
  const changeZone = (value: string): void => {
232
324
  setZone(value)
233
325
  setError(undefined)
326
+ if (mode === 'once' && schedule?.at !== undefined) {
327
+ setAtText(zonedInputValue(schedule.at, value === '' ? hostTimeZone : value))
328
+ controller.setSchedule(task.id, { mode: 'once', timeZone: value === '' ? null : value })
329
+ return
330
+ }
234
331
  controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
235
332
  }
236
333
 
237
- /** Arm/disarm the schedule (arming first persists the edited cron). */
334
+ /** Change the recurring rule's run cap (unlimited, a preset, or a custom value). */
335
+ const applyMaxRuns = (choice: string): void => {
336
+ setMaxRunsChoice(choice)
337
+ setError(undefined)
338
+ if (choice === 'unlimited') {
339
+ controller.setSchedule(task.id, { maxRuns: null })
340
+ return
341
+ }
342
+ if (choice === 'custom') {
343
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
344
+ return
345
+ }
346
+ controller.setSchedule(task.id, { maxRuns: Number(choice) })
347
+ }
348
+
349
+ /** Persist a custom run cap; a non-positive or fractional value is refused. */
350
+ const saveCustomMaxRuns = (value: string): void => {
351
+ setMaxRunsText(value)
352
+ const parsed = Number(value.trim())
353
+ if (value.trim() === '' || !Number.isInteger(parsed) || parsed < 1) {
354
+ setError(t('detail.schedule.maxRunsInvalid'))
355
+ return
356
+ }
357
+ setError(undefined)
358
+ controller.setSchedule(task.id, { maxRuns: parsed })
359
+ }
360
+
361
+ /** Arm/disarm the schedule (arming first persists the edited plan). */
238
362
  const toggleEnabled = (next: boolean): void => {
363
+ if (next && mode === 'once') {
364
+ const at = parseZonedInput(atText, effectiveZone)
365
+ if (at === undefined || at <= now) {
366
+ setError(t('detail.schedule.atInvalid'))
367
+ return
368
+ }
369
+ setError(undefined)
370
+ const submitted = controller.setSchedule(task.id, { mode: 'once', at, enabled: true })
371
+ if (submitted && !controller.isHostBacked()) setEnabled(true)
372
+ return
373
+ }
239
374
  const trimmed = cron.trim()
240
375
  if (next && (trimmed === '' || !isValidCron(trimmed))) {
241
376
  setError(t('detail.schedule.invalid'))
@@ -249,11 +384,19 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
249
384
  if (submitted && !controller.isHostBacked()) setEnabled(next)
250
385
  }
251
386
 
387
+ /** Drop every future trigger without touching the card's execution history. */
388
+ const cancelSchedule = (): void => {
389
+ setError(undefined)
390
+ const submitted = controller.setSchedule(task.id, { enabled: false })
391
+ if (submitted && !controller.isHostBacked()) setEnabled(false)
392
+ }
393
+
252
394
  const applyPreset = (preset: string): void => {
253
395
  if (preset === '') return
254
396
  setCron(preset)
397
+ setMode('cron')
255
398
  setError(undefined)
256
- controller.setSchedule(task.id, { cron: preset })
399
+ controller.setSchedule(task.id, { mode: 'cron', cron: preset })
257
400
  }
258
401
 
259
402
  // The zone the rule's wall clock is actually read in: its own when set,
@@ -268,6 +411,11 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
268
411
  // The rule's own stored zone rides the list even when this runtime's
269
412
  // inventory does not carry it, so opening the editor cannot rewrite it.
270
413
  const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
414
+ // The ledger's own view of the budget and stop record: derived here rather
415
+ // than tracked in local state, so the display cannot drift from the Host.
416
+ const budget = schedule === undefined ? undefined : budgetLabel(schedule)
417
+ const ended = schedule === undefined ? undefined : endedLabel(schedule)
418
+ const skipped = schedule === undefined ? undefined : skippedLabel(schedule)
271
419
 
272
420
  return (
273
421
  <section className={css.detailSection}>
@@ -281,6 +429,35 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
281
429
  />
282
430
  <span>{t('detail.schedule.enable')}</span>
283
431
  </label>
432
+ <label className={css.scheduleZone}>
433
+ <span>{t('detail.schedule.mode')}</span>
434
+ <select
435
+ className={css.schedulePreset}
436
+ value={mode}
437
+ disabled={pending}
438
+ aria-label={t('detail.schedule.mode')}
439
+ onChange={event => { changeMode(event.target.value as ScheduleMode) }}
440
+ >
441
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
442
+ <option value="once">{t('detail.schedule.mode.once')}</option>
443
+ </select>
444
+ </label>
445
+ {mode === 'once' ? (
446
+ <label className={css.scheduleZone}>
447
+ <span>{t('detail.schedule.at')}</span>
448
+ <input
449
+ className={css.input}
450
+ type="datetime-local"
451
+ value={atText}
452
+ disabled={pending}
453
+ aria-label={t('detail.schedule.at')}
454
+ title={t('detail.schedule.atHint')}
455
+ onChange={event => { setAtText(event.target.value); setError(undefined) }}
456
+ onBlur={() => { saveAt(atText) }}
457
+ />
458
+ </label>
459
+ ) : (
460
+ <>
284
461
  <div className={css.scheduleRow}>
285
462
  <input
286
463
  className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -306,6 +483,39 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
306
483
  ))}
307
484
  </select>
308
485
  </div>
486
+ <label className={css.scheduleZone}>
487
+ <span>{t('detail.schedule.maxRuns')}</span>
488
+ <select
489
+ className={css.schedulePreset}
490
+ value={maxRunsChoice}
491
+ disabled={pending}
492
+ aria-label={t('detail.schedule.maxRuns')}
493
+ onChange={event => { applyMaxRuns(event.target.value) }}
494
+ >
495
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
496
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
497
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
498
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
499
+ </select>
500
+ </label>
501
+ {maxRunsChoice === 'custom' && (
502
+ <label className={css.scheduleZone}>
503
+ <span>{t('detail.schedule.maxRuns')}</span>
504
+ <input
505
+ className={css.input}
506
+ type="number"
507
+ min={1}
508
+ step={1}
509
+ value={maxRunsText}
510
+ disabled={pending}
511
+ aria-label={t('detail.schedule.maxRuns')}
512
+ onChange={event => { setMaxRunsText(event.target.value) }}
513
+ onBlur={() => { saveCustomMaxRuns(maxRunsText) }}
514
+ />
515
+ </label>
516
+ )}
517
+ </>
518
+ )}
309
519
  {/* A list is used instead of a free-text field so only zones the Host can
310
520
  also resolve are offered; the Host zone entry clears the stored value. */}
311
521
  <label className={css.scheduleZone}>
@@ -315,7 +525,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
315
525
  value={zone}
316
526
  disabled={pending}
317
527
  aria-label={t('detail.schedule.timeZone')}
318
- title={t('detail.schedule.timeZoneHint')}
528
+ title={mode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
319
529
  onChange={event => { changeZone(event.target.value) }}
320
530
  >
321
531
  {zones.map(choice => (
@@ -328,6 +538,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
328
538
  {t('detail.schedule.nextRun')} {nextLabel}
329
539
  {' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
330
540
  </p>
541
+ {budget !== undefined && <p className={css.scheduleMeta}>{budget}</p>}
542
+ {ended !== undefined && <p className={css.scheduleMeta}>{ended}</p>}
543
+ {ended === undefined && skipped !== undefined && <p className={css.scheduleMeta}>{skipped}</p>}
544
+ {enabled && (
545
+ <button type="button" className={css.ghostButton} disabled={pending} onClick={cancelSchedule}>
546
+ {t('detail.schedule.cancel')}
547
+ </button>
548
+ )}
331
549
  </section>
332
550
  )
333
551
  }
@@ -347,6 +565,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
347
565
  }) {
348
566
  const [showAdd, setShowAdd] = useState(false)
349
567
  const [showLink, setShowLink] = useState(false)
568
+ const addPresence = usePresence(showAdd)
569
+ const linkPresence = usePresence(showLink)
350
570
  const tasks = snapshot.tasks
351
571
  const parent = task.parentId === undefined ? undefined : tasks.find(candidate => candidate.id === task.parentId)
352
572
  const children = directSubtasks(tasks, task.id)
@@ -388,8 +608,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
388
608
  <button
389
609
  type="button"
390
610
  className={css.linkButton}
391
- disabled={pending || child.status === 'running'}
392
- title={child.status === 'running' ? t('detail.subtasks.runningLock') : undefined}
611
+ disabled={pending || hasOpenExecution(child)}
612
+ title={hasOpenExecution(child) ? t('detail.subtasks.runningLock') : undefined}
393
613
  onClick={() => { void controller.setParent(child.id, null) }}
394
614
  >
395
615
  {t('detail.subtasks.detach')}
@@ -407,7 +627,8 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
407
627
  ? (
408
628
  <div className={css.subtaskAddRow}>
409
629
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowAdd(true) }}>
410
- + {t('detail.subtasks.add')}
630
+ <IconPlus size={14} />
631
+ {t('detail.subtasks.add')}
411
632
  </button>
412
633
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowLink(true) }}>
413
634
  {t('detail.subtasks.link')}
@@ -416,18 +637,19 @@ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
416
637
  )
417
638
  : <p className={css.detailMeta}>{t('detail.subtasks.hostOnly')}</p>)
418
639
  : <p className={css.detailMeta}>{t('detail.subtasks.depthLimit', { depth: String(limit) })}</p>)}
419
- {showAdd && (
420
- <NewTaskModal controller={controller} parentTask={task} onClose={() => { setShowAdd(false) }} />
640
+ {addPresence.mounted && (
641
+ <NewTaskModal controller={controller} parentTask={task} phase={addPresence.phase} onClose={() => { setShowAdd(false) }} />
421
642
  )}
422
- {showLink && (
423
- <LinkSubtaskModal controller={controller} parent={task} onClose={() => { setShowLink(false) }} />
643
+ {linkPresence.mounted && (
644
+ <LinkSubtaskModal controller={controller} parent={task} phase={linkPresence.phase} onClose={() => { setShowLink(false) }} />
424
645
  )}
425
646
  </section>
426
647
  )
427
648
  }
428
649
 
650
+
429
651
  /** Task detail overlay. */
430
- export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
652
+ export function TaskDetail({ controller, task, phase = 'open' }: { controller: BoardController; task: TaskRecord; phase?: OverlayPhase }) {
431
653
  const [confirmDelete, setConfirmDelete] = useState(false)
432
654
  const [showEdit, setShowEdit] = useState(false)
433
655
  const [showEditTags, setShowEditTags] = useState(false)
@@ -443,18 +665,36 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
443
665
  setShowDuplicate(false)
444
666
  }, [task.id])
445
667
  const current = latest
668
+ const seats = useTaskBoardSeats()
669
+ const dispatch = useCallback(
670
+ (request: TaskBoardExtensionActionRequest): Promise<boolean> => controller.dispatchExtension(request),
671
+ [controller],
672
+ ) as TaskBoardExtensionDispatch
446
673
  const snapshot = controller.getSnapshot()
447
- const running = current.status === 'running'
674
+ // Busy means the runner is executing this card, which is an execution the
675
+ // detail view can see; the column alone never locks a card.
676
+ const busy = hasOpenExecution(current)
448
677
  const archived = current.archivedAt !== undefined
449
678
  const pending = snapshot.pendingTaskIds.includes(current.id)
450
679
  const transportError = snapshot.transportError
451
680
  const timeZone = snapshot.host?.scheduler.timeZone
452
681
  const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
453
682
  const subtaskChildren = directSubtasks(snapshot.tasks, current.id)
683
+ const dialog = useDialog<HTMLDivElement>(() => { controller.closeTask() }, phase)
684
+ // One presence per nested overlay: the detail view sits under up to four of
685
+ // them, and each has to run its own exit leg instead of the surface being
686
+ // pulled out of the DOM the moment its flag flips.
687
+ const editOpen = showEdit && !archived && canEditTaskContent(current)
688
+ const tagsOpen = showEditTags && !archived && !busy
689
+ const duplicateOpen = showDuplicate && !archived
690
+ const confirmPresence = usePresence(confirmDelete)
691
+ const editPresence = usePresence(editOpen)
692
+ const tagsPresence = usePresence(tagsOpen)
693
+ const duplicatePresence = usePresence(duplicateOpen)
454
694
 
455
695
  return (
456
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
457
- <div className={css.detail} role="dialog" aria-label={t('detail.title')}>
696
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
697
+ <div ref={dialog.attach} className={css.detail} role="dialog" aria-modal="true" aria-label={t('detail.title')} tabIndex={-1}>
458
698
  <header className={css.detailHeader}>
459
699
  <h2 className={css.detailTitle}>{current.title}</h2>
460
700
  <span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
@@ -464,9 +704,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
464
704
  type="button"
465
705
  className={css.iconButton}
466
706
  aria-label={t('detail.close')}
707
+ title={t('detail.close')}
467
708
  onClick={() => { controller.closeTask() }}
468
709
  >
469
- ×
710
+ <IconClose size={16} />
470
711
  </button>
471
712
  </header>
472
713
 
@@ -481,7 +722,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
481
722
  )}
482
723
  <section className={css.detailSection}>
483
724
  <h4>{t('detail.description')}</h4>
484
- <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
725
+ <ClampedMarkdown source={current.description !== '' ? current.description : '—'} />
485
726
  </section>
486
727
 
487
728
  <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
@@ -555,9 +796,11 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
555
796
  <p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
556
797
  )}
557
798
 
799
+ {seats.detailSection({ task: current, dispatch })}
800
+
558
801
  <section className={css.detailSection}>
559
802
  <h4>{t('detail.prompt')}</h4>
560
- <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
803
+ <ClampedMarkdown source={current.prompt !== '' ? current.prompt : current.title} />
561
804
  </section>
562
805
 
563
806
  {!archived && (
@@ -568,6 +811,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
568
811
  pending={pending}
569
812
  executionOptions={snapshot.executionOptions}
570
813
  teamRunAvailable={snapshot.host?.teamRunAvailable === true}
814
+ goalRunEnabled={snapshot.host?.goalRunEnabled === true}
571
815
  />
572
816
  <ScheduleSection controller={controller} task={current} pending={pending} />
573
817
  </>
@@ -600,7 +844,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
600
844
  key={status}
601
845
  type="button"
602
846
  className={css.ghostButton}
603
- disabled={current.status === status || running || pending}
847
+ disabled={pending || !canMoveTask(current, status)}
604
848
  onClick={() => { controller.moveTask(current.id, status) }}
605
849
  >
606
850
  {t(`status.move.${status}` as TaskBoardKey)}
@@ -623,7 +867,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
623
867
  {t('detail.edit')}
624
868
  </button>
625
869
  )}
626
- {!archived && !canEditTaskContent(current) && current.status !== 'running' && (
870
+ {!archived && !canEditTaskContent(current) && !busy && (
627
871
  <button
628
872
  type="button"
629
873
  className={css.ghostButton}
@@ -648,7 +892,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
648
892
  <button
649
893
  type="button"
650
894
  className={css.primaryButton}
651
- disabled={running || pending}
895
+ disabled={busy || pending}
652
896
  title={subtaskChildren.length > 0
653
897
  ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
654
898
  : undefined}
@@ -701,12 +945,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
701
945
  </footer>
702
946
  </div>
703
947
 
704
- {confirmDelete && (
948
+ {confirmPresence.mounted && (
705
949
  <ConfirmDialog
706
950
  title={t('delete.title')}
707
951
  message={t('delete.confirm', { name: current.title })}
708
952
  confirmLabel={t('delete.ok')}
709
953
  danger
954
+ phase={confirmPresence.phase}
710
955
  onCancel={() => { setConfirmDelete(false) }}
711
956
  onConfirm={() => {
712
957
  setConfirmDelete(false)
@@ -715,18 +960,19 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
715
960
  />
716
961
  )}
717
962
 
718
- {showEdit && !archived && canEditTaskContent(current) && (
719
- <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
963
+ {editPresence.mounted && (
964
+ <EditTaskModal controller={controller} task={current} phase={editPresence.phase} onClose={() => { setShowEdit(false) }} />
720
965
  )}
721
966
 
722
- {showEditTags && !archived && current.status !== 'running' && (
723
- <EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
967
+ {tagsPresence.mounted && (
968
+ <EditTagsModal controller={controller} task={current} phase={tagsPresence.phase} onClose={() => { setShowEditTags(false) }} />
724
969
  )}
725
970
 
726
- {showDuplicate && !archived && (
971
+ {duplicatePresence.mounted && (
727
972
  <NewTaskModal
728
973
  controller={controller}
729
974
  initialTask={current}
975
+ phase={duplicatePresence.phase}
730
976
  onClose={() => { setShowDuplicate(false) }}
731
977
  onDuplicateSuccess={async (sourceId) => {
732
978
  await controller.archiveTask(sourceId)