This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,384 @@
import { useState, useEffect, useRef, 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, Plus, Check, X, Trash2, Search } 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 { useVisibleClaudeModels } from '@/state/useModels';
import { Card } from '@/components/Card';
import { DashboardLayout } from './Layout';
import { useClaude } from './Chat/useClaude';
import { EmbeddableChat } from './Chat/EmbeddableChat';
import type { ChatMessage } from './Chat/types';
type ResourceSummary = {
dirName: string;
name: string;
description: string;
scope: 'global' | 'user';
};
type ResourceDetail = ResourceSummary & {
body: string;
filePath: string;
chatSessionId: string | null;
};
type ResourcePageProps = {
kind: string;
endpoint: string;
queryKey: string;
};
type ResourceChatProps = {
kind: string;
endpoint: string;
dirName: string;
filePath: string;
resourceDir: string;
chatSessionId: string | null;
isNew?: boolean;
onResponseEnd?: () => void;
};
const ResourceChat = ({
kind,
endpoint,
dirName,
filePath,
resourceDir,
chatSessionId,
isNew,
onResponseEnd,
}: ResourceChatProps) => {
const client = useClient();
const claudeModels = useVisibleClaudeModels();
const defaultInput = chatSessionId
? undefined
: isNew
? `Help me create the content for this new ${kind} file: ${filePath}`
: `Help me understand and improve this ${kind} file: ${filePath}`;
const storage = useMemo(
() => ({
load: async () => {
const data = await client.get<{ sessionId: string | null; messages: ChatMessage[] }>(
`${endpoint}/${dirName}/chat`,
);
return { sessionId: data.sessionId, messages: data.messages ?? [] };
},
save: async (sessionId: string, messages: ChatMessage[]) => {
await client.put(`${endpoint}/${dirName}/chat`, { sessionId, messages });
},
}),
[endpoint, dirName],
);
const claude = useClaude(chatSessionId ?? undefined, undefined, {
replaceUrl: false,
storage,
resourceChatDir: resourceDir,
});
const onResponseEndRef = useRef(onResponseEnd);
onResponseEndRef.current = onResponseEnd;
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !claude.isGenerating) {
onResponseEndRef.current?.();
}
wasGenerating.current = claude.isGenerating;
}, [claude.isGenerating]);
return (
<EmbeddableChat
chat={claude}
provider="claude"
availableModels={claudeModels}
defaultInput={defaultInput}
className="h-full"
/>
);
};
export const ResourcePage = ({ kind, endpoint, queryKey }: ResourcePageProps) => {
const client = useClient();
const qc = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [isNew, setIsNew] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [search, setSearch] = useState('');
const [showDetail, setShowDetail] = useState(false);
const newNameRef = useRef<HTMLInputElement | null>(null);
const { data: items = [] } = useQuery<ResourceSummary[]>({
queryKey: [queryKey],
queryFn: () => client.get<ResourceSummary[]>(endpoint),
});
useEffect(() => {
if (items.length > 0 && !selected) {
setSelected(items[0]!.dirName);
}
}, [items, selected]);
const { data: detail } = useQuery<ResourceDetail>({
queryKey: [queryKey, selected],
queryFn: () => client.get<ResourceDetail>(`${endpoint}/${selected}`),
enabled: !!selected,
});
const selectItem = (dirName: string) => {
setSelected(dirName);
setShowDetail(true);
setIsNew(false);
setEditing(false);
};
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('');
setSelected(res.dirName);
setShowDetail(true);
setIsNew(true);
setEditing(true);
} catch {
toast.error(`Failed to create ${kind}`);
}
};
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}`);
}
};
const filtered = items.filter(
(item) =>
!search ||
item.name.toLowerCase().includes(search.toLowerCase()) ||
item.description?.toLowerCase().includes(search.toLowerCase()),
);
return (
<DashboardLayout mobileFull={editing}>
<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'}`}
>
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-white/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-white 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>
)}
<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={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder={`Search ${kind.toLowerCase()}s...`}
className="w-full rounded border border-duck-dark/15 bg-white/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={() => selectItem(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>
</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-white/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?.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-white/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>
<ResourceChat
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 &quot;{detail?.name}&quot;? 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>
</DashboardLayout>
);
};