multica/apps/web/features/skills/components/skills-page.tsx
Naiyuan Qing 7f0cb106bd feat(web): add skeleton loading, error toasts, and confirmation dialogs
- Replace all "Loading..." text with structured skeleton screens
  (Issue Detail, Agents, Skills, Runtimes, Tokens, Usage)
- Add toast.error for all API failures that were previously silent
  (Agents CRUD, Skills CRUD, workspace store, issue/inbox stores,
   timeline/reactions/subscribers hooks, agent-live-card)
- Add toast.success for mutations (agent update/delete, skill CRUD)
- Add confirmation dialogs for destructive actions
  (comment delete, token revoke)
- Add empty states for Issues and My Issues pages
- Fix hydrateWorkspace resilience: each request catches independently
  so partial failures don't block workspace entry
- Fix React key warning in issue-detail timeline rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:46:56 +08:00

810 lines
27 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Sparkles,
Plus,
Trash2,
Save,
AlertCircle,
Download,
} from "lucide-react";
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { FileTree } from "./file-tree";
import { FileViewer } from "./file-viewer";
// ---------------------------------------------------------------------------
// Create Skill Dialog
// ---------------------------------------------------------------------------
function CreateSkillDialog({
onClose,
onCreate,
onImport,
}: {
onClose: () => void;
onCreate: (data: CreateSkillRequest) => Promise<void>;
onImport: (url: string) => Promise<void>;
}) {
const [tab, setTab] = useState<"create" | "import">("create");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [importUrl, setImportUrl] = useState("");
const [loading, setLoading] = useState(false);
const [importError, setImportError] = useState("");
const detectedSource = (() => {
const url = importUrl.trim().toLowerCase();
if (url.includes("clawhub.ai")) return "clawhub" as const;
if (url.includes("skills.sh")) return "skills.sh" as const;
return null;
})();
const handleCreate = async () => {
if (!name.trim()) return;
setLoading(true);
try {
await onCreate({ name: name.trim(), description: description.trim() });
onClose();
} catch {
setLoading(false);
}
};
const handleImport = async () => {
if (!importUrl.trim()) return;
setLoading(true);
setImportError("");
try {
await onImport(importUrl.trim());
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import failed");
setLoading(false);
}
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Skill</DialogTitle>
<DialogDescription>
Create a new skill or import from ClawHub / Skills.sh.
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as "create" | "import")}>
<TabsList className="w-full">
<TabsTrigger value="create" className="flex-1">
<Plus className="mr-1.5 h-3 w-3" />
Create
</TabsTrigger>
<TabsTrigger value="import" className="flex-1">
<Download className="mr-1.5 h-3 w-3" />
Import
</TabsTrigger>
</TabsList>
<TabsContent value="create" className="space-y-4 mt-4 min-h-[180px]">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Code Review, Bug Triage"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this skill does"
className="mt-1"
/>
</div>
</TabsContent>
<TabsContent value="import" className="space-y-4 mt-4 min-h-[180px]">
<div>
<Label className="text-xs text-muted-foreground">Skill URL</Label>
<Input
autoFocus
type="text"
value={importUrl}
onChange={(e) => { setImportUrl(e.target.value); setImportError(""); }}
placeholder="Paste a skill URL..."
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleImport()}
/>
</div>
{/* Supported sources — highlight on detection */}
<div>
<p className="text-xs text-muted-foreground mb-2">Supported sources</p>
<div className="grid grid-cols-2 gap-2">
<div className={`rounded-lg border px-3 py-2.5 transition-colors ${
detectedSource === "clawhub"
? "border-primary bg-primary/5"
: ""
}`}>
<div className="text-xs font-medium">ClawHub</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground font-mono">
clawhub.ai/owner/skill
</div>
</div>
<div className={`rounded-lg border px-3 py-2.5 transition-colors ${
detectedSource === "skills.sh"
? "border-primary bg-primary/5"
: ""
}`}>
<div className="text-xs font-medium">Skills.sh</div>
<div className="mt-0.5 truncate text-[11px] text-muted-foreground font-mono">
skills.sh/owner/repo/skill
</div>
</div>
</div>
</div>
{importError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{importError}
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
{tab === "create" ? (
<Button onClick={handleCreate} disabled={loading || !name.trim()}>
{loading ? "Creating..." : "Create"}
</Button>
) : (
<Button onClick={handleImport} disabled={loading || !importUrl.trim()}>
{loading ? (
detectedSource === "clawhub"
? "Importing from ClawHub..."
: detectedSource === "skills.sh"
? "Importing from Skills.sh..."
: "Importing..."
) : (
<>
<Download className="mr-1.5 h-3 w-3" />
Import
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Skill List Item
// ---------------------------------------------------------------------------
function SkillListItem({
skill,
isSelected,
onClick,
}: {
skill: Skill;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{skill.name}</div>
{skill.description && (
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{skill.description}
</div>
)}
</div>
{(skill.files?.length ?? 0) > 0 && (
<Badge variant="secondary">
{skill.files.length} file{skill.files.length !== 1 ? "s" : ""}
</Badge>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Helpers: virtual file list for the tree
// ---------------------------------------------------------------------------
const SKILL_MD = "SKILL.md";
/** Merge skill.content (as SKILL.md) + skill.files into a single map */
function buildFileMap(
content: string,
files: { path: string; content: string }[],
): Map<string, string> {
const map = new Map<string, string>();
map.set(SKILL_MD, content);
for (const f of files) {
if (f.path.trim()) map.set(f.path, f.content);
}
return map;
}
// ---------------------------------------------------------------------------
// Add File Dialog
// ---------------------------------------------------------------------------
function AddFileDialog({
existingPaths,
onClose,
onAdd,
}: {
existingPaths: string[];
onClose: () => void;
onAdd: (path: string) => void;
}) {
const [path, setPath] = useState("");
const duplicate = existingPaths.includes(path.trim());
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Add File</DialogTitle>
<DialogDescription className="text-xs">
Add a supporting file to this skill.
</DialogDescription>
</DialogHeader>
<div>
<Label className="text-xs text-muted-foreground">File Path</Label>
<Input
autoFocus
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g. templates/review.md"
className="mt-1 font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim() && !duplicate) {
onAdd(path.trim());
onClose();
}
}}
/>
{duplicate && (
<p className="mt-1 text-xs text-destructive">File already exists</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
disabled={!path.trim() || duplicate}
onClick={() => { onAdd(path.trim()); onClose(); }}
>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Skill Detail — file-browser layout
// ---------------------------------------------------------------------------
function SkillDetail({
skill,
onUpdate,
onDelete,
}: {
skill: Skill;
onUpdate: (id: string, data: UpdateSkillRequest) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description);
const [content, setContent] = useState(skill.content);
const [files, setFiles] = useState<{ path: string; content: string }[]>(
(skill.files ?? []).map((f) => ({ path: f.path, content: f.content })),
);
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
const [saving, setSaving] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [showAddFile, setShowAddFile] = useState(false);
// Sync basic fields from store updates
useEffect(() => {
setName(skill.name);
setDescription(skill.description);
setContent(skill.content);
}, [skill.id, skill.name, skill.description, skill.content]);
// Fetch full skill (with files) on selection change
useEffect(() => {
setSelectedPath(SKILL_MD);
setLoadingFiles(true);
api.getSkill(skill.id).then((full) => {
useWorkspaceStore.getState().upsertSkill(full);
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
}).catch((e) => {
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
}).finally(() => setLoadingFiles(false));
}, [skill.id]);
// Build the virtual file map
const fileMap = useMemo(() => buildFileMap(content, files), [content, files]);
const filePaths = useMemo(() => Array.from(fileMap.keys()), [fileMap]);
const selectedContent = fileMap.get(selectedPath) ?? "";
const isDirty =
name !== skill.name ||
description !== skill.description ||
content !== skill.content ||
JSON.stringify(files) !==
JSON.stringify((skill.files ?? []).map((f) => ({ path: f.path, content: f.content })));
const handleSave = async () => {
setSaving(true);
try {
await onUpdate(skill.id, {
name: name.trim(),
description: description.trim(),
content,
files: files.filter((f) => f.path.trim()),
});
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
};
const handleFileContentChange = (newContent: string) => {
if (selectedPath === SKILL_MD) {
setContent(newContent);
} else {
setFiles((prev) =>
prev.map((f) =>
f.path === selectedPath ? { ...f, content: newContent } : f,
),
);
}
};
const handleAddFile = (path: string) => {
setFiles((prev) => [...prev, { path, content: "" }]);
setSelectedPath(path);
};
const handleDeleteFile = () => {
if (selectedPath === SKILL_MD) return;
setFiles((prev) => prev.filter((f) => f.path !== selectedPath));
setSelectedPath(SKILL_MD);
};
return (
<div className="flex h-full min-h-0 flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<Sparkles className="h-4 w-4 text-muted-foreground" />
</div>
<div className="grid grid-cols-2 gap-3 flex-1 min-w-0">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-8 text-sm font-medium"
placeholder="Skill name"
/>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="h-8 text-sm"
placeholder="Description"
/>
</div>
</div>
<div className="flex items-center gap-2 ml-3">
{isDirty && (
<Button onClick={handleSave} disabled={saving || !name.trim()} size="xs">
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="xs"
onClick={() => setConfirmDelete(true)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
}
/>
<TooltipContent>Delete skill</TooltipContent>
</Tooltip>
</div>
</div>
{/* File browser: tree + viewer */}
<div className="flex flex-1 min-h-0">
{/* File tree */}
<div className="w-52 shrink-0 border-r flex flex-col">
<div className="flex h-10 items-center justify-between border-b px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Files
</span>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowAddFile(true)}
className="text-muted-foreground"
>
<Plus className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Add file</TooltipContent>
</Tooltip>
{selectedPath !== SKILL_MD && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={handleDeleteFile}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loadingFiles ? (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<FileTree
filePaths={filePaths}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
)}
</div>
</div>
{/* File viewer */}
<div className="flex-1 min-w-0">
{loadingFiles ? (
<div className="p-4 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<FileViewer
key={selectedPath}
path={selectedPath}
content={selectedContent}
onChange={handleFileContentChange}
/>
)}
</div>
</div>
{/* Add file dialog */}
{showAddFile && (
<AddFileDialog
existingPaths={filePaths}
onClose={() => setShowAddFile(false)}
onAdd={handleAddFile}
/>
)}
{/* Delete Confirmation */}
{confirmDelete && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">Delete skill?</DialogTitle>
<DialogDescription className="text-xs">
This will permanently delete &quot;{skill.name}&quot; and remove it from all agents.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmDelete(false);
onDelete(skill.id);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function SkillsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const skills = useWorkspaceStore((s) => s.skills);
const refreshSkills = useWorkspaceStore((s) => s.refreshSkills);
const upsertSkill = useWorkspaceStore((s) => s.upsertSkill);
const removeSkill = useWorkspaceStore((s) => s.removeSkill);
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_skills_layout",
});
useEffect(() => {
if (skills.length > 0 && !selectedId) {
setSelectedId(skills[0]!.id);
}
}, [skills, selectedId]);
const handleCreate = async (data: CreateSkillRequest) => {
const skill = await api.createSkill(data);
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill created");
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill imported");
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
try {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
toast.success("Skill saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save skill");
throw e;
}
};
const handleDelete = async (id: string) => {
try {
await api.deleteSkill(id);
if (selectedId === id) {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
removeSkill(id);
toast.success("Skill deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
}
};
const selected = skills.find((s) => s.id === selectedId) ?? null;
if (isLoading) {
return (
<div className="flex flex-1 min-h-0">
{/* List skeleton */}
<div className="w-72 border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-6 rounded" />
</div>
<div className="divide-y">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 flex flex-col">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-56" />
</div>
<div className="flex flex-1 min-h-0">
<div className="w-48 border-r p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex-1 p-4 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
className="flex-1 min-h-0"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
{/* Left column — skill list */}
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Skills</h1>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
}
/>
<TooltipContent side="bottom">Create skill</TooltipContent>
</Tooltip>
</div>
{skills.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No skills yet</p>
<p className="mt-1 text-xs text-muted-foreground text-center">
Skills define reusable instructions for agents.
</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
) : (
<div className="divide-y">
{skills.map((skill) => (
<SkillListItem
key={skill.id}
skill={skill}
isSelected={skill.id === selectedId}
onClick={() => setSelectedId(skill.id)}
/>
))}
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="50%">
{/* Right column — skill detail */}
<div className="flex-1 overflow-hidden h-full">
{selected ? (
<SkillDetail
key={selected.id}
skill={selected}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Sparkles className="h-10 w-10 text-muted-foreground/30" />
<p className="mt-3 text-sm">Select a skill to view details</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Skill
</Button>
</div>
)}
</div>
</ResizablePanel>
{showCreate && (
<CreateSkillDialog
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
onImport={handleImport}
/>
)}
</ResizablePanelGroup>
);
}