/** * New-task modal: title + description + the prompt that execution will send. * Creates through the Host and closes only after the Host confirms it. */ import { useEffect, useRef, useState } from 'react' import type { BoardController } from '../../core/controller.ts' import { isValidCron, nextRunAtMs } from '../../core/schedule.ts' import { parseFreezeRequest } from '../../core/freeze-snapshot.ts' import { collectKnownTags, TASK_PERMISSIONS, type TaskPermission, type TaskRecord, type TaskTag } from '../../core/tasks.ts' import { t, type TaskBoardKey } from '../locales.ts' import { SCHEDULE_PRESETS } from '../schedule-presets.ts' import { ModalShell, TaskContentFields, TaskTagFields, cleanTags } from './TaskForm.tsx' import { readParseModelPreference, writeParseModelPreference } from './parse-model-pref.ts' import css from '../board.module.css' export interface NewTaskModalProps { controller: BoardController onClose: () => void /** Optional task template to clone/duplicate from. */ initialTask?: TaskRecord /** * Workspace the board's project filter has selected (#1536): a task created * while a project is open belongs to that project unless the user changes it. */ defaultWorkspaceId?: string /** Optional callback after successful duplication (e.g. to archive source). */ onDuplicateSuccess?: (sourceTaskId: string) => Promise } /** New-task form overlay. */ export function NewTaskModal({ controller, onClose, initialTask, defaultWorkspaceId, onDuplicateSuccess }: NewTaskModalProps) { const isDuplicate = initialTask !== undefined const [title, setTitle] = useState(initialTask?.title ?? '') const [description, setDescription] = useState(initialTask?.description ?? '') const [prompt, setPrompt] = useState(initialTask?.prompt ?? '') const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? defaultWorkspaceId ?? '') const [mode, setMode] = useState(initialTask?.mode ?? '') const [permission, setPermission] = useState(initialTask?.permission ?? '') const [model, setModel] = useState(initialTask?.model ?? '') const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false) const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false) const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '') const [scheduleError, setScheduleError] = useState(undefined) const [freezeText, setFreezeText] = useState('') const [freezeError, setFreezeError] = useState(undefined) const [handoverText, setHandoverText] = useState( initialTask?.handover?.references !== undefined ? initialTask.handover.references.join('\n') : '', ) const [tags, setTags] = useState(initialTask?.tags ?? []) const [archiveOriginal, setArchiveOriginal] = useState(true) const [error, setError] = useState(undefined) const [pending, setPending] = useState(false) const [options, setOptions] = useState(controller.getSnapshot().executionOptions) // "Parse pasted text" (issue #1540) exists only when the deployment carries a // parse face; the section stays hidden otherwise. const [canParse] = useState(controller.getSnapshot().canParseTask === true) const [parseText, setParseText] = useState('') // Issue #1621: start from the model this browser used last, not from the // roster's first entry; '' is the Host default and stays valid. const [parseModel, setParseModel] = useState(() => readParseModelPreference()) const [parsePending, setParsePending] = useState(false) const [parseError, setParseError] = useState(undefined) const parseAbort = useRef(undefined) const parseModels = options.models ?? [] // The workspace list and preset roster arrive from the runtime after mount; // follow them so the pickers never freeze on an empty snapshot. useEffect( () => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)), [controller], ) // The model roster arrives asynchronously. A remembered model the deployment // no longer offers falls back to the Host default; an empty value is the // Host-default choice and is never overwritten by the roster (issue #1621). useEffect(() => { if (parseModel === '' || parseModels.length === 0) return if (parseModels.some(option => option.id === parseModel)) return setParseModel('') writeParseModelPreference('') }, [parseModel, options.models]) const runParse = async (): Promise => { const text = parseText.trim() if (text === '') { setParseError(t('new.aiParseEmpty')) return } const abort = new AbortController() parseAbort.current = abort setParsePending(true) setParseError(undefined) try { const draft = await controller.parseTaskDraft({ text, ...(parseModel === '' ? {} : { model: parseModel }) }, abort.signal) setTitle(draft.title) setDescription(draft.description) setPrompt(draft.prompt) } catch (parseFailure) { // A cancelled parse reports nothing: the user asked for it to stop. if (!abort.signal.aborted) setParseError(parseFailure instanceof Error ? parseFailure.message : String(parseFailure)) } finally { parseAbort.current = undefined setParsePending(false) } } /** * Create the task through the Host, then optionally start it. * @param runAfterCreate - true for the "create and run" action: the task is * committed either way, and a refused start opens the task instead of * reporting the creation as failed. */ const submit = async (runAfterCreate: boolean): Promise => { if (scheduleEnabled) { const cron = scheduleCron.trim() if (cron === '' || !isValidCron(cron)) { setScheduleError(t('detail.schedule.invalid')) return } } // Optional continuation-card snapshot: parse the freeze block through the // T2 gate (structure + redaction + taint + size); a malformed block stops // submission with the parser's error instead of creating a plain task. let freeze: Parameters[0]['freeze'] = undefined if (freezeText.trim() !== '') { const parsed = parseFreezeRequest(freezeText) if (!parsed.ok) { setFreezeError(parsed.error.message) return } freeze = { ...parsed.snapshot, ...(parsed.warnings.includes('redacted') ? { redacted: true } : {}) } } // Optional handover bundle: non-empty reference lines attach the picked // triplet (workspace/mode/permission above) plus the references. const references = handoverText.split('\n').map(line => line.trim()).filter(line => line !== '') const handover = references.length === 0 ? undefined : { references, workspaceId: workspaceId === '' ? undefined : workspaceId, mode: mode === '' ? undefined : mode, permission: permission === '' ? undefined : permission as TaskPermission, } // Blank rows never reach the wire: the protocol rejects a tag with an // empty name, and an empty list is expressed by omitting the field. const tagList = cleanTags(tags) setPending(true) const task = await controller.createTaskConfirmed({ title, description, prompt, freeze, handover, workspaceId: workspaceId === '' ? undefined : workspaceId, mode: mode === '' ? undefined : mode, permission: permission === '' ? undefined : permission as TaskPermission, model: model === '' ? undefined : model, ...(reuseSession ? { reuseSession: true } : {}), ...(tagList.length > 0 ? { tags: tagList } : {}), schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined, }) if (task === undefined) { setPending(false) setError(controller.getSnapshot().transportError ?? t('new.required')) return } if (isDuplicate && archiveOriginal && initialTask !== undefined) { if (onDuplicateSuccess !== undefined) { await onDuplicateSuccess(initialTask.id) } else { await controller.archiveTask(initialTask.id) } } if (runAfterCreate) { // The task exists from here on, so a refused start (a permission above // the session default, a pinned target that went stale) must not read as // a failed creation: open the task, whose detail view owns the // confirmation step and the refusal message. const started = await controller.runTask(task.id) if (!started) controller.openTask(task.id) } onClose() } /** Next-run preview for a valid armed cron (creation-time only). */ const scheduleNextRun = scheduleEnabled && scheduleCron.trim() !== '' && isValidCron(scheduleCron) ? nextRunAtMs(scheduleCron, Date.now()) : undefined const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new') return ( { void submit(false) }} onClose={onClose} secondaryAction={{ label: t('new.createAndRun'), onSubmit: () => { void submit(true) } }} > {canParse && (
{t('new.aiParse')}

{t('new.aiParseHint')}