feat(skills): replace skill detail with file-browser tree view
Redesign the skill detail panel to use a directory tree + file viewer layout, similar to a file browser. SKILL.md and supporting files are shown in a collapsible tree on the left; selecting a file renders its content on the right with markdown preview (including YAML frontmatter parsing) and an edit toggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6395a74661
commit
8de700620d
3 changed files with 520 additions and 151 deletions
181
apps/web/features/skills/components/file-tree.tsx
Normal file
181
apps/web/features/skills/components/file-tree.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree data structures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: FileTreeNode[];
|
||||
}
|
||||
|
||||
function buildTree(filePaths: string[]): FileTreeNode[] {
|
||||
const root: FileTreeNode[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const parts = filePath.split("/");
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const name = parts[i]!;
|
||||
const isLast = i === parts.length - 1;
|
||||
const path = parts.slice(0, i + 1).join("/");
|
||||
|
||||
let existing = current.find((n) => n.name === name);
|
||||
|
||||
if (!existing) {
|
||||
existing = {
|
||||
name,
|
||||
path,
|
||||
isDirectory: !isLast,
|
||||
children: [],
|
||||
};
|
||||
current.push(existing);
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
current = existing.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.path === "SKILL.md") return -1;
|
||||
if (b.path === "SKILL.md") return 1;
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.isDirectory) sortNodes(node.children);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return sortNodes(root);
|
||||
}
|
||||
|
||||
function getFileIcon(name: string) {
|
||||
if (name.endsWith(".md") || name.endsWith(".mdx")) return FileText;
|
||||
return File;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree node renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TreeNodeItem({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isSelected = node.path === selectedPath;
|
||||
|
||||
if (node.isDirectory) {
|
||||
const FolderIcon = expanded ? FolderOpen : Folder;
|
||||
const ChevronIcon = expanded ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center gap-1.5 py-1 text-left text-xs hover:bg-accent/50 rounded-sm"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<ChevronIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<FolderIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = getFileIcon(node.name);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(node.path)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 py-1 text-left text-xs rounded-sm",
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8 + 16}px` }}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FileTree({
|
||||
filePaths,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}: {
|
||||
filePaths: string[];
|
||||
selectedPath: string;
|
||||
onSelect: (path: string) => void;
|
||||
}) {
|
||||
const tree = buildTree(filePaths);
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<FolderOpen className="h-5 w-5 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs">No files</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1 px-1">
|
||||
{tree.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/web/features/skills/components/file-viewer.tsx
Normal file
156
apps/web/features/skills/components/file-viewer.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Pencil, Eye } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Markdown } from "@/components/markdown/Markdown";
|
||||
|
||||
function isMarkdown(path: string) {
|
||||
return path.endsWith(".md") || path.endsWith(".mdx");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML frontmatter parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Frontmatter {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
||||
|
||||
function parseFrontmatter(raw: string): {
|
||||
frontmatter: Frontmatter | null;
|
||||
body: string;
|
||||
} {
|
||||
const match = FRONTMATTER_RE.exec(raw);
|
||||
if (!match) return { frontmatter: null, body: raw };
|
||||
|
||||
const yamlBlock = match[1]!;
|
||||
const body = raw.slice(match[0].length);
|
||||
const frontmatter: Frontmatter = {};
|
||||
|
||||
for (const line of yamlBlock.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
let value = line.slice(idx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) frontmatter[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FrontmatterCard({ data }: { data: Frontmatter }) {
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<div className="grid gap-1.5">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2 text-xs">
|
||||
<span className="shrink-0 font-medium text-muted-foreground min-w-[80px]">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function FileViewer({
|
||||
path,
|
||||
content,
|
||||
onChange,
|
||||
}: {
|
||||
path: string;
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const isMd = isMarkdown(path);
|
||||
|
||||
const { frontmatter, body } = useMemo(
|
||||
() => (isMd ? parseFrontmatter(content) : { frontmatter: null, body: content }),
|
||||
[content, isMd],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex h-10 items-center justify-between border-b px-4">
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">
|
||||
{path}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isMd && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setEditing(!editing)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{editing ? (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{editing ? "Preview" : "Edit"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isMd && !editing ? (
|
||||
<div className="p-6">
|
||||
{frontmatter && <FrontmatterCard data={frontmatter} />}
|
||||
<Markdown mode="full">
|
||||
{body || "*No content yet*"}
|
||||
</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
isMd
|
||||
? "Write markdown content..."
|
||||
: "File content..."
|
||||
}
|
||||
className="h-full min-h-full resize-none rounded-none border-0 font-mono text-sm leading-relaxed focus-visible:ring-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
AlertCircle,
|
||||
X,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import type { Skill, CreateSkillRequest, UpdateSkillRequest } from "@/shared/types";
|
||||
|
|
@ -30,13 +27,14 @@ import {
|
|||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { FileTree } from "./file-tree";
|
||||
import { FileViewer } from "./file-viewer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Skill Dialog
|
||||
|
|
@ -228,119 +226,85 @@ function SkillListItem({
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Editor
|
||||
// Helpers: virtual file list for the tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileEditor({
|
||||
files,
|
||||
onFilesChange,
|
||||
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,
|
||||
}: {
|
||||
files: { path: string; content: string }[];
|
||||
onFilesChange: (files: { path: string; content: string }[]) => void;
|
||||
existingPaths: string[];
|
||||
onClose: () => void;
|
||||
onAdd: (path: string) => void;
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
const addFile = () => {
|
||||
onFilesChange([...files, { path: "", content: "" }]);
|
||||
setEditingIndex(files.length);
|
||||
};
|
||||
|
||||
const updateFile = (index: number, field: "path" | "content", value: string) => {
|
||||
const updated = files.map((f, i) =>
|
||||
i === index ? { ...f, [field]: value } : f,
|
||||
);
|
||||
onFilesChange(updated);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
onFilesChange(files.filter((_, i) => i !== index));
|
||||
if (editingIndex === index) setEditingIndex(null);
|
||||
};
|
||||
const [path, setPath] = useState("");
|
||||
const duplicate = existingPaths.includes(path.trim());
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<h4 className="text-sm font-medium">Supporting Files</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Templates, scripts, or reference files available to the agent.
|
||||
</p>
|
||||
<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>
|
||||
<Button variant="outline" size="xs" onClick={addFile}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<FolderOpen className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No supporting files</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="rounded-lg border">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={file.path}
|
||||
onChange={(e) => updateFile(index, "path", e.target.value)}
|
||||
placeholder="path/to/file.md"
|
||||
className="h-7 border-0 p-0 text-xs font-mono shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() =>
|
||||
setEditingIndex(editingIndex === index ? null : index)
|
||||
}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Edit content</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeFile(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Remove file</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{editingIndex === index && (
|
||||
<Textarea
|
||||
value={file.content}
|
||||
onChange={(e) => updateFile(index, "content", e.target.value)}
|
||||
placeholder="File content..."
|
||||
className="min-h-32 resize-none rounded-none rounded-b-lg border-0 font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
// Skill Detail — file-browser layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SkillDetail({
|
||||
|
|
@ -358,8 +322,10 @@ function SkillDetail({
|
|||
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 [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [showAddFile, setShowAddFile] = useState(false);
|
||||
|
||||
// Sync basic fields from store updates
|
||||
useEffect(() => {
|
||||
|
|
@ -370,12 +336,18 @@ function SkillDetail({
|
|||
|
||||
// Fetch full skill (with files) on selection change
|
||||
useEffect(() => {
|
||||
setSelectedPath(SKILL_MD);
|
||||
api.getSkill(skill.id).then((full) => {
|
||||
useWorkspaceStore.getState().upsertSkill(full);
|
||||
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
|
||||
});
|
||||
}, [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 ||
|
||||
|
|
@ -397,22 +369,55 @@ function SkillDetail({
|
|||
}
|
||||
};
|
||||
|
||||
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-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<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="min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{skill.name}</h2>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{skill.description}</p>
|
||||
)}
|
||||
<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">
|
||||
<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" />
|
||||
|
|
@ -437,51 +442,78 @@ function SkillDetail({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
{/* 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>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="mt-1"
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<FileTree
|
||||
filePaths={filePaths}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Content (SKILL.md)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
Main skill instructions in Markdown. This becomes the SKILL.md file in the agent's execution environment.
|
||||
</p>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`# Skill Name\n\nDescribe what this skill does and provide instructions.\n\n## Workflow\n1. Step one\n2. Step two\n\n## Rules\n- Rule one\n- Rule two`}
|
||||
className="h-64 resize-none font-mono text-sm leading-relaxed"
|
||||
{/* File viewer */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<FileViewer
|
||||
key={selectedPath}
|
||||
path={selectedPath}
|
||||
content={selectedContent}
|
||||
onChange={handleFileContentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<FileEditor files={files} onFilesChange={setFiles} />
|
||||
</div>
|
||||
|
||||
{/* Add file dialog */}
|
||||
{showAddFile && (
|
||||
<AddFileDialog
|
||||
existingPaths={filePaths}
|
||||
onClose={() => setShowAddFile(false)}
|
||||
onAdd={handleAddFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue