import { useState, useEffect, useRef } 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, Plus, Check, X, Trash2, Search, ChevronRight } 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 { Card } from '@/components/Card'; import { usePiChat, EmbeddableChat } from 'officerdev'; type CapabilitySummary = { dirName: string; name: string; description: string; scope: 'native' | 'global' | 'user'; }; export type CapabilityDetail = CapabilitySummary & { body: string; rawFrontmatter: string; filePath: string; chatSessionId: string | null; }; type CapabilityListProps = { kind: string; endpoint: string; queryKey: string; selected: string | null; onSelect: (dirName: string) => void; onCreate?: (dirName: string) => void; search?: string; showCreate?: boolean; onShowCreateChange?: (value: boolean) => void; }; type CapabilityPageProps = { kind: string; endpoint: string; queryKey: string; }; type CapabilityChatProps = { kind: string; endpoint: string; dirName: string; filePath: string; resourceDir: string; chatSessionId: string | null; isNew?: boolean; description?: string; onResponseEnd?: () => void; }; const buildTaskCreationPrefix = (filePath: string, resourceDir: string) => ` input file: ${filePath} TASK.md: ${filePath} dir: ${resourceDir} Be aware of any extra files alongside the same dir as the task file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future. You are helping create a new task. Gather requirements through a short conversation BEFORE writing the TASK.md. Ask questions one at a time (or a small related group), wait for the answer, then move on. Conversation flow: 1. First, ask what the task should do — its purpose and high-level steps. 2. Based on the answer, ask about triggers: should it appear in the file browser context menu for specific file types? For directories? Or only be runnable from the Automation page? 3. Then ask if it needs user inputs (parameters) when running, and if so what kind (text, number, yes/no toggle, dropdown). 4. If anything is still unclear, ask a follow-up. Otherwise, write the TASK.md. Rules: - Never ask all questions at once. Keep it conversational. - Each message should have at most 1-2 questions on the same topic. - Summarize what you understood before writing the file so the user can confirm. ## TASK.md Format \`\`\`yaml --- name: Task Name description: Short description of what the task does. version: 1 author: pastilhas tags: - tag1 - tag2 skills: - skill-name # optional — skills the agent can use tools: - tool_name # optional — tools the agent can call trigger: # optional — when omitted, only runnable from Automation page - type: file extensions: - mp3 - flac - type: directory inputs: # optional — parameters the user fills in before running - name: param_name description: What this parameter is for. type: string # string (default) | number | boolean | select required: true default: some value # select example: - name: country description: Country to use. type: select default: US options: - value: US label: United States - value: PT label: Portugal # number example: - name: limit type: number default: 20 min: 1 max: 100 # boolean example: - name: download type: boolean default: false --- (Markdown body with detailed instructions for the agent executing the task) \`\`\` ## Trigger rules - \`type: file\` + \`extensions\` → appears in file browser context menu for those file types - \`type: directory\` → appears on right-click directories - Both can coexist in the same task - No triggers → task is only runnable from the Automation page ## Notes - Tasks run inside the user's sandboxed container - The markdown body after the frontmatter should contain step-by-step instructions for the agent `; const buildSkillCreationPrefix = (filePath: string, resourceDir: string) => ` input file: ${filePath} SKILL.md: ${filePath} dir: ${resourceDir} Be aware of any extra files alongside the same dir as the skill file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future. You are helping create a new skill. A skill is a reference document (knowledge base) that the agent can consult when performing tasks. Gather requirements through a short conversation BEFORE writing the SKILL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on. Conversation flow: 1. First, ask what technology, API, or domain this skill covers — what should the agent know about? 2. Ask what key information should be included: API reference, code examples, common patterns, gotchas? 3. If it's for a specific library or tool, ask for the version and any project-specific conventions. 4. Summarize what you understood before writing the file so the user can confirm. Rules: - Never ask all questions at once. Keep it conversational. - Each message should have at most 1-2 questions on the same topic. - Summarize what you understood before writing the file so the user can confirm. ## SKILL.md Format \`\`\`yaml --- name: skill-name description: When to use this skill — a sentence describing the domain and trigger conditions. --- (Comprehensive reference documentation in markdown — API docs, code examples, recipes, best practices) \`\`\` ## Notes - The frontmatter only needs \`name\` and \`description\` - The description should tell the agent WHEN to consult this skill (e.g. "Use when the user wants to process images with sharp") - The markdown body is the actual knowledge — be thorough, include code examples and common recipes - Skills are referenced by name in TASK.md \`skills:\` fields `; const buildToolCreationPrefix = (filePath: string, resourceDir: string) => ` input file: ${filePath} TOOL.md: ${filePath} dir: ${resourceDir} Be aware of any extra files alongside the same dir as the tool file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future. You are helping create a new tool. A tool is an executable function the agent can call. Gather requirements through a short conversation BEFORE writing the TOOL.md. Ask questions one at a time (or a small related group), wait for the answer, then move on. Conversation flow: 1. First, ask what the tool should do — what action does it perform? 2. Ask what inputs (parameters) it needs and their types. 3. Ask what language it should be implemented in (TypeScript, Bash, or Python) and whether it needs any external APIs or services. 4. If anything is still unclear, ask a follow-up. Otherwise, write the TOOL.md. Rules: - Never ask all questions at once. Keep it conversational. - Each message should have at most 1-2 questions on the same topic. - Summarize what you understood before writing the file so the user can confirm. ## TOOL.md Format \`\`\`yaml --- name: tool_name label: Tool Display Name description: What the tool does and when to use it. language: typescript # typescript | bash | python inputs: param_name: type: string # string | number | boolean | enum | object description: What this parameter is for. optional_param: type: string description: An optional parameter. optional: true secret_param: type: string description: A sensitive parameter (e.g. API key). optional: true sensitive: true choice_param: type: enum description: A parameter with fixed options. values: - option_a - option_b --- (Markdown body with documentation: usage notes, output format, error handling, examples) \`\`\` ## Input types - \`string\` — free text (default) - \`number\` — numeric value - \`boolean\` — true/false - \`enum\` — fixed set of values (list under \`values:\`) - \`object\` — JSON object ## Notes - Tools run inside the user's sandboxed container - The \`name\` field uses snake_case (this is the function name the agent calls) - The \`label\` field is the human-readable display name - Mark parameters as \`optional: true\` when they have sensible defaults - Mark credentials/keys as \`sensitive: true\` so they aren't logged - Tools are referenced by name in TASK.md \`tools:\` fields `; const buildCreationPrefix = (kind: string, filePath: string, resourceDir: string) => { switch (kind) { case 'task': return buildTaskCreationPrefix(filePath, resourceDir); case 'skill': return buildSkillCreationPrefix(filePath, resourceDir); case 'tool': return buildToolCreationPrefix(filePath, resourceDir); default: return null; } }; export const CapabilityChat = ({ kind, filePath, resourceDir, isNew, description, onResponseEnd, }: CapabilityChatProps) => { const seedFile = `${kind.toUpperCase()}.md`; const genericPrefix = `\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n`; const promptFrontmatter = isNew ? buildCreationPrefix(kind, filePath, resourceDir) ?? genericPrefix : genericPrefix; const defaultInput = isNew ? description ?? `Help me create the content for this new ${kind} file` : `Help me understand and improve this ${kind} file`; const pi = usePiChat(undefined, undefined, { replaceUrl: false }); const onResponseEndRef = useRef(onResponseEnd); onResponseEndRef.current = onResponseEnd; const wasGenerating = useRef(false); useEffect(() => { if (wasGenerating.current && !pi.isGenerating) { onResponseEndRef.current?.(); } wasGenerating.current = pi.isGenerating; }, [pi.isGenerating]); return ( ); }; export const FrontmatterBlock = ({ yaml }: { yaml: string }) => { const [open, setOpen] = useState(false); return (
{open && (
{yaml}
)}
); }; export const CapabilityList = ({ kind, endpoint, queryKey, selected, onSelect, onCreate, search: externalSearch, showCreate, onShowCreateChange }: CapabilityListProps) => { const client = useClient(); const qc = useQueryClient(); const [internalCreating, setInternalCreating] = useState(false); const creating = showCreate ?? internalCreating; const setCreating = onShowCreateChange ?? setInternalCreating; const [newName, setNewName] = useState(''); const [internalSearch, setInternalSearch] = useState(''); const search = externalSearch ?? internalSearch; const newNameRef = useRef(null); const { data: items = [] } = useQuery({ queryKey: [queryKey], queryFn: () => client.get(endpoint), }); const handleCreate = async () => { const name = newName.trim(); if (!name) return; try { const res = await client.post<{ name: string; dirName: string }>(endpoint, { name }); await qc.invalidateQueries({ queryKey: [queryKey] }); setCreating(false); setNewName(''); (onCreate ?? onSelect)(res.dirName); } catch { toast.error(`Failed to create ${kind}`); } }; const filtered = items.filter( (item) => !search || item.name.toLowerCase().includes(search.toLowerCase()) || item.description?.toLowerCase().includes(search.toLowerCase()), ); return ( <>
{kind}s {!creating && ( )}
{creating && (
setNewName(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'Enter') { ev.preventDefault(); handleCreate(); } if (ev.key === 'Escape') { setCreating(false); setNewName(''); } }} placeholder={`${kind} name...`} className="flex-1 min-w-0 rounded border border-duck-dark/20 bg-background px-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" />
)} {externalSearch === undefined && (
setInternalSearch(ev.target.value)} placeholder={`Search ${kind.toLowerCase()}s...`} className="w-full rounded border border-duck-dark/15 bg-background/80 pl-7 pr-2 py-1 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/30" />
)}
{filtered.map((item) => ( ))} {items.length === 0 && (

No {kind.toLowerCase()}s found

)} {items.length > 0 && filtered.length === 0 && (

No matches

)}
); }; export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps) => { const client = useClient(); const qc = useQueryClient(); const { user } = useAuth(); const [selected, setSelected] = useState(null); const [editing, setEditing] = useState(false); const [isNew, setIsNew] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [showDetail, setShowDetail] = useState(false); const { data: items = [] } = useQuery({ queryKey: [queryKey], queryFn: () => client.get(endpoint), }); useEffect(() => { if (items.length > 0 && !selected) { setSelected(items[0]!.dirName); } }, [items, selected]); const { data: detail } = useQuery({ queryKey: [queryKey, selected], queryFn: () => client.get(`${endpoint}/${selected}`), enabled: !!selected, }); const selectItem = (dirName: string) => { setSelected(dirName); setShowDetail(true); setIsNew(false); setEditing(false); }; const handleCreate = (dirName: string) => { setSelected(dirName); setShowDetail(true); setIsNew(true); setEditing(true); }; const handleDelete = async () => { if (!selected) return; try { await client.delete(`${endpoint}/${selected}`); setDeleteConfirm(false); setEditing(false); setSelected(null); setShowDetail(false); await qc.invalidateQueries({ queryKey: [queryKey] }); } catch { toast.error(`Failed to delete ${kind}`); } }; return ( <>
{/* Left panel — list */} {/* Right panel — detail + chat */}
{detail?.name ?? `Select a ${kind.toLowerCase()}`} {detail && (detail.scope === 'user' || user?.role === 'Super Admin') && ( <> )}
{detail?.rawFrontmatter && } {detail?.body ? (
{detail.body}
) : detail ? (

Empty file

) : (

Select a {kind.toLowerCase()} to view its contents

)}
{editing && detail?.filePath && selected && (
{detail?.name ?? 'Chat'}
qc.invalidateQueries({ queryKey: [queryKey, selected] })} />
)}
Delete {kind} Are you sure you want to delete "{detail?.name}"? This action cannot be undone.
); };