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

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 (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-client-ui-task-board",
3
3
  "description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dsh": {
26
26
  "engines": {
27
- "dsh": ">=0.1.7-rc.2"
27
+ "dsh": ">=0.2.0-rc.1"
28
28
  },
29
29
  "bundle": {
30
30
  "patch": "./cordis.patch.yml"
@@ -34,6 +34,7 @@
34
34
  "@deepseek-ai/dsh-client-connection",
35
35
  "@deepseek-ai/dsh-client-ui-settings",
36
36
  "@deepseek-ai/dsh-client-ui-renderer",
37
+ "@deepseek-ai/dsh-client-ui-layout",
37
38
  "@deepseek-ai/dsh-api-session-controller",
38
39
  "@deepseek-ai/dsh-api-workspace-controller",
39
40
  "@deepseek-ai/dsh-api-remotes",
@@ -43,7 +44,7 @@
43
44
  }
44
45
  },
45
46
  "peerDependencies": {
46
- "@deepseek-ai/dsh": ">=0.1.7-rc.2",
47
+ "@deepseek-ai/dsh": ">=0.2.0-rc.1",
47
48
  "react": "^18.2.0"
48
49
  },
49
50
  "dependencies": {
@@ -51,26 +52,28 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "@deepseek-ai/cordis": "^4.0.4",
54
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
55
- "@deepseek-ai/dsh-agent-preset-registry": "^0.1.7-rc.2",
56
- "@deepseek-ai/dsh-api-gateway": "^0.1.7-rc.2",
57
- "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
58
- "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
59
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
60
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
61
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
62
- "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
63
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
64
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
65
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
66
- "@deepseek-ai/dsh-client-ui-workspace": "^0.1.7-rc.2",
67
- "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
68
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
69
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
70
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
71
- "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
72
- "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
73
- "@deepseek-ai/dsh-workspace": "^0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
56
+ "@deepseek-ai/dsh-agent-preset-registry": "^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-api-gateway": "^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-api-remotes": "^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.2.0-rc.1",
61
+ "@deepseek-ai/dsh-client-connection": "^0.2.0-rc.1",
62
+ "@deepseek-ai/dsh-client-locale": "^0.2.0-rc.1",
63
+ "@deepseek-ai/dsh-client-store": "^0.2.0-rc.1",
64
+ "@deepseek-ai/dsh-client-ui-layout": "^0.2.0-rc.1",
65
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.2.0-rc.1",
66
+ "@deepseek-ai/dsh-client-ui-settings": "^0.2.0-rc.1",
67
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.2.0-rc.1",
68
+ "@deepseek-ai/dsh-client-ui-slots": "^0.2.0-rc.1",
69
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.2.0-rc.1",
70
+ "@deepseek-ai/dsh-commands": "^0.2.0-rc.1",
71
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
72
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
73
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1",
74
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
75
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
76
+ "@deepseek-ai/dsh-workspace": "^0.2.0-rc.1",
74
77
  "@types/node": "^22.20.0",
75
78
  "@types/react": "~18.3.1",
76
79
  "@types/react-dom": "^18.3.5",
@@ -4,9 +4,9 @@
4
4
  * a link the Host would refuse is not offered in the first place; the Host
5
5
  * still re-checks the action at submit time.
6
6
  */
7
- import { useState } from 'react'
7
+ import { useMemo, useState } from 'react'
8
8
  import type { BoardController } from '../../core/controller.ts'
9
- import { DEFAULT_SUBTASK_DEPTH, checkParentLink } from '../../core/subtask.ts'
9
+ import { DEFAULT_SUBTASK_DEPTH, buildLineageIndex, checkParentLink } from '../../core/subtask.ts'
10
10
  import type { TaskRecord } from '../../core/tasks.ts'
11
11
  import { t } from '../locales.ts'
12
12
  import css from '../board.module.css'
@@ -28,15 +28,25 @@ export function LinkSubtaskModal({ controller, parent, onClose }: LinkSubtaskMod
28
28
  const limit = snapshot.host?.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH
29
29
  // Only root, on-board tasks can move under another parent; the lineage gate
30
30
  // decides the rest (depth, cycle, archived parent).
31
- const candidates = snapshot.tasks.filter(task =>
32
- task.archivedAt === undefined
33
- // A running task may already be a participant of another run group; the
34
- // Host refuses the move, so it is not offered here.
35
- && task.status !== 'running'
36
- && task.parentId === undefined
37
- && task.id !== parent.id
38
- && checkParentLink(snapshot.tasks, task.id, parent.id, limit).ok,
39
- )
31
+ //
32
+ // The gate is run once per candidate task, so it is given ONE lineage index
33
+ // for the whole pass: without it every call rebuilds the id/child maps and
34
+ // rescans the ledger, turning a render into quadratic work. The derivation
35
+ // also runs on every controller notification (the SSE heartbeat included),
36
+ // so it is memoized on the inputs it actually reads.
37
+ const tasks = snapshot.tasks
38
+ const candidates = useMemo(() => {
39
+ const lineage = buildLineageIndex(tasks)
40
+ return tasks.filter(task =>
41
+ 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'
45
+ && task.parentId === undefined
46
+ && task.id !== parent.id
47
+ && checkParentLink(tasks, task.id, parent.id, limit, lineage).ok,
48
+ )
49
+ }, [tasks, parent.id, limit])
40
50
  const link = async (taskId: string): Promise<void> => {
41
51
  setPending(true)
42
52
  setError(undefined)
@@ -10,8 +10,11 @@ import { effectiveTaskPermission } from '../../core/subtask.ts'
10
10
  import { collectKnownTags, TASK_PERMISSIONS, 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 { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
13
+ import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
14
+ import { CollapsibleSection, ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx'
15
+ import { formatHostTimestamp } from './TaskCard.tsx'
14
16
  import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts'
17
+ import { inheritPresetLabel, isBuiltinPreset, presetLabel } from './preset-label.ts'
15
18
  import css from '../board.module.css'
16
19
 
17
20
  export interface NewTaskModalProps {
@@ -51,8 +54,14 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
51
54
  const [model, setModel] = useState(initialTask?.model ?? parentTask?.model ?? '')
52
55
  const inheritedPermission = parentTask === undefined ? undefined : effectiveTaskPermission(parentTask)
53
56
  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)
54
60
  const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
55
61
  const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
62
+ // '' means "follow the Host zone" (store no zone), which is the default a
63
+ // user gets unless they pick one explicitly.
64
+ const [scheduleZone, setScheduleZone] = useState(initialTask?.schedule?.timeZone ?? '')
56
65
  const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
57
66
  const [freezeText, setFreezeText] = useState('')
58
67
  const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
@@ -175,8 +184,11 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
175
184
  permission: permission === '' ? undefined : permission as TaskPermission,
176
185
  model: model === '' ? undefined : model,
177
186
  ...(reuseSession ? { reuseSession: true } : {}),
187
+ ...(goalRun ? {} : { goalRun: false }),
178
188
  ...(tagList.length > 0 ? { tags: tagList } : {}),
179
- schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
189
+ schedule: scheduleEnabled
190
+ ? { enabled: true, cron: scheduleCron.trim(), ...(scheduleZone === '' ? {} : { timeZone: scheduleZone }) }
191
+ : undefined,
180
192
  })
181
193
  if (task === undefined) {
182
194
  setPending(false)
@@ -201,15 +213,61 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
201
213
  onClose()
202
214
  }
203
215
 
204
- /** Next-run preview for a valid armed cron (creation-time only). */
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.
218
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
219
+ const scheduleTimeZone = scheduleZone === '' ? hostTimeZone : scheduleZone
205
220
  const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron)
206
- ? nextRunAtMs(scheduleCron, Date.now())
221
+ ? nextRunAtMs(scheduleCron, Date.now(), scheduleTimeZone)
207
222
  : undefined
208
223
 
209
224
  const modalTitle = parentTask !== undefined
210
225
  ? t('new.subtaskTitle')
211
226
  : isDuplicate ? t('new.duplicateTitle') : t('board.new')
212
227
 
228
+ // Agent-preset picker rows: the four built-in presets first, then every other
229
+ // roster row (user presets). The inherit option names the preset a run
230
+ // actually lands on when the card pins nothing.
231
+ const builtinPresets = options.presets.filter(preset => isBuiltinPreset(preset.id))
232
+ const customPresets = options.presets.filter(preset => !isBuiltinPreset(preset.id))
233
+ const inheritLabel = inheritPresetLabel(options.presets)
234
+ const selectedPreset = options.presets.find(preset => preset.id === mode)
235
+ const modeLabel = mode === '' ? inheritLabel : selectedPreset === undefined ? mode : presetLabel(selectedPreset)
236
+ const workspaceLabel = workspaceId === ''
237
+ ? t('exec.workspace.recent')
238
+ : options.workspaces.find(item => item.workspaceId === workspaceId)?.title ?? workspaceId
239
+ const permissionLabel = permission === ''
240
+ ? inheritedPermission === undefined
241
+ ? t('exec.permission.default')
242
+ : t('exec.permission.inheritParent', { permission: t(`exec.permission.${inheritedPermission}` as TaskBoardKey) })
243
+ : t(`exec.permission.${permission}` as TaskBoardKey)
244
+ const modelLabel = model === ''
245
+ ? t('exec.model.default')
246
+ : (options.models ?? []).find(item => item.id === model)?.name ?? model
247
+ // Collapsed-region summaries: what each closed region currently holds, so a
248
+ // collapse never hides the configuration without a trace.
249
+ // A host-default model adds only the long parenthetical name to the
250
+ // collapsed line, so it is summarized only once it is actually pinned.
251
+ const executionSummary = [
252
+ workspaceLabel,
253
+ modeLabel,
254
+ permissionLabel,
255
+ ...(model === '' ? [] : [modelLabel]),
256
+ ].join(' · ')
257
+ const tagCount = cleanTags(tags).length
258
+ const labelsSummary = tagCount === 0 ? t('new.summary.none') : t('new.summary.labelCount', { count: String(tagCount) })
259
+ const runSummary = [
260
+ goalRun ? t('new.summary.multiRound') : t('new.summary.singleRound'),
261
+ ...(reuseSession ? [t('exec.reuseSession')] : []),
262
+ ].join(' · ')
263
+ const handoverSummary = freezeText.trim() !== '' || handoverText.trim() !== ''
264
+ ? t('new.summary.filled')
265
+ : t('new.summary.none')
266
+ const scheduleSummary = !scheduleEnabled
267
+ ? t('new.summary.scheduleOff')
268
+ : scheduleCron.trim() === '' ? t('new.summary.none') : scheduleCron.trim()
269
+ const parseSummary = parseText.trim() === '' ? t('new.summary.none') : t('new.summary.filled')
270
+
213
271
  return (
214
272
  <ModalShell
215
273
  ariaLabel={modalTitle}
@@ -222,51 +280,57 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
222
280
  secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }}
223
281
  >
224
282
  {canParse && (
225
- <section className={css.aiParse} data-dsh-part="ai-parse">
226
- <span className={css.fieldLabel}>{t('new.aiParse')}</span>
227
- <p className={css.fieldHint}>{t('new.aiParseHint')}</p>
228
- <textarea
229
- className={css.input}
230
- rows={3}
231
- value={parseText}
232
- placeholder={t('new.aiParsePlaceholder')}
233
- spellCheck={false}
234
- onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
235
- />
236
- <div className={css.aiParseRow}>
237
- <select
238
- className={css.select}
239
- value={parseModel}
240
- aria-label={t('new.aiParseModel')}
241
- onChange={event => {
242
- setParseModel(event.target.value)
243
- writeParseModelPreference(event.target.value)
244
- }}
245
- >
246
- <option value="">{t('exec.model.default')}</option>
247
- {parseModels.map(option => (
248
- <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
249
- ))}
250
- </select>
251
- {parsePending
252
- ? (
253
- <button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
254
- {t('new.aiParseCancel')}
255
- </button>
256
- )
257
- : (
258
- <button
259
- type="button"
260
- className={css.primaryButton}
261
- disabled={parseText.trim() === ''}
262
- onClick={() => { void runParse() }}
263
- >
264
- {t('new.aiParseRun')}
265
- </button>
266
- )}
267
- </div>
268
- {parseError !== undefined && <p className={css.formError}>{parseError}</p>}
269
- </section>
283
+ <CollapsibleSection
284
+ title={t('new.section.parse')}
285
+ summary={parseSummary}
286
+ forceOpen={parseError !== undefined}
287
+ >
288
+ <section className={css.aiParse} data-dsh-part="ai-parse">
289
+ <span className={css.fieldLabel}>{t('new.aiParse')}</span>
290
+ <p className={css.fieldHint}>{t('new.aiParseHint')}</p>
291
+ <textarea
292
+ className={css.input}
293
+ rows={3}
294
+ value={parseText}
295
+ placeholder={t('new.aiParsePlaceholder')}
296
+ spellCheck={false}
297
+ onChange={event => { setParseText(event.target.value); setParseError(undefined) }}
298
+ />
299
+ <div className={css.aiParseRow}>
300
+ <select
301
+ className={css.select}
302
+ value={parseModel}
303
+ aria-label={t('new.aiParseModel')}
304
+ onChange={event => {
305
+ setParseModel(event.target.value)
306
+ writeParseModelPreference(event.target.value)
307
+ }}
308
+ >
309
+ <option value="">{t('exec.model.default')}</option>
310
+ {parseModels.map(option => (
311
+ <option key={option.id} value={option.id}>{option.name ?? option.id}</option>
312
+ ))}
313
+ </select>
314
+ {parsePending
315
+ ? (
316
+ <button type="button" className={css.ghostButton} onClick={() => { parseAbort.current?.abort() }}>
317
+ {t('new.aiParseCancel')}
318
+ </button>
319
+ )
320
+ : (
321
+ <button
322
+ type="button"
323
+ className={css.primaryButton}
324
+ disabled={parseText.trim() === ''}
325
+ onClick={() => { void runParse() }}
326
+ >
327
+ {t('new.aiParseRun')}
328
+ </button>
329
+ )}
330
+ </div>
331
+ {parseError !== undefined && <p className={css.formError}>{parseError}</p>}
332
+ </section>
333
+ </CollapsibleSection>
270
334
  )}
271
335
 
272
336
  {parentTask !== undefined && (
@@ -275,42 +339,32 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
275
339
  </p>
276
340
  )}
277
341
 
278
- <TaskContentFields
279
- title={title}
280
- description={description}
281
- prompt={prompt}
282
- onTitleChange={value => { setTitle(value); setError(undefined) }}
283
- onDescriptionChange={setDescription}
284
- onPromptChange={setPrompt}
285
- />
286
-
287
- <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
288
-
289
- <label className={css.field}>
290
- <span className={css.fieldLabel}>{t('new.freeze')}</span>
291
- <textarea
292
- className={css.input}
293
- rows={4}
294
- value={freezeText}
295
- placeholder={t('new.freezePlaceholder')}
296
- spellCheck={false}
297
- onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
298
- />
299
- </label>
300
- {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
342
+ <CollapsibleSection title={t('new.section.content')} defaultOpen>
343
+ <TaskContentFields
344
+ title={title}
345
+ description={description}
346
+ prompt={prompt}
347
+ onTitleChange={value => { setTitle(value); setError(undefined) }}
348
+ onDescriptionChange={setDescription}
349
+ onPromptChange={setPrompt}
350
+ />
351
+ {isDuplicate && (
352
+ <label className={css.checkboxLabel}>
353
+ <input
354
+ type="checkbox"
355
+ checked={archiveOriginal}
356
+ onChange={event => { setArchiveOriginal(event.target.checked) }}
357
+ />
358
+ <span>{t('new.archiveOriginal')}</span>
359
+ </label>
360
+ )}
361
+ </CollapsibleSection>
301
362
 
302
- <label className={css.field}>
303
- <span className={css.fieldLabel}>{t('new.handover')}</span>
304
- <textarea
305
- className={css.input}
306
- rows={3}
307
- value={handoverText}
308
- placeholder={t('new.handoverPlaceholder')}
309
- spellCheck={false}
310
- onChange={event => { setHandoverText(event.target.value) }}
311
- />
312
- </label>
363
+ <CollapsibleSection title={t('new.section.labels')} summary={labelsSummary}>
364
+ <TaskTagFields tags={tags} knownTags={collectKnownTags(controller.getSnapshot().tasks)} onChange={setTags} />
365
+ </CollapsibleSection>
313
366
 
367
+ <CollapsibleSection title={t('new.section.execution')} summary={executionSummary}>
314
368
  <label className={css.field}>
315
369
  <span className={css.fieldLabel}>{t('new.workspace')}</span>
316
370
  <select
@@ -327,21 +381,36 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
327
381
  </label>
328
382
 
329
383
  <label className={css.field}>
330
- <span className={css.fieldLabel}>{t('new.mode')}</span>
384
+ <span className={css.fieldLabel}>{t('new.agentPreset')}</span>
331
385
  <select
332
386
  className={css.select}
333
387
  value={mode}
334
388
  onChange={event => { setMode(event.target.value) }}
335
389
  >
336
- <option value="">{t('exec.mode.default')}</option>
390
+ <option value="">{inheritLabel}</option>
337
391
  {!modeKnown && <option value={mode}>{mode}{t('exec.mode.removed')}</option>}
338
- {options.presets.map(preset => (
339
- <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
340
- {preset.name ?? preset.id}
341
- {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
342
- {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
343
- </option>
344
- ))}
392
+ {builtinPresets.length > 0 && (
393
+ <optgroup label={t('exec.mode.builtinGroup')}>
394
+ {builtinPresets.map(preset => (
395
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
396
+ {presetLabel(preset)}
397
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
398
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
399
+ </option>
400
+ ))}
401
+ </optgroup>
402
+ )}
403
+ {customPresets.length > 0 && (
404
+ <optgroup label={t('exec.mode.customGroup')}>
405
+ {customPresets.map(preset => (
406
+ <option key={preset.id} value={preset.id} disabled={preset.broken !== undefined}>
407
+ {presetLabel(preset)}
408
+ {preset.isDefault ? t('exec.mode.defaultSuffix') : ''}
409
+ {preset.broken !== undefined ? t('exec.mode.brokenSuffix') : ''}
410
+ </option>
411
+ ))}
412
+ </optgroup>
413
+ )}
345
414
  </select>
346
415
  </label>
347
416
 
@@ -375,7 +444,9 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
375
444
  ))}
376
445
  </select>
377
446
  </label>
447
+ </CollapsibleSection>
378
448
 
449
+ <CollapsibleSection title={t('new.section.run')} summary={runSummary}>
379
450
  <label className={css.scheduleToggle}>
380
451
  <input
381
452
  type="checkbox"
@@ -386,65 +457,115 @@ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspac
386
457
  </label>
387
458
  <p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
388
459
 
389
- <section className={css.detailSection}>
390
- <h4>{t('detail.schedule')}</h4>
391
- <label className={css.scheduleToggle}>
392
- <input
393
- type="checkbox"
394
- checked={scheduleEnabled}
395
- onChange={event => {
396
- setScheduleEnabled(event.target.checked)
397
- if (!event.target.checked) setScheduleError(undefined)
398
- }}
399
- />
400
- <span>{t('detail.schedule.enable')}</span>
401
- </label>
402
- {scheduleEnabled && (
403
- <>
404
- <div className={css.scheduleRow}>
405
- <input
406
- className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
407
- value={scheduleCron}
408
- placeholder="0 9 * * *"
409
- spellCheck={false}
410
- aria-label={t('detail.schedule.cron')}
411
- onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
412
- />
413
- <select
414
- className={css.schedulePreset}
415
- value=""
416
- aria-label={t('detail.schedule.presets')}
417
- onChange={event => {
418
- if (event.target.value === '') return
419
- setScheduleCron(event.target.value)
420
- setScheduleError(undefined)
421
- }}
422
- >
423
- <option value="">{t('detail.schedule.presets')}…</option>
424
- {SCHEDULE_PRESETS.map(preset => (
425
- <option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
426
- ))}
427
- </select>
428
- </div>
429
- {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
430
- {scheduleError === undefined && scheduleNextRun !== undefined && (
431
- <p className={css.scheduleMeta}>
432
- {t('detail.schedule.nextRun')} {new Date(scheduleNextRun).toLocaleString()}
433
- </p>
434
- )}
435
- </>
436
- )}
437
- </section>
438
- {isDuplicate && (
439
- <label className={css.checkboxLabel} style={{ marginTop: '12px' }}>
440
- <input
441
- type="checkbox"
442
- checked={archiveOriginal}
443
- onChange={event => { setArchiveOriginal(event.target.checked) }}
444
- />
445
- <span>{t('new.archiveOriginal')}</span>
446
- </label>
460
+ <label className={css.scheduleToggle}>
461
+ <input
462
+ type="checkbox"
463
+ checked={goalRun}
464
+ onChange={event => { setGoalRun(event.target.checked) }}
465
+ />
466
+ <span>{t('exec.goalRun')}</span>
467
+ </label>
468
+ <p className={css.detailText}>{t('exec.goalRunHint')}</p>
469
+ </CollapsibleSection>
470
+
471
+ <CollapsibleSection
472
+ title={t('new.section.handover')}
473
+ summary={handoverSummary}
474
+ forceOpen={freezeError !== undefined}
475
+ >
476
+ <label className={css.field}>
477
+ <span className={css.fieldLabel}>{t('new.freeze')}</span>
478
+ <textarea
479
+ className={css.input}
480
+ rows={4}
481
+ value={freezeText}
482
+ placeholder={t('new.freezePlaceholder')}
483
+ spellCheck={false}
484
+ onChange={event => { setFreezeText(event.target.value); setFreezeError(undefined) }}
485
+ />
486
+ </label>
487
+ {freezeError !== undefined && <p className={css.formError}>{freezeError}</p>}
488
+
489
+ <label className={css.field}>
490
+ <span className={css.fieldLabel}>{t('new.handover')}</span>
491
+ <textarea
492
+ className={css.input}
493
+ rows={3}
494
+ value={handoverText}
495
+ placeholder={t('new.handoverPlaceholder')}
496
+ spellCheck={false}
497
+ onChange={event => { setHandoverText(event.target.value) }}
498
+ />
499
+ </label>
500
+ </CollapsibleSection>
501
+
502
+ <CollapsibleSection
503
+ title={t('new.section.schedule')}
504
+ summary={scheduleSummary}
505
+ forceOpen={scheduleError !== undefined}
506
+ >
507
+ <label className={css.scheduleToggle}>
508
+ <input
509
+ type="checkbox"
510
+ checked={scheduleEnabled}
511
+ onChange={event => {
512
+ setScheduleEnabled(event.target.checked)
513
+ if (!event.target.checked) setScheduleError(undefined)
514
+ }}
515
+ />
516
+ <span>{t('detail.schedule.enable')}</span>
517
+ </label>
518
+ {scheduleEnabled && (
519
+ <>
520
+ <div className={css.scheduleRow}>
521
+ <input
522
+ className={`${css.input} ${css.scheduleInput}${scheduleError !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
523
+ value={scheduleCron}
524
+ placeholder="0 9 * * *"
525
+ spellCheck={false}
526
+ aria-label={t('detail.schedule.cron')}
527
+ onChange={event => { setScheduleCron(event.target.value); setScheduleError(undefined) }}
528
+ />
529
+ <select
530
+ className={css.schedulePreset}
531
+ value=""
532
+ aria-label={t('detail.schedule.presets')}
533
+ onChange={event => {
534
+ if (event.target.value === '') return
535
+ setScheduleCron(event.target.value)
536
+ setScheduleError(undefined)
537
+ }}
538
+ >
539
+ <option value="">{t('detail.schedule.presets')}…</option>
540
+ {SCHEDULE_PRESETS.map(preset => (
541
+ <option key={preset.cron} value={preset.cron}>{t(preset.label)}</option>
542
+ ))}
543
+ </select>
544
+ </div>
545
+ {scheduleError !== undefined && <p className={css.formError}>{scheduleError}</p>}
546
+ <label className={css.scheduleZone}>
547
+ <span>{t('detail.schedule.timeZone')}</span>
548
+ <select
549
+ className={css.schedulePreset}
550
+ value={scheduleZone}
551
+ aria-label={t('detail.schedule.timeZone')}
552
+ title={t('detail.schedule.timeZoneHint')}
553
+ onChange={event => { setScheduleZone(event.target.value); setScheduleError(undefined) }}
554
+ >
555
+ {zoneChoices(hostTimeZone, initialTask?.schedule?.timeZone).map(choice => (
556
+ <option key={choice.id === '' ? '__host' : choice.id} value={choice.id}>{choice.label}</option>
557
+ ))}
558
+ </select>
559
+ </label>
560
+ {scheduleError === undefined && scheduleNextRun !== undefined && (
561
+ <p className={css.scheduleMeta}>
562
+ {t('detail.schedule.nextRun')}{' '}
563
+ {nextRunLabel(scheduleNextRun, scheduleTimeZone, formatHostTimestamp, Date.now())}
564
+ </p>
565
+ )}
566
+ </>
447
567
  )}
568
+ </CollapsibleSection>
448
569
  </ModalShell>
449
570
  )
450
571
  }