@linxin666/dsh-client-ui-task-board 0.4.3 → 0.4.5

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 (128) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -18
  3. package/README.zh.md +56 -18
  4. package/lib/client.js +6242 -958
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +3475 -234
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -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 +23 -2
  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/preset-label.d.ts +22 -0
  24. package/lib/types/client/board/status-key.d.ts +15 -0
  25. package/lib/types/client/board/task-markdown.d.ts +19 -0
  26. package/lib/types/client/board-card-seat.d.ts +34 -0
  27. package/lib/types/client/host-api.d.ts +24 -1
  28. package/lib/types/client/index.d.ts +19 -0
  29. package/lib/types/client/locales.d.ts +141 -3
  30. package/lib/types/client/native-panel.d.ts +6 -20
  31. package/lib/types/client/schedule-zone.d.ts +47 -0
  32. package/lib/types/client/seats.d.ts +18 -0
  33. package/lib/types/client/service.d.ts +32 -0
  34. package/lib/types/client/settings-form.d.ts +16 -0
  35. package/lib/types/core/controller.d.ts +38 -1
  36. package/lib/types/core/degraded-reason.d.ts +46 -0
  37. package/lib/types/core/extension.d.ts +283 -0
  38. package/lib/types/core/handover.d.ts +13 -1
  39. package/lib/types/core/issue-form.d.ts +16 -0
  40. package/lib/types/core/package-name.d.ts +9 -0
  41. package/lib/types/core/schedule.d.ts +95 -20
  42. package/lib/types/core/subtask.d.ts +30 -11
  43. package/lib/types/core/tasks.d.ts +110 -5
  44. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  45. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  46. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  47. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  48. package/lib/types/core/verification.d.ts +386 -0
  49. package/lib/types/core/workspace-target.d.ts +41 -0
  50. package/lib/types/host/agent-tools.d.ts +1 -1
  51. package/lib/types/host/extension-registry.d.ts +111 -0
  52. package/lib/types/host/llm-dispatch.d.ts +63 -0
  53. package/lib/types/host/verification-gate.d.ts +71 -0
  54. package/lib/types/host/verification-runner.d.ts +108 -0
  55. package/lib/types/host/workspace-evidence.d.ts +53 -0
  56. package/lib/types/host-ledger.d.ts +107 -11
  57. package/lib/types/host-runner.d.ts +99 -4
  58. package/lib/types/host-service.d.ts +114 -2
  59. package/lib/types/index.d.ts +80 -26
  60. package/lib/types/protocol.d.ts +53 -3
  61. package/lib/types/tool-surface.d.ts +71 -0
  62. package/package.json +27 -26
  63. package/src/client/PluginSettingsCard.tsx +9 -0
  64. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  65. package/src/client/board/ClampedMarkdown.tsx +69 -0
  66. package/src/client/board/ConfirmDialog.tsx +7 -3
  67. package/src/client/board/EditTaskModal.tsx +5 -2
  68. package/src/client/board/LinkSubtaskModal.tsx +30 -15
  69. package/src/client/board/NewTaskModal.tsx +288 -151
  70. package/src/client/board/TagManagerModal.tsx +235 -0
  71. package/src/client/board/TaskBoard.tsx +259 -103
  72. package/src/client/board/TaskCard.tsx +238 -86
  73. package/src/client/board/TaskDetail.tsx +154 -59
  74. package/src/client/board/TaskForm.tsx +61 -6
  75. package/src/client/board/VerificationReport.tsx +203 -0
  76. package/src/client/board/card-view.ts +220 -0
  77. package/src/client/board/icons.tsx +89 -0
  78. package/src/client/board/overlay.tsx +189 -0
  79. package/src/client/board/preset-label.ts +44 -0
  80. package/src/client/board/status-key.ts +27 -0
  81. package/src/client/board/task-markdown.module.css +137 -0
  82. package/src/client/board/task-markdown.tsx +106 -0
  83. package/src/client/board-card-seat.ts +102 -0
  84. package/src/client/board-settings.module.css +66 -0
  85. package/src/client/board.module.css +602 -68
  86. package/src/client/host-api.ts +109 -6
  87. package/src/client/index.ts +48 -6
  88. package/src/client/locales.ts +290 -12
  89. package/src/client/native-panel.tsx +18 -2
  90. package/src/client/schedule-zone.ts +135 -0
  91. package/src/client/seats.tsx +58 -0
  92. package/src/client/service.ts +94 -0
  93. package/src/client/settings-card.module.css +31 -5
  94. package/src/client/settings-form.ts +27 -1
  95. package/src/core/controller.ts +101 -5
  96. package/src/core/degraded-reason.ts +81 -0
  97. package/src/core/extension.ts +376 -0
  98. package/src/core/freeze-snapshot.ts +2 -2
  99. package/src/core/handover.ts +16 -1
  100. package/src/core/issue-form.ts +19 -0
  101. package/src/core/package-name.ts +9 -0
  102. package/src/core/schedule.ts +211 -55
  103. package/src/core/store.ts +26 -1
  104. package/src/core/subtask.ts +77 -22
  105. package/src/core/tasks.ts +138 -10
  106. package/src/core/use-cases/task-create.ts +14 -2
  107. package/src/core/use-cases/task-parent.ts +3 -2
  108. package/src/core/use-cases/task-schedule.ts +17 -6
  109. package/src/core/use-cases/task-tag.ts +110 -0
  110. package/src/core/use-cases/task-update.ts +13 -4
  111. package/src/core/verification.ts +826 -0
  112. package/src/core/workspace-target.ts +59 -0
  113. package/src/host/agent-tools.ts +58 -24
  114. package/src/host/extension-registry.ts +509 -0
  115. package/src/host/llm-dispatch.ts +87 -0
  116. package/src/host/verification-gate.ts +331 -0
  117. package/src/host/verification-runner.ts +535 -0
  118. package/src/host/workspace-evidence.ts +176 -0
  119. package/src/host-ai.ts +15 -9
  120. package/src/host-ledger.ts +342 -51
  121. package/src/host-routes.ts +15 -2
  122. package/src/host-runner.ts +219 -20
  123. package/src/host-service.ts +346 -26
  124. package/src/index.ts +204 -16
  125. package/src/protocol.ts +122 -9
  126. package/src/tool-surface.ts +95 -0
  127. package/lib/types/host/run-guarded.d.ts +0 -39
  128. package/src/host/run-guarded.ts +0 -76
