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 { Card } from '@/components/Card';
import { usePiChat, EmbeddableChat } from 'officerdev';
type CapabilitySummary = {
dirName: string;
name: string;
description: string;
scope: '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;
};
export const CapabilityChat = ({
kind,
filePath,
resourceDir,
isNew,
description,
onResponseEnd,
}: CapabilityChatProps) => {
const seedFile = `${kind.toUpperCase()}.md`;
const promptFrontmatter = `\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 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 && (
)}
{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 [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?.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] })}
/>
)}
>
);
};