285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
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 { 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 'apps/FileBrowser';
|
|
|
|
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 [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
|
const [showInputForm, setShowInputForm] = useState(false);
|
|
const [runPrompt, setRunPrompt] = useState<string | null>(null);
|
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
|
|
const { data: detail } = useQuery<CapabilityDetail>({
|
|
queryKey: [queryKey, dirName],
|
|
queryFn: () => client.get<CapabilityDetail>(`${endpoint}/${dirName}`),
|
|
});
|
|
|
|
const taskInputs = useMemo(() => (detail?.rawFrontmatter ? parseInputs(detail.rawFrontmatter) : []), [detail?.rawFrontmatter]);
|
|
|
|
const handleRunClick = () => {
|
|
if (taskInputs.length > 0) {
|
|
const defaults: Record<string, string> = {};
|
|
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 (
|
|
<>
|
|
<Card className="h-full overflow-hidden flex flex-col min-h-0">
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
|
|
<button onClick={() => setSelection({ kind, endpoint, queryKey, dirName: '' })} className="p-1 -ml-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
|
</button>
|
|
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1">{detail?.name ?? `Loading...`}</span>
|
|
{detail && (
|
|
<>
|
|
{kind === 'Task' && (
|
|
<button
|
|
onClick={handleRunClick}
|
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
|
title="Run task"
|
|
>
|
|
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={toggleEditing}
|
|
className={`p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors ${editing ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<Pencil className={`h-3.5 w-3.5 ${editing ? 'text-duck-teal' : 'text-duck-dark/50 dark:text-foreground/50'}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm(true)}
|
|
className="p-1 rounded hover:bg-red-50 cursor-pointer transition-colors"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="overflow-y-auto flex-1 p-6">
|
|
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
|
|
{detail?.body ? (
|
|
<article className="skill-md">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
{detail.body}
|
|
</ReactMarkdown>
|
|
</article>
|
|
) : detail ? (
|
|
<p className="text-sm text-duck-dark/40 dark:text-foreground/40 text-center mt-12">Empty file</p>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
|
|
<Dialog open={deleteConfirm} onOpenChange={setDeleteConfirm}>
|
|
<DialogContent className="sm:max-w-md z-[700]">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete {kind}</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(false)} className="cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete} className="cursor-pointer">
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showInputForm} onOpenChange={setShowInputForm}>
|
|
<DialogContent className="sm:max-w-lg z-[700]">
|
|
<DialogHeader>
|
|
<DialogTitle>Run {detail?.name}</DialogTitle>
|
|
<DialogDescription>Configure inputs before running.</DialogDescription>
|
|
</DialogHeader>
|
|
<form
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
handleInputSubmit();
|
|
}}
|
|
className="flex flex-col gap-3 mt-1"
|
|
>
|
|
{taskInputs.map((input) => (
|
|
<div key={input.name} className="flex flex-col gap-1">
|
|
{input.type === 'boolean' ? (
|
|
<label className="flex items-center gap-2 text-sm font-medium text-foreground/70 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={inputValues[input.name] === 'true'}
|
|
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.checked ? 'true' : 'false' }))}
|
|
className="h-4 w-4 rounded border-foreground/20 accent-duck-teal"
|
|
/>
|
|
{input.name}
|
|
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
|
</label>
|
|
) : (
|
|
<>
|
|
<label className="text-sm font-medium text-foreground/70">
|
|
{input.name}
|
|
{input.required && <span className="text-destructive ml-0.5">*</span>}
|
|
</label>
|
|
{input.type === 'select' ? (
|
|
<select
|
|
value={inputValues[input.name] ?? ''}
|
|
onChange={(ev) => setInputValues((prev) => ({ ...prev, [input.name]: ev.target.value }))}
|
|
required={input.required}
|
|
className="rounded border border-foreground/20 bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-duck-teal/30"
|
|
>
|
|
{input.options.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={input.type === 'number' ? 'number' : 'text'}
|
|
value={inputValues[input.name] ?? ''}
|
|
onChange={(ev) => 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 && (
|
|
<span className="text-xs text-foreground/40">{input.description}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button type="button" variant="outline" onClick={() => setShowInputForm(false)} className="cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer">
|
|
Run
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{runPrompt !== null && detail && (
|
|
<TaskRunnerModal
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setRunPrompt(null);
|
|
}}
|
|
task={{ dirName, name: detail.name, description: detail.description ?? '', scope: detail.scope ?? 'user', triggers: [], filePath: detail.filePath }}
|
|
promptOverride={runPrompt}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|