@@ -4,22 +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'
8
- import type { BoardController } from '../../core/controller.ts'
7
+ import { useCallback, useEffect, useState } from 'react'
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 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'
18
+ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
16
19
  import css from '../board.module.css'
17
20
  import { ConfirmDialog } from './ConfirmDialog.tsx'
18
21
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
22
+ import { IconClose, IconPlus, IconSession } from './icons.tsx'
19
23
  import { LinkSubtaskModal } from './LinkSubtaskModal.tsx'
20
24
  import { NewTaskModal } from './NewTaskModal.tsx'
25
+ import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
26
+ import { inheritPresetLabel, presetLabel } from './preset-label.ts'
21
27
  import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
22
28
  import { STATUS_KEY } from './status-key.ts'
29
+ import { VerificationReport } from './VerificationReport.tsx'
30
+ import { ClampedMarkdown } from './ClampedMarkdown.tsx'
23
31
 
24
32
  /** Execution outcome → locale key. */
25
33
  const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> = {
@@ -45,6 +53,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
45
53
  {t('detail.execution.initiator', { session: execution.initiatedBy })}
46
54
  </span>
47
55
  )}
56
+ {execution.verification !== undefined && <VerificationReport verification={execution.verification} />}
48
57
  {execution.sessionId !== undefined && (
49
58
  <button
50
59
  type="button"
@@ -52,7 +61,8 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
52
61
  onClick={() => { onOpen(execution.sessionId as string) }}
53
62
  title={execution.sessionId}
54
63
  >
55
- {t('detail.viewSession')} ⌁
64
+ {t('detail.viewSession')}
65
+ <IconSession size={13} />
56
66
  </button>
57
67
  )}
