import { useState, useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import { toast } from 'sonner'; import { ArrowLeft, Pencil, Play, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { useClient } from 'hooks/useClient'; import { useAuth } from 'hooks/useAuth'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { Card } from '@/components/Card'; import { FrontmatterBlock } from '../CapabilityPage'; import type { CapabilityDetail } from '../CapabilityPage'; import type { AutomationSelection } from './AutomationRightPanel'; import { TaskRunnerModal } from 'officerdev'; type SelectOption = { value: string; label: string }; type TaskInput = { name: string; description: string; type: 'string' | 'number' | 'boolean' | 'select'; required: boolean; default: string; options: SelectOption[]; }; function parseInputs(rawYaml: string): TaskInput[] { // Match the inputs block: everything indented after "inputs:" until next top-level key or end const inputsMatch = rawYaml.match(/^inputs:\s*\n((?:[ \t]+.*\n?)*)/m); if (!inputsMatch) return []; const block = inputsMatch[1]!; // Split on list items that have a "name:" field (e.g. " - name: country") const items = block.split(/(?=[ \t]+-\s*name\s*:)/); return items .filter((item) => /name\s*:/.test(item)) .map((item) => { const name = item.match(/name\s*:\s*(.+)/)?.[1]?.trim() ?? ''; const description = item.match(/description\s*:\s*(.+)/)?.[1]?.trim() ?? ''; const rawType = item.match(/type\s*:\s*(.+)/)?.[1]?.trim() ?? 'string'; const type = (['string', 'number', 'boolean', 'select'] as const).includes(rawType as 'string') ? (rawType as TaskInput['type']) : 'string'; const required = item.match(/required\s*:\s*(.+)/)?.[1]?.trim() === 'true'; const defaultFromField = item.match(/default\s*:\s*(.+)/)?.[1]?.trim(); const defaultFromDesc = description.match(/[Dd]efaults?\s+to\s+(\S+?)\.?\s*$/)?.[1]; const options: SelectOption[] = []; const optionsMatch = item.match(/options\s*:\s*\n((?:[ \t]+.*\n?)*)/); if (optionsMatch) { const optionEntries = optionsMatch[1]!.split(/(?=[ \t]*- \s*value\s*:)/); for (const entry of optionEntries) { const value = entry.match(/value\s*:\s*(.+)/)?.[1]?.trim() ?? ''; const label = entry.match(/label\s*:\s*(.+)/)?.[1]?.trim() ?? value; if (value) options.push({ value, label }); } } return { name, description, type, required, default: defaultFromField ?? defaultFromDesc ?? '', options }; }); } type CapabilityDetailViewProps = { kind: string; endpoint: string; queryKey: string; dirName: string; isNew?: boolean; editing?: boolean; }; export const CapabilityDetailView = ({ kind, endpoint, queryKey, dirName, editing }: CapabilityDetailViewProps) => { const client = useClient(); const qc = useQueryClient(); const { user } = useAuth(); const [selection, setSelection] = usePanelChannel('automation:selected-capability', null); const [deleteConfirm, setDeleteConfirm] = useState(false); const [showInputForm, setShowInputForm] = useState(false); const [runPrompt, setRunPrompt] = useState(null); const [inputValues, setInputValues] = useState>({}); const { data: detail } = useQuery({ queryKey: [queryKey, dirName], queryFn: () => client.get(`${endpoint}/${dirName}`), }); const taskInputs = useMemo(() => (detail?.rawFrontmatter ? parseInputs(detail.rawFrontmatter) : []), [detail?.rawFrontmatter]); const handleRunClick = () => { if (taskInputs.length > 0) { const defaults: Record = {}; for (const input of taskInputs) { if (input.type === 'boolean') { defaults[input.name] = input.default === 'true' ? 'true' : 'false'; } else if (input.type === 'select') { defaults[input.name] = input.default || (input.options[0]?.value ?? ''); } else { defaults[input.name] = input.default; } } setInputValues(defaults); setShowInputForm(true); } else { setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them`); } }; const handleInputSubmit = () => { const parts = Object.entries(inputValues) .filter(([, v]) => v !== '') .map(([k, v]) => `- ${k}: ${v}`); const suffix = parts.length > 0 ? `\n\nInputs:\n${parts.join('\n')}` : ''; setRunPrompt(`Read the task instructions at ${detail!.filePath} and execute them${suffix}`); setShowInputForm(false); }; const toggleEditing = () => { if (!selection) return; setSelection({ ...selection, editing: !editing }); }; const handleDelete = async () => { try { await client.delete(`${endpoint}/${dirName}`); setDeleteConfirm(false); await qc.invalidateQueries({ queryKey: [queryKey] }); setSelection(null); } catch { toast.error(`Failed to delete ${kind}`); } }; return ( <>
{detail?.name ?? `Loading...`} {detail && ( <> {kind === 'Task' && ( )} {(detail.scope === 'user' || user?.role === 'Super Admin') && ( <> )} )}
{detail?.rawFrontmatter && } {detail?.body ? (
{detail.body}
) : detail ? (

Empty file

) : null}
Delete {kind} Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
Run {detail?.name} Configure inputs before running.
{ ev.preventDefault(); handleInputSubmit(); }} className="flex flex-col gap-3 mt-1" > {taskInputs.map((input) => (
{input.type === 'boolean' ? ( ) : ( <> {input.type === 'select' ? ( ) : ( setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))} placeholder={input.default || undefined} required={input.required} className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" /> )} )} {input.description && ( {input.description} )}
))}
{runPrompt !== null && detail && ( { if (!open) setRunPrompt(null); }} task={{ dirName, name: detail.name, description: detail.description ?? '', scope: detail.scope === 'user' ? 'user' : 'global', triggers: [], filePath: detail.filePath }} promptOverride={runPrompt} /> )} ); };