SPB Git

Repository Public

create-task-dialog.tsx 5.4 KB 93 lines tsx

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
Files main
Menu

Theme