58
68
  {execution.error !== undefined && execution.error !== '' && (
@@ -63,20 +73,16 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
63
73
  }
64
74
 
65
75
  /** The execution-target editor: workspace / mode / permission pickers. */
66
- function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
67
- const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
68
- // Whether this deployment serves the Agent Teams service: without it a team
69
- // run is refused, so the opt-in is offered read-only with the reason.
70
- const teamRunOffered = (): boolean => controller.getSnapshot().host?.teamRunAvailable === true
71
- const [teamRunAvailable, setTeamRunAvailable] = useState(teamRunOffered())
72
- useEffect(
73
- () => controller.subscribe(() => {
74
- const snapshot = controller.getSnapshot()
75
- setOptions(snapshot.executionOptions)
76
- setTeamRunAvailable(snapshot.host?.teamRunAvailable === true)
77
- }),
78
- [controller],
79
- )
76
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
77
+ controller: BoardController
78
+ task: TaskRecord
79
+ pending: boolean
80
+ /** Picker option sets, owned by the detail overlay's snapshot. */
81
+ executionOptions: ControllerSnapshot['executionOptions']
82
+ /** Whether this deployment serves the Agent Teams service. */
83
+ teamRunAvailable: boolean
84
+ }) {
85
+ const options = executionOptions
80
86
  const workspaceId = task.workspaceId ?? ''
81
87
  const mode = task.mode ?? ''
82
88
  const permission = task.permission ?? ''
@@ -107,18 +113,18 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
107
113
  </select>
108
114
  </label>
109
115
  <label className={css.field}>
110
- <span className={css.fieldLabel}>{t('new.mode')}</span>
116
+ <span className={css.fieldLabel}>{t('new.agentPreset')}</span>
111
117
  <select
112
118
  className={css.select}
113
119
  value={mode}
114
120
  disabled={pending}
115
121
  onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
116
122
  >
117
- <option value="">{t('exec.mode.default')}</option>
123
+ <option value="">{inheritPresetLabel(options.presets)}</option>
118
124
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
119
125
  {options.presets.map(preset => (
120
126
  <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
121
- {preset.name ?? preset.id}
127
+ {presetLabel(preset)}
122
128
  {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
123
129
  {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
124
130
  </option>
@@ -164,6 +170,26 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
164
170
  <span>{t('exec.reuseSession')}</span>
165
171
  </label>
166
172
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
173
+ <label className={css.scheduleToggle}>
174
+ <input
175
+ type="checkbox"
176
+ checked={task.goalRun !== false}
177
+ disabled={pending}
178
+ onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
179
+ />
180
+ <span>{t('exec.goalRun')}</span>
181
+ </label>
182
+ <p className={css.detailText}>{t('exec.goalRunHint')}</p>
183
+ <label className={css.scheduleToggle}>
184
+ <input
185
+ type="checkbox"
186
+ checked={task.skipVerification === true}
187
+ disabled={pending}
188
+ onChange={event => { controller.updateTask(task.id, { skipVerification: event.target.checked }) }}
189
+ />
190
+ <span>{t('exec.skipVerification')}</span>
191
+ </label>
192
+ <p className={css.detailText}>{t('exec.skipVerificationHint')}</p>
167
193
  <label className={css.scheduleToggle}>
168
194
  <input
169
195
  type="checkbox"
@@ -178,7 +204,7 @@ function ExecutionSettingsSection({ controller, task, pending }: { controller: B
178
204
  )
179
205
  }
180
206
 
181
- /** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
207
+ /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
182
208
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
183
209
  const schedule = task.schedule
184
210
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
@@ -186,7 +212,12 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
186
212
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
187
213
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
188
214
  const [error, setError] = useState<string | undefined>(undefined)
189
- const timeZone = controller.getSnapshot().host?.scheduler.timeZone
215
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
216
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
217
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
218
+ // One clock reading per render pass, so the absolute and relative halves of
219
+ // the label always describe the same instant.
220
+ const now = Date.now()
190
221
 
191
222
  // Keep the editor in sync when the task record changes underneath (the
192
223
  // schedule rolls forward as runs trigger).
@@ -195,8 +226,9 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
195
226
  setEnabled(schedule?.enabled ?? false)
196
227
  setNextRunAt(schedule?.nextRunAt)
197
228
  setLastTriggeredAt(schedule?.lastTriggeredAt)
229
+ setZone(schedule?.timeZone ?? '')
198
230
  setError(undefined)
199
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.nextRunAt, schedule?.lastTriggeredAt])
231
+ }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
200
232
 
201
233
  /** Validate + persist the current cron text (Enter or blur). */
202
234
  const saveCron = (value: string): void => {
@@ -210,6 +242,16 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
210
242
  controller.setSchedule(task.id, { cron: trimmed })
211
243
  }
212
244
 
245
+ /**
246
+ * Change the rule's zone. `''` clears the stored zone, which is how a user
247
+ * returns a rule to following the Host zone.
248
+ */
249
+ const changeZone = (value: string): void => {
250
+ setZone(value)
251
+ setError(undefined)
252
+ controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
253
+ }
254
+
213
255
  /** Arm/disarm the schedule (arming first persists the edited cron). */
214
256
  const toggleEnabled = (next: boolean): void => {
215
257
  const trimmed = cron.trim()
@@ -232,12 +274,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
232
274
  controller.setSchedule(task.id, { cron: preset })
233
275
  }
234
276
 
277
+ // The zone the rule's wall clock is actually read in: its own when set,
278
+ // otherwise the Host zone. The preview must use the same one the Host will.
279
+ const effectiveZone = zone === '' ? hostTimeZone : zone
235
280
  const nextLabel = !enabled || nextRunAt === undefined
236
281
  ? t('detail.schedule.notScheduled')
237
- : nextRunAt <= Date.now()
282
+ : nextRunAt <= now
238
283
  ? t('detail.schedule.dueSoon')
239
- : formatHostTimestamp(nextRunAt, timeZone)
240
- const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
284
+ : nextRunLabel(nextRunAt, effectiveZone, formatHostTimestamp, now)
285
+ const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, effectiveZone)
286
+ // The rule's own stored zone rides the list even when this runtime's
287
+ // inventory does not carry it, so opening the editor cannot rewrite it.
288
+ const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
241
289
 
242
290
  return (
243
291
  <section className={css.detailSection}>
@@ -276,6 +324,23 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
276
324
  ))}
277
325
  </select>
278
326
  </div>
327
+ {/* A list is used instead of a free-text field so only zones the Host can
328
+ also resolve are offered; the Host zone entry clears the stored value. */}
329
+ <label className={css.scheduleZone}>
330
+ <span>{t('detail.schedule.timeZone')}</span>
331
+ <select
332
+ className={css.schedulePreset}
333
+ value={zone}
334
+ disabled={pending}
335
+ aria-label={t('detail.schedule.timeZone')}
336
+ title={t('detail.schedule.timeZoneHint')}
337
+ onChange={event => { changeZone(event.target.value) }}
338
+ >
339
+ {zones.map(choice => (
340
+ <option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
341
+ ))}
342
+ </select>
343
+ </label>
279
344
  {error !== undefined && <p className={css.formError}>{error}</p>}
280
345
  <p className={css.scheduleMeta}>
281
346
  {t('detail.schedule.nextRun')} {nextLabel}
@@ -290,19 +355,18 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
290
355
  * and the actions that grow or prune the tree. Every gate here mirrors the
291
356
  * Host lineage gate for affordance only; the Host re-checks the action.
292
357
  */
293
- function SubtaskSection({ controller, task, pending, archived }: {
358
+ function SubtaskSection({ controller, task, pending, archived, snapshot }: {
294
359
  controller: BoardController
295
360
  task: TaskRecord
296
361
  pending: boolean
297
362
  archived: boolean
363
+ /** Detail-overlay snapshot; the overlay already re-renders on every notify. */
364
+ snapshot: ControllerSnapshot
298
365
  }) {
299
- const [snapshot, setSnapshot] = useState(controller.getSnapshot())
300
- useEffect(
301
- () => controller.subscribe(() => setSnapshot(controller.getSnapshot())),
302
- [controller],
303
- )
304
366
  const [showAdd, setShowAdd] = useState(false)
305
367
  const [showLink, setShowLink] = useState(false)
368
+ const addPresence = usePresence(showAdd)
369
+ const linkPresence = usePresence(showLink)
306
370
  const tasks = snapshot.tasks
307
371
  const parent = task.parentId === undefined ? undefined : tasks.find(candidate => candidate.id === task.parentId)
308
372
  const children = directSubtasks(tasks, task.id)
@@ -344,8 +408,8 @@ function SubtaskSection({ controller, task, pending, archived }: {
344
408
  <button
345
409
  type="button"
346
410
  className={css.linkButton}
347
- disabled={pending || child.status === 'running'}
348
- title={child.status === 'running' ? t('detail.subtasks.runningLock') : undefined}
411
+ disabled={pending || hasOpenExecution(child)}
412
+ title={hasOpenExecution(child) ? t('detail.subtasks.runningLock') : undefined}
349
413
  onClick={() => { void controller.setParent(child.id, null) }}
350
414
  >
351
415
  {t('detail.subtasks.detach')}
@@ -363,7 +427,8 @@ function SubtaskSection({ controller, task, pending, archived }: {
363
427
  ? (
364
428
  <div className={css.subtaskAddRow}>
365
429
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowAdd(true) }}>
366
- + {t('detail.subtasks.add')}
430
+ <IconPlus size={14} />
431
+ {t('detail.subtasks.add')}
367
432
  </button>
368
433
  <button type="button" className={css.ghostButton} disabled={!editable} onClick={() => { setShowLink(true) }}>
369
434
  {t('detail.subtasks.link')}
@@ -372,18 +437,19 @@ function SubtaskSection({ controller, task, pending, archived }: {
372
437
  )
373
438
  : <p className={css.detailMeta}>{t('detail.subtasks.hostOnly')}</p>)
374
439
  : <p className={css.detailMeta}>{t('detail.subtasks.depthLimit', { depth: String(limit) })}</p>)}
375
- {showAdd && (
376
- <NewTaskModal controller={controller} parentTask={task} onClose={() => { setShowAdd(false) }} />
440
+ {addPresence.mounted && (
441
+ <NewTaskModal controller={controller} parentTask={task} phase={addPresence.phase} onClose={() => { setShowAdd(false) }} />
377
442
  )}
378
- {showLink && (
379
- <LinkSubtaskModal controller={controller} parent={task} onClose={() => { setShowLink(false) }} />
443
+ {linkPresence.mounted && (
444
+ <LinkSubtaskModal controller={controller} parent={task} phase={linkPresence.phase} onClose={() => { setShowLink(false) }} />
380
445
  )}
381
446
  </section>
382
447
  )
383
448
  }
384
449
 
450
+
385
451
  /** Task detail overlay. */
386
- export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
452
+ export function TaskDetail({ controller, task, phase = 'open' }: { controller: BoardController; task: TaskRecord; phase?: OverlayPhase }) {
387
453
  const [confirmDelete, setConfirmDelete] = useState(false)
388
454
  const [showEdit, setShowEdit] = useState(false)
389
455
  const [showEditTags, setShowEditTags] = useState(false)
@@ -399,18 +465,36 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
399
465
  setShowDuplicate(false)
400
466
  }, [task.id])
401
467
  const current = latest
468
+ const seats = useTaskBoardSeats()
469
+ const dispatch = useCallback(
470
+ (request: TaskBoardExtensionActionRequest): Promise<boolean> => controller.dispatchExtension(request),
471
+ [controller],
472
+ ) as TaskBoardExtensionDispatch
402
473
  const snapshot = controller.getSnapshot()
403
- const running = current.status === 'running'
474
+ // Busy means the runner is executing this card, which is an execution the
475
+ // detail view can see; the column alone never locks a card.
476
+ const busy = hasOpenExecution(current)
404
477
  const archived = current.archivedAt !== undefined
405
478
  const pending = snapshot.pendingTaskIds.includes(current.id)
406
479
  const transportError = snapshot.transportError
407
480
  const timeZone = snapshot.host?.scheduler.timeZone
408
481
  const permissionPending = requiresPermissionConfirmation(current, snapshot.host?.sessionDefaultPermission)
409
482
  const subtaskChildren = directSubtasks(snapshot.tasks, current.id)
483
+ const dialog = useDialog<HTMLDivElement>(() => { controller.closeTask() }, phase)
484
+ // One presence per nested overlay: the detail view sits under up to four of
485
+ // them, and each has to run its own exit leg instead of the surface being
486
+ // pulled out of the DOM the moment its flag flips.
487
+ const editOpen = showEdit && !archived && canEditTaskContent(current)
488
+ const tagsOpen = showEditTags && !archived && !busy
489
+ const duplicateOpen = showDuplicate && !archived
490
+ const confirmPresence = usePresence(confirmDelete)
491
+ const editPresence = usePresence(editOpen)
492
+ const tagsPresence = usePresence(tagsOpen)
493
+ const duplicatePresence = usePresence(duplicateOpen)
410
494
 
411
495
  return (
412
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
413
- <div className={css.detail} role="dialog" aria-label={t('detail.title')}>
496
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
497
+ <div ref={dialog.attach} className={css.detail} role="dialog" aria-modal="true" aria-label={t('detail.title')} tabIndex={-1}>
414
498
  <header className={css.detailHeader}>
415
499
  <h2 className={css.detailTitle}>{current.title}</h2>
416
500
  <span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
@@ -420,9 +504,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
420
504
  type="button"
421
505
  className={css.iconButton}
422
506
  aria-label={t('detail.close')}
507
+ title={t('detail.close')}
423
508
  onClick={() => { controller.closeTask() }}
424
509
  >
425
- ×
510
+ <IconClose size={16} />
426
511
  </button>
427
512
  </header>
428
513
 
@@ -437,10 +522,10 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
437
522
  )}
438
523
  <section className={css.detailSection}>
439
524
  <h4>{t('detail.description')}</h4>
440
- <p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
525
+ <ClampedMarkdown source={current.description !== '' ? current.description : '—'} />
441
526
  </section>
442
527
 
443
- <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} />
528
+ <SubtaskSection controller={controller} task={current} pending={pending} archived={archived} snapshot={snapshot} />
444
529
 
445
530
  {current.tags !== undefined && current.tags.length > 0 && (
446
531
  <section className={css.detailSection} data-dsh-part="tags">
@@ -484,7 +569,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
484
569
  <h4>{t('detail.handover')}</h4>
485
570
  <p className={css.detailText}>
486
571
  {t('new.workspace')}: {current.handover.workspaceId ?? t('exec.workspace.recent')}
487
- {' · '}{t('new.mode')}: {current.handover.mode ?? t('exec.mode.default')}
572
+ {' · '}{t('new.agentPreset')}: {current.handover.mode ?? t('exec.mode.inherit')}
488
573
  {' · '}{t('new.permission')}: {current.handover.permission === undefined ? t('exec.permission.default') : t(`exec.permission.${current.handover.permission}` as TaskBoardKey)}
489
574
  </p>
490
575
  <p className={css.detailText}><strong>{t('detail.handover.references')}</strong></p>
@@ -511,14 +596,22 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
511
596
  <p className={css.detailMeta}>{t('detail.permissionConfirmed', { time: formatHostTimestamp(current.permissionConfirmedAt, timeZone) })}</p>
512
597
  )}
513
598
 
599
+ {seats.detailSection({ task: current, dispatch })}
600
+
514
601
  <section className={css.detailSection}>
515
602
  <h4>{t('detail.prompt')}</h4>
516
- <pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
603
+ <ClampedMarkdown source={current.prompt !== '' ? current.prompt : current.title} />
517
604
  </section>
518
605
 
519
606
  {!archived && (
520
607
  <>
521
- <ExecutionSettingsSection controller={controller} task={current} pending={pending} />
608
+ <ExecutionSettingsSection
609
+ controller={controller}
610
+ task={current}
611
+ pending={pending}
612
+ executionOptions={snapshot.executionOptions}
613
+ teamRunAvailable={snapshot.host?.teamRunAvailable === true}
614
+ />
522
615
  <ScheduleSection controller={controller} task={current} pending={pending} />
523
616
  </>
524
617
  )}
@@ -550,7 +643,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
550
643
  key={status}
551
644
  type="button"
552
645
  className={css.ghostButton}
553
- disabled={current.status === status || running || pending}
646
+ disabled={pending || !canMoveTask(current, status)}
554
647
  onClick={() => { controller.moveTask(current.id, status) }}
555
648
  >
556
649
  {t(`status.move.${status}` as TaskBoardKey)}
@@ -573,7 +666,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
573
666
  {t('detail.edit')}
574
667
  </button>
575
668
  )}
576
- {!archived && !canEditTaskContent(current) && current.status !== 'running' && (
669
+ {!archived && !canEditTaskContent(current) && !busy && (
577
670
  <button
578
671
  type="button"
579
672
  className={css.ghostButton}
@@ -598,7 +691,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
598
691
  <button
599
692
  type="button"
600
693
  className={css.primaryButton}
601
- disabled={running || pending}
694
+ disabled={busy || pending}
602
695
  title={subtaskChildren.length > 0
603
696
  ? t('detail.subtasks.runHint', { count: String(subtaskChildren.length) })
604
697
  : undefined}
@@ -651,12 +744,13 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
651
744
  </footer>
652
745
  </div>
653
746
 
654
- {confirmDelete && (
747
+ {confirmPresence.mounted && (
655
748
  <ConfirmDialog
656
749
  title={t('delete.title')}
657
750
  message={t('delete.confirm', { name: current.title })}
658
751
  confirmLabel={t('delete.ok')}
659
752
  danger
753
+ phase={confirmPresence.phase}
660
754
  onCancel={() => { setConfirmDelete(false) }}
661
755
  onConfirm={() => {
662
756
  setConfirmDelete(false)
@@ -665,18 +759,19 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
665
759
  />
666
760
  )}
667
761
 
668
- {showEdit && !archived && canEditTaskContent(current) && (
669
- <EditTaskModal controller={controller} task={current} onClose={() => { setShowEdit(false) }} />
762
+ {editPresence.mounted && (
763
+ <EditTaskModal controller={controller} task={current} phase={editPresence.phase} onClose={() => { setShowEdit(false) }} />
670
764
  )}
671
765
 
672
- {showEditTags && !archived && current.status !== 'running' && (
673
- <EditTagsModal controller={controller} task={current} onClose={() => { setShowEditTags(false) }} />
766
+ {tagsPresence.mounted && (
767
+ <EditTagsModal controller={controller} task={current} phase={tagsPresence.phase} onClose={() => { setShowEditTags(false) }} />
674
768
  )}
675
769
 
676
- {showDuplicate && !archived && (
770
+ {duplicatePresence.mounted && (
677
771
  <NewTaskModal
678
772
  controller={controller}
679
773
  initialTask={current}
774
+ phase={duplicatePresence.phase}
680
775
  onClose={() => { setShowDuplicate(false) }}
681
776
  onDuplicateSuccess={async (sourceId) => {
682
777
  await controller.archiveTask(sourceId)
@@ -4,10 +4,12 @@
4
4
  * NewTaskModal and the EditTaskModal. State stays in the owning modal; these
5
5
  * are controlled components.
6
6
  */
7
- import type { ReactNode } from 'react'
7
+ import { useState, type ReactNode } from 'react'
8
8
  import { TAG_NAME_MAX_LENGTH, TAG_PROMPT_MAX_LENGTH, TASK_TAG_LIMIT, normalizeTags, type TaskTag } from '../../core/tasks.ts'
9
9
  import { t } from '../locales.ts'
10
10
  import css from '../board.module.css'
11
+ import { IconClose, IconPlus } from './icons.tsx'
12
+ import { useDialog, type OverlayPhase } from './overlay.tsx'
11
13
 
12
14
  /** DOM id shared by the tag-name inputs and their datalist (one board at a time). */
13
15
  const TAG_NAME_LIST_ID = 'dsh-task-board-tag-names'
@@ -22,6 +24,7 @@ export function ModalShell({
22
24
  onSubmit,
23
25
  onClose,
24
26
  secondaryAction,
27
+ phase = 'open',
25
28
  children,
26
29
  }: {
27
30
  ariaLabel: string
@@ -33,19 +36,25 @@ export function ModalShell({
33
36
  onClose: () => void
34
37
  /** Optional second action beside the primary submit (e.g. create and run). */
35
38
  secondaryAction?: { label: string; onSubmit: () => void }
39
+ /** Which leg of the enter/exit motion pair the surface is on. */
40
+ phase?: OverlayPhase
36
41
  children: ReactNode
37
42
  }) {
43
+ const dialog = useDialog<HTMLFormElement>(onClose, phase)
38
44
  return (
39
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
45
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
40
46
  <form
47
+ ref={dialog.attach}
41
48
  className={css.modal}
42
49
  role="dialog"
50
+ aria-modal="true"
43
51
  aria-label={ariaLabel}
52
+ tabIndex={-1}
44
53
  onSubmit={event => { event.preventDefault(); onSubmit() }}
45
54
  >
46
55
  <h2 className={css.modalTitle}>{title}</h2>
47
56
 
48
- {children}
57
+ <div className={css.modalBody}>{children}</div>
49
58
 
50
59
  {error !== undefined && <p className={css.formError}>{error}</p>}
51
60
 
@@ -72,6 +81,51 @@ export function ModalShell({
72
81
  )
73
82
  }
74
83
 
84
+ /**
85
+ * One collapsible region of a task form (create/edit dialog UX): the form
86
+ * groups its configuration into a few regions instead of stacking every field
87
+ * in one long column, so a normal-sized dialog needs no scrollbar. A collapsed
88
+ * region keeps a one-line summary of the values it holds, so nothing is hidden
89
+ * without a trace; a region holding a blocking error is forced open by its
90
+ * owner (forceOpen) rather than reporting the error out of sight.
91
+ */
92
+ export function CollapsibleSection({
93
+ title,
94
+ summary,
95
+ defaultOpen = false,
96
+ forceOpen = false,
97
+ children,
98
+ }: {
99
+ title: string
100
+ /** One-line value preview rendered only while the region is collapsed. */
101
+ summary?: string | undefined
102
+ /** Whether the region starts expanded (the owner's default layout). */
103
+ defaultOpen?: boolean
104
+ /** Open regardless of the stored state: an error inside must stay visible. */
105
+ forceOpen?: boolean
106
+ children: ReactNode
107
+ }) {
108
+ const [open, setOpen] = useState(defaultOpen)
109
+ const expanded = forceOpen || open
110
+ return (
111
+ <section className={css.formSection} data-dsh-part="form-section" data-open={expanded ? 'true' : undefined}>
112
+ <button
113
+ type="button"
114
+ className={css.formSectionHeader}
115
+ aria-expanded={expanded}
116
+ onClick={() => { setOpen(value => !value) }}
117
+ >
118
+ <span className={css.formSectionChevron} aria-hidden="true" />
119
+ <span className={css.formSectionTitle}>{title}</span>
120
+ {!expanded && summary !== undefined && summary !== '' && (
121
+ <span className={css.formSectionSummary}>{summary}</span>
122
+ )}
123
+ </button>
124
+ {expanded && <div className={css.formSectionBody}>{children}</div>}
125
+ </section>
126
+ )
127
+ }
128
+
75
129
  /** Title + description + prompt fields shared by the new and edit task forms. */
76
130
  export function TaskContentFields({
77
131
  title,
@@ -106,7 +160,7 @@ export function TaskContentFields({
106
160
  <span className={css.fieldLabel}>{t('new.description')}</span>
107
161
  <textarea
108
162
  className={css.input}
109
- rows={3}
163
+ rows={2}
110
164
  value={description}
111
165
  placeholder={t('new.descriptionPlaceholder')}
112
166
  onChange={event => onDescriptionChange(event.target.value)}
@@ -181,7 +235,7 @@ export function TaskTagFields({
181
235
  aria-label={t('new.tagRemove', { name: tag.name })}
182
236
  onClick={() => { onChange(tags.filter((_, position) => position !== index)) }}
183
237
  >
184
- ×
238
+ <IconClose size={14} />
185
239
  </button>
186
240
  </div>
187
241
  ))}
@@ -194,7 +248,8 @@ export function TaskTagFields({
194
248
  disabled={tags.length >= TASK_TAG_LIMIT}
195
249
  onClick={() => { onChange([...tags, { name: '' }]) }}
196
250
  >
197
- + {t('new.tagAdd')}
251
+ <IconPlus size={14} />
252
+ {t('new.tagAdd')}
198
253
  </button>
199
254
  </div>
200
255
  )