@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
|
@@ -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
|
|
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
|
-
//
|
|
43
|
-
// Host refuses the move, so it is not offered here.
|
|
44
|
-
|
|
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}
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const [goalRun, setGoalRun] = useState(initialTask?.goalRun ??
|
|
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(() =>
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 ? {
|
|
223
|
+
...(goalRun ? { goalRun: true } : {}),
|
|
224
|
+
...(skipVerification ? { skipVerification: true } : {}),
|
|
188
225
|
...(tagList.length > 0 ? { tags: tagList } : {}),
|
|
189
226
|
schedule: scheduleEnabled
|
|
190
|
-
?
|
|
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
|
|
217
|
-
//
|
|
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
|
|
221
|
-
?
|
|
222
|
-
:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
+
}
|