@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
@@ -10,9 +10,10 @@ import type { BoardController } from '../../core/controller.ts'
10
10
  import { collectKnownTags, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
12
  import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
+ import type { OverlayPhase } from './overlay.tsx'
13
14
 
14
15
  /** Edit-task form overlay. */
15
- export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
16
+ export function EditTaskModal({ controller, task, onClose, phase }: { controller: BoardController; task: TaskRecord; onClose: () => void; phase?: OverlayPhase }) {
16
17
  const [title, setTitle] = useState(task.title)
17
18
  const [description, setDescription] = useState(task.description)
18
19
  const [prompt, setPrompt] = useState(task.prompt)
@@ -55,6 +56,7 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
55
56
  submitLabel={t('edit.save')}
56
57
  onSubmit={() => { void submit() }}
57
58
  onClose={onClose}
59
+ phase={phase}
58
60
  >
59
61
  <TaskContentFields
60
62
  title={title}
@@ -71,7 +73,7 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
71
73
  }
72
74
 
73
75
  /** Edit-tags modal: edit labels only, shown for tasks after first execution. */
74
- export function EditTagsModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
76
+ export function EditTagsModal({ controller, task, onClose, phase }: { controller: BoardController; task: TaskRecord; onClose: () => void; phase?: OverlayPhase }) {
75
77
  const [tags, setTags] = useState<TaskTag[]>(task.tags ?? [])
76
78
  const [error, setError] = useState<string | undefined>(undefined)
77
79
  const [pending, setPending] = useState(false)
@@ -99,6 +101,7 @@ export function EditTagsModal({ controller, task, onClose }: { controller: Board
99
101
  submitLabel={t('edit.save')}
100
102
  onSubmit={() => { void submit() }}
101
103
  onClose={onClose}
104
+ phase={phase}
102
105
  >
103
106
  <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
104
107
  </ModalShell>
@@ -7,9 +7,10 @@
7
7
  import { useMemo, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
9
  import { DEFAULT_SUBTASK_DEPTH, buildLineageIndex, checkParentLink } from '../../core/subtask.ts'
10
- import type { TaskRecord } from '../../core/tasks.ts'
10
+ import { hasOpenExecution, type TaskRecord } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
12
  import css from '../board.module.css'
13
+ import { useDialog, type OverlayPhase } from './overlay.tsx'
13
14
  import { STATUS_KEY } from './status-key.ts'
14
15
 
15
16
  /** Link-subtask overlay props. */
@@ -18,10 +19,13 @@ export interface LinkSubtaskModalProps {
18
19
  /** The task the picked card becomes a subtask of. */
19
20
  parent: TaskRecord
20
21
  onClose: () => void
22
+ /** Which leg of the enter/exit motion pair the surface is on. */
23
+ phase?: OverlayPhase
21
24
  }
22
25
 
23
26
  /** Link-subtask overlay. */
24
- export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskModalProps) {
27
+ export function LinkSubtaskModal({ controller, parent, onClose, phase = 'open' }: LinkSubtaskModalProps) {
28
+ const dialog = useDialog<HTMLDivElement>(onClose, phase)
25
29
  const [pending, setPending] = useState(false)
26
30
  const [error, setError] = useState<string | undefined>(undefined)
27
31
  const snapshot = controller.getSnapshot()
@@ -39,9 +43,10 @@ export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskMod
39
43
  const lineage = buildLineageIndex(tasks)
40
44
  return tasks.filter(task =>
41
45
  task.archivedAt === undefined
42
- // A running task may already be a participant of another run group; the
43
- // Host refuses the move, so it is not offered here.
44
- && task.status !== 'running'
46
+ // An executing task may already be a participant of another run group;
47
+ // the Host refuses the move, so it is not offered here. A card parked in
48
+ // the running column by hand carries no execution and stays linkable.
49
+ && !hasOpenExecution(task)
45
50
  && task.parentId === undefined
46
51
  && task.id !== parent.id
47
52
  && checkParentLink(tasks, task.id, parent.id, limit, lineage).ok,
@@ -58,8 +63,8 @@ export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskMod
58
63
  setError(controller.getSnapshot().transportError ?? t('new.required'))
59
64
  }
60
65
  return (
61
- <div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
62
- <div className={css.modal} role="dialog" aria-label={t('link.subtask.title')}>
66
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
67
+ <div ref={dialog.attach} className={css.modal} role="dialog" aria-modal="true" aria-label={t('link.subtask.title')} tabIndex={-1}>
63
68
  <h2 className={css.modalTitle}>{t('link.subtask.title')}</h2>
64
69
  <p className={css.fieldHint}>{t('link.subtask.hint')}</p>
65
70
  {candidates.length === 0
@@ -7,11 +7,12 @@ import type { BoardController } from '../../core/controller.ts'
7
7
  import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
8
8
  import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
9
9
  import { effectiveTaskPermission } from '../../core/subtask.ts'
10
- import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
10
+ import { collectKnownTags, TASK_PERMISSIONS, type ScheduleMode, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts'
11
11
  import { t, type TaskBoardKey } from '../locales.ts'
12
12
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
13
- import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
13
+ import { defaultZonedInputValue, nextRunLabel, parseZonedInput, runCapOption, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
14
14
  import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
15
+ import type { OverlayPhase } from './overlay.tsx'
15
16
  import { formatHostTimestamp } from './TaskCard.tsx'
16
17
  import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
17
18
  import { inheritPresetLabel, isBuiltinPreset, presetLabel } from './preset-label.ts'
@@ -35,10 +36,12 @@ export interface NewTaskModalProps {
35
36
  * carries the parent link; unset targets inherit again at run time.
36
37
  */
37
38
  parentTask?: TaskRecord
39
+ /** Which leg of the enter/exit motion pair the surface is on. */
40
+ phase?: OverlayPhase
38
41
  }
39
42
 
40
43
  /** New-task form overlay. */
41
- export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess, parentTask }: NewTaskModalProps) {
44
+ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess, parentTask, phase }: NewTaskModalProps) {
42
45
  const isDuplicate = initialTask !== undefined
43
46
  const [title, setTitle] = useState(initialTask?.title ?? '')
44
47
  const [description, setDescription] = useState(initialTask?.description ?? '')
@@ -54,11 +57,20 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
54
57
  const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
55
58
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
56
59
  const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
57
- // Checked by default: a new task starts its runs with dsh's built-in /goal
58
- // unless the user opts out here. A duplicate keeps the original card's choice.
59
- const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? true)
60
+ // Unchecked by default: a new task runs one plain turn unless the user opts
61
+ // into dsh's built-in /goal here. A duplicate keeps the original card's choice.
62
+ const [goalRun, setGoalRun] = useState(initialTask?.goalRun ?? false)
63
+ const [skipVerification, setSkipVerification] = useState(initialTask?.skipVerification ?? false)
60
64
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
65
+ const [scheduleMode, setScheduleMode] = useState<ScheduleMode>(initialTask?.schedule?.mode ?? 'cron')
61
66
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
67
+ // A duplicate keeps the original card's plan; a fresh one-shot defaults to
68
+ // the next hour, so the field never opens on a past instant.
69
+ const [scheduleAt, setScheduleAt] = useState<string>(() => initialTask?.schedule?.mode === 'once' && initialTask.schedule.at !== undefined
70
+ ? zonedInputValue(initialTask.schedule.at, initialTask.schedule.timeZone)
71
+ : defaultZonedInputValue(Date.now(), initialTask?.schedule?.timeZone))
72
+ const [scheduleMaxRunsChoice, setScheduleMaxRunsChoice] = useState<string>(() => runCapOption(initialTask?.schedule?.maxRuns))
73
+ const [scheduleMaxRunsText, setScheduleMaxRunsText] = useState<string>(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
62
74
  // '' means "follow the Host zone" (store no zone), which is the default a
63
75
  // user gets unless they pick one explicitly.
64
76
  const [scheduleZone, setScheduleZone] = useState(initialTask?.schedule?.timeZone ?? '')
@@ -73,6 +85,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
73
85
  const [error, setError] = useState<string | undefined>(undefined)
74
86
  const [pending, setPending] = useState(false)
75
87
  const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
88
+ // Live GLOBAL native-/goal switch. The new-task form never rewrites a stored
89
+ // preference from it: the checkbox stays operable and is explained while the
90
+ // master switch is off, and the user's own choice is what a later re-enable
91
+ // restores.
92
+ const [goalRunEnabled, setGoalRunEnabled] = useState(controller.getSnapshot().host?.goalRunEnabled === true)
76
93
  // "Parse pasted text" (issue #1540) exists only when the deployment carries a
77
94
  // parse face; the section stays hidden otherwise.
78
95
  const [canParse] = useState(controller.getSnapshot().canParseTask === true)
@@ -95,7 +112,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
95
112
  // The workspace list and preset roster arrive from the runtime after mount;
96
113
  // follow them so the pickers never freeze on an empty snapshot.
97
114
  useEffect(
98
- () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
115
+ () => controller.subscribe(() => {
116
+ const snapshot = controller.getSnapshot()
117
+ setOptions(snapshot.executionOptions)
118
+ setGoalRunEnabled(snapshot.host?.goalRunEnabled === true)
119
+ }),
99
120
  [controller],
100
121
  )
101
122
 
@@ -141,10 +162,25 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
141
162
  */
142
163
  const submit = async (runAfterCreate: boolean): Promise<void> => {
143
164
  if (scheduleEnabled) {
144
- const cron = scheduleCron.trim()
145
- if (cron === '' || !isValidCron(cron)) {
146
- setScheduleError(t('detail.schedule.invalid'))
147
- return
165
+ if (scheduleMode === 'once') {
166
+ const at = parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)
167
+ if (at === undefined || at <= Date.now()) {
168
+ setScheduleError(t('detail.schedule.atInvalid'))
169
+ return
170
+ }
171
+ } else {
172
+ const cron = scheduleCron.trim()
173
+ if (cron === '' || !isValidCron(cron)) {
174
+ setScheduleError(t('detail.schedule.invalid'))
175
+ return
176
+ }
177
+ if (scheduleMaxRunsChoice === 'custom') {
178
+ const cap = Number(scheduleMaxRunsText.trim())
179
+ if (scheduleMaxRunsText.trim() === '' || !Number.isInteger(cap) || cap < 1) {
180
+ setScheduleError(t('detail.schedule.maxRunsInvalid'))
181
+ return
182
+ }
183
+ }
148
184
  }
149
185
  }
150
186
  // Optional continuation-card snapshot: parse the freeze block through the
@@ -184,10 +220,26 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
184
220
  permission: permission === '' ? undefined : permission as TaskPermission,
185
221
  model: model === '' ? undefined : model,
186
222
  ...(reuseSession ? { reuseSession: true } : {}),
187
- ...(goalRun ? {} : { goalRun: false }),
223
+ ...(goalRun ? { goalRun: true } : {}),
224
+ ...(skipVerification ? { skipVerification: true } : {}),
188
225
  ...(tagList.length > 0 ? { tags: tagList } : {}),
189
226
  schedule: scheduleEnabled
190
- ? { enabled: true, cron: scheduleCron.trim(), ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }) }
227
+ ? scheduleMode === 'once'
228
+ ? {
229
+ enabled: true,
230
+ mode: 'once' as const,
231
+ at: parseZonedInput(scheduleAt, scheduleZone === '' ? hostTimeZone : scheduleZone)!,
232
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
233
+ }
234
+ : {
235
+ enabled: true,
236
+ mode: 'cron' as const,
237
+ cron: scheduleCron.trim(),
238
+ ...(scheduleMaxRunsChoice === 'unlimited' ? {} : {
239
+ maxRuns: scheduleMaxRunsChoice === 'custom' ? Number(scheduleMaxRunsText.trim()) : Number(scheduleMaxRunsChoice),
240
+ }),
241
+ ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }),
242
+ }
191
243
  : undefined,
192
244
  })
193
245
  if (task === undefined) {
@@ -213,13 +265,18 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
213
265
  onClose()
214
266
  }
215
267
 
216
- // Next-run preview for a valid armed cron (creation-time only), computed in
217
- // the selected zone so it matches what the Host will arm.
268
+ // Next-run preview for the armed plan (creation-time only), computed in the
269
+ // selected zone so it matches what the Host will arm. A one-shot's next run
270
+ // IS its planned instant; a recurring rule resolves its next occurrence.
218
271
  const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
219
272
  const scheduleTimeZone = scheduleZone === '' ? hostTimeZone : scheduleZone
220
- const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
221
- ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
222
- : undefined
273
+ const scheduleNextRun = !scheduleEnabled
274
+ ? undefined
275
+ : scheduleMode === 'once'
276
+ ? parseZonedInput(scheduleAt, scheduleTimeZone)
277
+ : scheduleCron.trim() !== '' && isValidCron(scheduleCron)
278
+ ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
279
+ : undefined
223
280
 
224
281
  const modalTitle = parentTask !== undefined
225
282
  ? t('new.subtaskTitle')
@@ -257,7 +314,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
257
314
  const tagCount = cleanTags(tags).length
258
315
  const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
259
316
  const runSummary = [
260
- goalRun ? t('new.summary.multiRound') : t('new.summary.singleRound'),
317
+ // A card whose goal option is on but whose master switch is off will run one
318
+ // plain turn: the collapsed line says the truth rather than the stored value.
319
+ goalRun && goalRunEnabled ? t('new.summary.multiRound') : t('new.summary.singleRound'),
261
320
  ...(reuseSession ? [t('exec.reuseSession')] : []),
262
321
  ].join(' · ')
263
322
  const handoverSummary = freezeText.trim() !== '' || handoverText.trim() !== ''
@@ -265,7 +324,18 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
265
324
  : t('new.summary.none')
266
325
  const scheduleSummary = !scheduleEnabled
267
326
  ? t('new.summary.scheduleOff')
268
- : scheduleCron.trim() === '' ? t('new.summary.none') : scheduleCron.trim()
327
+ : scheduleMode === 'once'
328
+ ? `${t('detail.schedule.mode.once')} · ${scheduleAt.replace('T', ' ')}`
329
+ : scheduleCron.trim() === ''
330
+ ? t('new.summary.none')
331
+ : [
332
+ scheduleCron.trim(),
333
+ ...(scheduleMaxRunsChoice === 'unlimited'
334
+ ? []
335
+ : [t('detail.schedule.maxRunsValue', {
336
+ count: scheduleMaxRunsChoice === 'custom' ? scheduleMaxRunsText : scheduleMaxRunsChoice,
337
+ })]),
338
+ ].join(' · ')
269
339
  const parseSummary = parseText.trim() === '' ? t('new.summary.none') : t('new.summary.filled')
270
340
 
271
341
  return (
@@ -277,6 +347,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
277
347
  submitLabel={t('new.submit')}
278
348
  onSubmit={() => { void submit(false) }}
279
349
  onClose={onClose}
350
+ phase={phase}
280
351
  secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
281
352
  >
282
353
  {canParse && (
@@ -461,11 +532,25 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
461
532
  <input
462
533
  type="checkbox"
463
534
  checked={goalRun}
535
+ // Always operable: the global switch decides whether the choice
536
+ // takes effect, never whether it may be stated, and the state (and a
537
+ // duplicate's template) is never rewritten by it.
464
538
  onChange={event => { setGoalRun(event.target.checked) }}
465
539
  />
466
540
  <span>{t('exec.goalRun')}</span>
467
541
  </label>
468
542
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
543
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
544
+
545
+ <label className={css.scheduleToggle}>
546
+ <input
547
+ type="checkbox"
548
+ checked={skipVerification}
549
+ onChange={event => { setSkipVerification(event.target.checked) }}
550
+ />
551
+ <span>{t('exec.skipVerification')}</span>
552
+ </label>
553
+ <p className={css.detailText}>{t('exec.skipVerificationHint')}</p>
469
554
  </CollapsibleSection>
470
555
 
471
556
  <CollapsibleSection
@@ -517,6 +602,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
517
602
  </label>
518
603
  {scheduleEnabled && (
519
604
  <>
605
+ <label className={css.scheduleZone}>
606
+ <span>{t('detail.schedule.mode')}</span>
607
+ <select
608
+ className={css.schedulePreset}
609
+ value={scheduleMode}
610
+ aria-label={t('detail.schedule.mode')}
611
+ onChange={event => { setScheduleMode(event.target.value as ScheduleMode); setScheduleError(undefined) }}
612
+ >
613
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
614
+ <option value="once">{t('detail.schedule.mode.once')}</option>
615
+ </select>
616
+ </label>
617
+ {scheduleMode === 'once' ? (
618
+ <label className={css.scheduleZone}>
619
+ <span>{t('detail.schedule.at')}</span>
620
+ <input
621
+ className={css.input}
622
+ type="datetime-local"
623
+ value={scheduleAt}
624
+ aria-label={t('detail.schedule.at')}
625
+ title={t('detail.schedule.atHint')}
626
+ onChange={event => { setScheduleAt(event.target.value); setScheduleError(undefined) }}
627
+ />
628
+ </label>
629
+ ) : (
630
+ <>
520
631
  <div className={css.scheduleRow}>
521
632
  <input
522
633
  className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -542,6 +653,42 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
542
653
  ))}
543
654
  </select>
544
655
  </div>
656
+ <label className={css.scheduleZone}>
657
+ <span>{t('detail.schedule.maxRuns')}</span>
658
+ <select
659
+ className={css.schedulePreset}
660
+ value={scheduleMaxRunsChoice}
661
+ aria-label={t('detail.schedule.maxRuns')}
662
+ onChange={event => {
663
+ setScheduleMaxRunsChoice(event.target.value)
664
+ setScheduleError(undefined)
665
+ if (event.target.value === 'custom') {
666
+ setScheduleMaxRunsText(initialTask?.schedule?.maxRuns === undefined ? '' : String(initialTask.schedule.maxRuns))
667
+ }
668
+ }}
669
+ >
670
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
671
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
672
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
673
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
674
+ </select>
675
+ </label>
676
+ {scheduleMaxRunsChoice === 'custom' && (
677
+ <label className={css.scheduleZone}>
678
+ <span>{t('detail.schedule.maxRuns')}</span>
679
+ <input
680
+ className={css.input}
681
+ type="number"
682
+ min={1}
683
+ step={1}
684
+ value={scheduleMaxRunsText}
685
+ aria-label={t('detail.schedule.maxRuns')}
686
+ onChange={event => { setScheduleMaxRunsText(event.target.value); setScheduleError(undefined) }}
687
+ />
688
+ </label>
689
+ )}
690
+ </>
691
+ )}
545
692
  {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
546
693
  <label className={css.scheduleZone}>
547
694
  <span>{t('detail.schedule.timeZone')}</span>
@@ -549,7 +696,7 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
549
696
  className={css.schedulePreset}
550
697
  value={scheduleZone}
551
698
  aria-label={t('detail.schedule.timeZone')}
552
- title={t('detail.schedule.timeZoneHint')}
699
+ title={scheduleMode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
553
700
  onChange={event => { setScheduleZone(event.target.value); setScheduleError(undefined) }}
554
701
  >
555
702
  {zoneChoices(hostTimeZone, initialTask?.schedule?.timeZone).map(choice => (
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Label manager: the board's label row can only shrink through here.
3
+ *
4
+ * The filter row is the union of every label in use across the ledger, so a
5
+ * label created once — by an experiment, a pasted task, an import — stayed on
6
+ * the board forever: there was no way to rename a typo, merge two labels that
7
+ * mean the same thing, or remove one nobody uses. This dialog is that way out.
8
+ * It lists the labels with their usage counts and offers the two ledger-wide
9
+ * transitions, both of which go through the controller so the Host ledger stays
10
+ * the authority (and a shared board stays consistent for everyone watching it).
11
+ *
12
+ * Renaming onto a name already in use merges the two labels; the dialog says so
13
+ * before the edit is submitted. Deleting is confirmed first, and the
14
+ * confirmation names how many tasks the label is removed from.
15
+ */
16
+ import { useMemo, useState } from 'react'
17
+ import type { BoardController } from '../../core/controller.ts'
18
+ import { TAG_NAME_MAX_LENGTH, collectKnownTags, type TaskRecord } from '../../core/tasks.ts'
19
+ import { t } from '../locales.ts'
20
+ import css from '../board.module.css'
21
+ import { ConfirmDialog } from './ConfirmDialog.tsx'
22
+ import { IconClose } from './icons.tsx'
23
+ import { useDialog, usePresence, type OverlayPhase } from './overlay.tsx'
24
+
25
+ /** One label as the manager lists it: its definition plus how many tasks carry it. */
26
+ export interface TagUsage {
27
+ name: string
28
+ promptPrefix?: string
29
+ /** Tasks carrying the label right now. */
30
+ count: number
31
+ }
32
+
33
+ /**
34
+ * Labels in use, in board order (first occurrence wins, exactly like the
35
+ * filter row), each with its usage count.
36
+ * @param tasks - the whole ledger (board and archive).
37
+ * @returns one row per label.
38
+ */
39
+ export function tagUsage(tasks: readonly TaskRecord[]): TagUsage[] {
40
+ const counts = new Map<string, number>()
41
+ for (const task of tasks) {
42
+ for (const tag of task.tags ?? []) counts.set(tag.name, (counts.get(tag.name) ?? 0) + 1)
43
+ }
44
+ return collectKnownTags(tasks).map(tag => ({
45
+ name: tag.name,
46
+ ...(tag.promptPrefix === undefined ? {} : { promptPrefix: tag.promptPrefix }),
47
+ count: counts.get(tag.name) ?? 0,
48
+ }))
49
+ }
50
+
51
+ /** Label-manager overlay props. */
52
+ export interface TagManagerModalProps {
53
+ controller: BoardController
54
+ onClose: () => void
55
+ /** Which leg of the enter/exit motion pair the surface is on. */
56
+ phase?: OverlayPhase
57
+ }
58
+
59
+ /** Label-manager overlay. */
60
+ export function TagManagerModal({ controller, onClose, phase = 'open' }: TagManagerModalProps) {
61
+ /** The label whose row is being renamed, and the draft name. */
62
+ const [editing, setEditing] = useState<string | undefined>(undefined)
63
+ const [draft, setDraft] = useState('')
64
+ const [busy, setBusy] = useState(false)
65
+ const [error, setError] = useState<string | undefined>(undefined)
66
+ const [confirmDelete, setConfirmDelete] = useState<string | undefined>(undefined)
67
+ const confirmPresence = usePresence(confirmDelete !== undefined)
68
+ // Escape and a backdrop press peel one layer at a time: an open row editor
69
+ // first, then the dialog — so a half-typed rename is never lost to the same
70
+ // key that closes the surface under it.
71
+ const dismiss = (): void => {
72
+ if (editing !== undefined) {
73
+ setEditing(undefined)
74
+ setError(undefined)
75
+ return
76
+ }
77
+ onClose()
78
+ }
79
+ const dialog = useDialog<HTMLDivElement>(dismiss, phase)
80
+ const snapshot = controller.getSnapshot()
81
+ const rows = useMemo(() => tagUsage(snapshot.tasks), [snapshot.tasks])
82
+ const deleting = rows.find(row => row.name === confirmDelete)
83
+ /** The label the typed name would merge into, when it is already in use. */
84
+ const mergesInto = editing === undefined ? undefined : rows.find(row => row.name === draft.trim() && row.name !== editing)
85
+
86
+ const startRename = (row: TagUsage): void => {
87
+ setEditing(row.name)
88
+ setDraft(row.name)
89
+ setError(undefined)
90
+ }
91
+
92
+ const submitRename = async (from: string): Promise<void> => {
93
+ const to = draft.trim()
94
+ if (to === '' || to === from) {
95
+ setEditing(undefined)
96
+ return
97
+ }
98
+ setBusy(true)
99
+ setError(undefined)
100
+ const accepted = await controller.renameTag(from, to)
101
+ setBusy(false)
102
+ if (accepted) {
103
+ setEditing(undefined)
104
+ return
105
+ }
106
+ setError(controller.getSnapshot().transportError ?? t('tags.renameFailed'))
107
+ }
108
+
109
+ const remove = async (name: string): Promise<void> => {
110
+ setBusy(true)
111
+ setError(undefined)
112
+ const accepted = await controller.deleteTag(name)
113
+ setBusy(false)
114
+ if (!accepted) setError(controller.getSnapshot().transportError ?? t('tags.deleteFailed'))
115
+ }
116
+
117
+ return (
118
+ <>
119
+ <div className={css.modalBackdrop} data-state={phase} onMouseDown={dialog.onMouseDown}>
120
+ <div
121
+ ref={dialog.attach}
122
+ className={css.modal}
123
+ role="dialog"
124
+ aria-modal="true"
125
+ aria-label={t('tags.title')}
126
+ tabIndex={-1}
127
+ >
128
+ <header className={css.modalHeader}>
129
+ <h2 className={css.modalTitle}>{t('tags.title')}</h2>
130
+ <button
131
+ type="button"
132
+ className={css.iconButton}
133
+ aria-label={t('detail.close')}
134
+ title={t('detail.close')}
135
+ onClick={onClose}
136
+ >
137
+ <IconClose size={16} />
138
+ </button>
139
+ </header>
140
+
141
+ <div className={css.modalBody}>
142
+ <p className={css.fieldHint}>{t('tags.hint')}</p>
143
+ {rows.length === 0
144
+ ? <p className={css.detailText}>{t('tags.empty')}</p>
145
+ : (
146
+ <ul className={css.tagManageList}>
147
+ {rows.map(row => (
148
+ <li key={row.name} className={css.tagManageRow}>
149
+ {editing === row.name
150
+ ? (
151
+ <>
152
+ <input
153
+ className={css.input + ' ' + css.tagManageInput}
154
+ value={draft}
155
+ maxLength={TAG_NAME_MAX_LENGTH}
156
+ spellCheck={false}
157
+ aria-label={t('tags.renameLabel', { name: row.name })}
158
+ onChange={event => { setDraft(event.target.value); setError(undefined) }}
159
+ onKeyDown={event => {
160
+ if (event.key === 'Enter') void submitRename(row.name)
161
+ }}
162
+ />
163
+ <button
164
+ type="button"
165
+ className={css.primaryButton}
166
+ disabled={busy || draft.trim() === ''}
167
+ onClick={() => { void submitRename(row.name) }}
168
+ >
169
+ {t('tags.save')}
170
+ </button>
171
+ <button
172
+ type="button"
173
+ className={css.ghostButton}
174
+ disabled={busy}
175
+ onClick={() => { setEditing(undefined); setError(undefined) }}
176
+ >
177
+ {t('new.cancel')}
178
+ </button>
179
+ </>
180
+ )
181
+ : (
182
+ <>
183
+ <span className={css.tagManageName}>{row.name}</span>
184
+ <span className={css.tagManageCount}>{t('tags.usage', { count: String(row.count) })}</span>
185
+ <button
186
+ type="button"
187
+ className={css.ghostButton}
188
+ disabled={busy}
189
+ onClick={() => { startRename(row) }}
190
+ >
191
+ {t('tags.rename')}
192
+ </button>
193
+ <button
194
+ type="button"
195
+ className={css.ghostButton}
196
+ disabled={busy}
197
+ onClick={() => { setConfirmDelete(row.name) }}
198
+ >
199
+ {t('tags.delete')}
200
+ </button>
201
+ </>
202
+ )}
203
+ {editing === row.name && mergesInto !== undefined && (
204
+ <span className={css.tagManageMerge}>{t('tags.mergeHint', { name: mergesInto.name })}</span>
205
+ )}
206
+ </li>
207
+ ))}
208
+ </ul>
209
+ )}
210
+ {error !== undefined && <p className={css.formError}>{error}</p>}
211
+ </div>
212
+
213
+ <footer className={css.modalFooter}>
214
+ <button type="button" className={css.ghostButton} onClick={onClose}>{t('tags.done')}</button>
215
+ </footer>
216
+ </div>
217
+ </div>
218
+
219
+ {confirmPresence.mounted && deleting !== undefined && (
220
+ <ConfirmDialog
221
+ title={t('tags.deleteTitle')}
222
+ message={t('tags.deleteConfirm', { name: deleting.name, count: String(deleting.count) })}
223
+ confirmLabel={t('tags.deleteOk')}
224
+ danger
225
+ phase={confirmPresence.phase}
226
+ onCancel={() => { setConfirmDelete(undefined) }}
227
+ onConfirm={() => {
228
+ setConfirmDelete(undefined)
229
+ void remove(deleting.name)
230
+ }}
231
+ />
232
+ )}
233
+ </>
234
+ )
235
+ }