422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
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 = `<frontmatter>\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</frontmatter>`;
|
|
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 (
|
|
<EmbeddableChat
|
|
chat={pi}
|
|
defaultInput={defaultInput}
|
|
promptPrefix={promptFrontmatter}
|
|
className="h-full"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const FrontmatterBlock = ({ yaml }: { yaml: string }) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="mb-4 rounded border border-duck-dark/10 bg-duck-dark/3 text-sm">
|
|
<button
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors"
|
|
>
|
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
<span className="text-xs font-medium">Frontmatter</span>
|
|
</button>
|
|
{open && (
|
|
<pre className="px-4 pb-3 text-xs text-duck-dark/60 whitespace-pre-wrap font-mono leading-relaxed">{yaml}</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<HTMLInputElement | null>(null);
|
|
|
|
const { data: items = [] } = useQuery<CapabilitySummary[]>({
|
|
queryKey: [queryKey],
|
|
queryFn: () => client.get<CapabilitySummary[]>(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 (
|
|
<>
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center justify-between">
|
|
<span className="text-sm font-medium text-duck-dark/70">{kind}s</span>
|
|
{!creating && (
|
|
<button
|
|
onClick={() => {
|
|
setCreating(true);
|
|
setTimeout(() => newNameRef.current?.focus(), 0);
|
|
}}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4 text-duck-dark/50" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{creating && (
|
|
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10 bg-duck-teal/5 flex items-center gap-1.5">
|
|
<input
|
|
ref={newNameRef}
|
|
value={newName}
|
|
onChange={(ev) => 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"
|
|
/>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={!newName.trim()}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors disabled:opacity-30"
|
|
>
|
|
<Check className="h-3.5 w-3.5 text-duck-teal" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setCreating(false);
|
|
setNewName('');
|
|
}}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{externalSearch === undefined && (
|
|
<div className="shrink-0 px-3 py-2 border-b border-duck-dark/10">
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
|
<input
|
|
value={internalSearch}
|
|
onChange={(ev) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="overflow-y-auto flex-1">
|
|
{filtered.map((item) => (
|
|
<button
|
|
key={item.dirName}
|
|
onClick={() => onSelect(item.dirName)}
|
|
className={`w-full text-left px-4 py-3 border-b border-duck-dark/5 cursor-pointer transition-colors ${
|
|
selected === item.dirName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-duck-dark truncate">{item.name}</span>
|
|
<span
|
|
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
|
item.scope === 'user' ? 'bg-duck-teal/20 text-duck-teal' : 'bg-duck-dark/10 text-duck-dark/60'
|
|
}`}
|
|
>
|
|
{item.scope}
|
|
</span>
|
|
</div>
|
|
{item.description && <p className="text-xs text-duck-dark/50 mt-1 line-clamp-2">{item.description}</p>}
|
|
</button>
|
|
))}
|
|
{items.length === 0 && (
|
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No {kind.toLowerCase()}s found</p>
|
|
)}
|
|
{items.length > 0 && filtered.length === 0 && (
|
|
<p className="text-sm text-duck-dark/40 px-4 py-6 text-center">No matches</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const CapabilityPage = ({ kind, endpoint, queryKey }: CapabilityPageProps) => {
|
|
const client = useClient();
|
|
const qc = useQueryClient();
|
|
const [selected, setSelected] = useState<string | null>(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<CapabilitySummary[]>({
|
|
queryKey: [queryKey],
|
|
queryFn: () => client.get<CapabilitySummary[]>(endpoint),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (items.length > 0 && !selected) {
|
|
setSelected(items[0]!.dirName);
|
|
}
|
|
}, [items, selected]);
|
|
|
|
const { data: detail } = useQuery<CapabilityDetail>({
|
|
queryKey: [queryKey, selected],
|
|
queryFn: () => client.get<CapabilityDetail>(`${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 (
|
|
<>
|
|
<div className="flex h-full p-2 md:p-4 gap-2 md:gap-4">
|
|
{/* Left panel — list */}
|
|
<Card
|
|
className={`md:w-72 shrink-0 overflow-hidden flex flex-col ${showDetail ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
|
|
>
|
|
<CapabilityList
|
|
kind={kind}
|
|
endpoint={endpoint}
|
|
queryKey={queryKey}
|
|
selected={selected}
|
|
onSelect={selectItem}
|
|
onCreate={handleCreate}
|
|
/>
|
|
</Card>
|
|
|
|
{/* Right panel — detail + chat */}
|
|
<div className={`flex-1 flex flex-col gap-4 min-h-0 ${showDetail ? 'flex' : 'hidden md:flex'}`}>
|
|
<Card className={`flex-1 overflow-hidden flex flex-col min-h-0 ${editing ? 'hidden md:flex' : ''}`}>
|
|
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowDetail(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<span className="text-sm font-medium text-duck-dark/70 flex-1">
|
|
{detail?.name ?? `Select a ${kind.toLowerCase()}`}
|
|
</span>
|
|
{detail && (
|
|
<>
|
|
<button
|
|
onClick={() => setEditing((e) => !e)}
|
|
className={`p-1 rounded hover:bg-duck-dark/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'}`} />
|
|
</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 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 text-center mt-12">Empty file</p>
|
|
) : (
|
|
<p className="text-sm text-duck-dark/40 text-center mt-12">
|
|
Select a {kind.toLowerCase()} to view its contents
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{editing && detail?.filePath && selected && (
|
|
<Card className="flex-1 overflow-hidden flex flex-col min-h-0">
|
|
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 bg-background/60 flex items-center gap-2">
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<span className="text-xs font-medium text-duck-dark/50 flex-1">{detail?.name ?? 'Chat'}</span>
|
|
<button
|
|
onClick={() => setEditing(false)}
|
|
className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
|
>
|
|
<X className="h-3.5 w-3.5 text-duck-dark/50" />
|
|
</button>
|
|
</div>
|
|
<CapabilityChat
|
|
kind={kind.toLowerCase()}
|
|
key={detail.filePath}
|
|
endpoint={endpoint}
|
|
dirName={selected}
|
|
filePath={detail.filePath}
|
|
resourceDir={detail.filePath.replace(/\/[^/]+$/, '')}
|
|
chatSessionId={detail.chatSessionId}
|
|
isNew={isNew}
|
|
onResponseEnd={() => qc.invalidateQueries({ queryKey: [queryKey, selected] })}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
};
|