@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.
- package/README.i18n.yaml +2 -2
- package/README.md +59 -20
- package/README.zh.md +59 -20
- package/lib/client.js +6208 -624
- package/lib/client.js.map +1 -1
- package/lib/index.js +4774 -493
- package/lib/types/client/PluginSettingsCard.d.ts +7 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
- package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
- package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
- package/lib/types/client/board/EditTaskModal.d.ts +5 -2
- package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
- package/lib/types/client/board/NewTaskModal.d.ts +4 -1
- package/lib/types/client/board/TagManagerModal.d.ts +26 -0
- package/lib/types/client/board/TaskBoard.d.ts +10 -1
- package/lib/types/client/board/TaskCard.d.ts +14 -1
- package/lib/types/client/board/TaskDetail.d.ts +3 -1
- package/lib/types/client/board/TaskForm.d.ts +4 -1
- package/lib/types/client/board/VerificationReport.d.ts +23 -0
- package/lib/types/client/board/card-view.d.ts +113 -0
- package/lib/types/client/board/icons.d.ts +32 -0
- package/lib/types/client/board/overlay.d.ts +69 -0
- package/lib/types/client/board/status-key.d.ts +15 -0
- package/lib/types/client/board/task-markdown.d.ts +19 -0
- package/lib/types/client/index.d.ts +23 -5
- package/lib/types/client/locales.d.ts +168 -1
- package/lib/types/client/native-panel.d.ts +6 -20
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/client/seats.d.ts +18 -0
- package/lib/types/client/service.d.ts +32 -0
- package/lib/types/client/settings-form.d.ts +16 -0
- package/lib/types/core/controller.d.ts +46 -11
- package/lib/types/core/extension.d.ts +283 -0
- package/lib/types/core/handover.d.ts +13 -1
- package/lib/types/core/issue-form.d.ts +16 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +228 -17
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-tag.d.ts +48 -0
- package/lib/types/core/use-cases/task-update.d.ts +5 -3
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +753 -0
- package/lib/types/core/workspace-target.d.ts +41 -0
- package/lib/types/host/agent-tools.d.ts +1 -1
- package/lib/types/host/extension-registry.d.ts +111 -0
- package/lib/types/host/llm-dispatch.d.ts +63 -0
- package/lib/types/host/verification-gate.d.ts +95 -0
- package/lib/types/host/verification-runner.d.ts +139 -0
- package/lib/types/host/workspace-evidence.d.ts +53 -0
- package/lib/types/host-ledger.d.ts +133 -14
- package/lib/types/host-runner.d.ts +55 -10
- package/lib/types/host-service.d.ts +205 -11
- package/lib/types/index.d.ts +134 -26
- package/lib/types/protocol.d.ts +92 -10
- package/lib/types/tool-surface.d.ts +71 -0
- package/package.json +28 -31
- package/src/client/PluginSettingsCard.tsx +9 -0
- package/src/client/TaskBoardSettingsCard.tsx +378 -66
- package/src/client/board/ClampedMarkdown.tsx +69 -0
- package/src/client/board/ConfirmDialog.tsx +7 -3
- package/src/client/board/EditTaskModal.tsx +5 -2
- package/src/client/board/LinkSubtaskModal.tsx +12 -7
- package/src/client/board/NewTaskModal.tsx +168 -21
- package/src/client/board/TagManagerModal.tsx +235 -0
- package/src/client/board/TaskBoard.tsx +259 -103
- package/src/client/board/TaskCard.tsx +214 -81
- package/src/client/board/TaskDetail.tsx +289 -43
- package/src/client/board/TaskForm.tsx +13 -3
- package/src/client/board/VerificationReport.tsx +322 -0
- package/src/client/board/card-view.ts +220 -0
- package/src/client/board/icons.tsx +89 -0
- package/src/client/board/overlay.tsx +189 -0
- package/src/client/board/status-key.ts +30 -0
- package/src/client/board/task-markdown.module.css +137 -0
- package/src/client/board/task-markdown.tsx +106 -0
- package/src/client/board-settings.module.css +66 -0
- package/src/client/board.module.css +488 -63
- package/src/client/index.ts +82 -51
- package/src/client/locales.ts +343 -6
- package/src/client/native-panel.tsx +18 -2
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/seats.tsx +58 -0
- package/src/client/service.ts +94 -0
- package/src/client/settings-card.module.css +31 -5
- package/src/client/settings-form.ts +27 -1
- package/src/core/controller.ts +105 -10
- package/src/core/extension.ts +376 -0
- package/src/core/freeze-snapshot.ts +2 -2
- package/src/core/handover.ts +16 -1
- package/src/core/issue-form.ts +19 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +80 -15
- package/src/core/tasks.ts +293 -45
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-parent.ts +3 -2
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-tag.ts +110 -0
- package/src/core/use-cases/task-update.ts +13 -7
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +1594 -0
- package/src/core/workspace-target.ts +59 -0
- package/src/host/agent-tools.ts +125 -38
- package/src/host/extension-registry.ts +509 -0
- package/src/host/llm-dispatch.ts +87 -0
- package/src/host/verification-gate.ts +487 -0
- package/src/host/verification-runner.ts +702 -0
- package/src/host/workspace-evidence.ts +176 -0
- package/src/host-ai.ts +15 -9
- package/src/host-ledger.ts +468 -71
- package/src/host-routes.ts +15 -2
- package/src/host-runner.ts +106 -24
- package/src/host-service.ts +546 -40
- package/src/index.ts +305 -21
- package/src/protocol.ts +226 -28
- package/src/tool-surface.ts +95 -0
- package/lib/invariant.js +0 -6
- package/lib/types/invariant.d.ts +0 -3
- 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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
392
|
-
title={child
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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}
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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={
|
|
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) &&
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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)
|