Repository Public
1'use client';2import { useEffect, useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { AgentTask, CreateTask } from '@cr/shared';5import { api, errorMessage } from '@/lib/api';6import { upsertTask, useCluster, useProjects } from '@/lib/queries';7import { useCurrentProject } from '@/hooks/use-current';8import { Dialog } from '@/components/ui/dialog';9import { Button } from '@/components/ui/button';10import { Field, Input, Textarea } from '@/components/ui/input';11import { NONE, Select } from '@/components/ui/select';12import { useBrand } from '@/stores/branding';1314export function CreateTaskDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (t: AgentTask) => void }) {15 const brand = useBrand();16 const qc = useQueryClient();17 const projects = useProjects().data ?? [];18 const current = useCurrentProject();19 const cluster = useCluster();20 const [f, setF] = useState({ title: '', command: '', projectId: NONE, cwd: '', host: 'local', timeout: '', idempotent: false, globs: '' });21 const [busy, setBusy] = useState(false);22 const [error, setError] = useState<string | null>(null);23 useEffect(() => {24 if (open) {25 setF({ title: '', command: '', projectId: current?.id ?? NONE, cwd: '', host: 'local', timeout: '', idempotent: false, globs: '' });26 setError(null);27 }28 // eslint-disable-next-line react-hooks/exhaustive-deps -- reset on open only29 }, [open]);30 const hosts = [{ value: 'local', label: 'local', hint: `the ${brand.product} host` }, ...(cluster.data?.nodes ?? []).filter((n) => n.execAllowed && !n.isHost).map((n) => ({ value: n.id, label: n.id, hint: n.model ?? undefined }))];31 const timeout = f.timeout.trim() === '' ? null : Number(f.timeout);32 const timeoutOk = timeout === null || (Number.isInteger(timeout) && timeout >= 1 && timeout <= 7 * 24 * 3600);33 const valid = f.title.trim() && f.command.trim() && timeoutOk;34 const submit = async () => {35 setBusy(true);36 setError(null);37 try {38 const body: CreateTask = {39 projectId: f.projectId === NONE ? null : f.projectId,40 title: f.title.trim(),41 command: f.command,42 host: f.host,43 cwd: f.cwd.trim(),44 timeoutSec: timeout,45 idempotent: f.idempotent,46 artifactGlobs: f.globs.split(/[\n,]/).map((g) => g.trim()).filter(Boolean).slice(0, 20),47 };48 const t = await api.post<AgentTask>('/tasks', body);49 upsertTask(qc, t);50 onCreated(t);51 onClose();52 } catch (e) {53 setError(errorMessage(e));54 } finally {55 setBusy(false);56 }57 };58 return (59 <Dialog60 open={open}61 onOpenChange={(o) => !o && onClose()}62 title="New task"63 description="Runs a shell command in the background on the server. Output is captured to a log."64 size="lg"65 footer={66 <>67 {error && <p role="alert" className="mr-auto text-xs text-error">{error}</p>}68 <Button variant="ghost" onClick={onClose}>Cancel</Button>69 <Button variant="primary" loading={busy} disabled={!valid} onClick={submit} data-testid="create-task">Run task</Button>70 </>71 }72 >73 <div className="flex flex-col gap-4">74 <Field label="Title"><Input value={f.title} onChange={(e) => setF({ ...f, title: e.target.value })} maxLength={160} data-testid="task-title" /></Field>75 <Field label="Command" hint="Executed by your login shell.">76 <Textarea value={f.command} onChange={(e) => setF({ ...f, command: e.target.value })} maxLength={8000} rows={3} spellCheck={false} autoCapitalize="off" autoCorrect="off" className="font-mono text-xs" data-testid="task-command" />77 </Field>78 <div className="grid gap-4 sm:grid-cols-2">79 <Field label="Project"><Select value={f.projectId} onValueChange={(v) => setF({ ...f, projectId: v })} options={[...projects.map((p) => ({ value: p.id, label: p.name })), { value: NONE, label: 'No project' }]} /></Field>80 <Field label="Working directory" hint="Relative to the project root."><Input value={f.cwd} onChange={(e) => setF({ ...f, cwd: e.target.value })} maxLength={400} spellCheck={false} className="font-mono" /></Field>81 <Field label="Host" hint={cluster.isError ? 'Cluster data unavailable: local only.' : 'Local, or a node on the remote-execution allowlist.'}><Select value={f.host} onValueChange={(v) => setF({ ...f, host: v })} options={hosts} /></Field>82 <Field label="Timeout (seconds)" hint="Empty = no timeout." error={timeoutOk ? null : 'Whole number between 1 and 604800.'}><Input value={f.timeout} onChange={(e) => setF({ ...f, timeout: e.target.value })} inputMode="numeric" /></Field>83 </div>84 <Field label="Artifact globs" hint="Files to keep after success, one per line or comma-separated (e.g. dist/*.tgz)."><Input value={f.globs} onChange={(e) => setF({ ...f, globs: e.target.value })} spellCheck={false} className="font-mono" data-testid="task-globs" /></Field>85 <label className="flex min-h-9 cursor-pointer items-start gap-2.5 text-[13px] pointer-coarse:min-h-11">86 <input type="checkbox" checked={f.idempotent} onChange={(e) => setF({ ...f, idempotent: e.target.checked })} className="mt-0.5 size-4 accent-accent" data-testid="task-idempotent" />87 <span>Idempotent (safe to retry)<span className="block text-[11px] text-muted">Retry is only offered for tasks marked idempotent. Tasks are never retried automatically.</span></span>88 </label>89 </div>90 </Dialog>91 );92}93