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:
Jiayuan 2026-03-27 23:54:32 +08:00
parent 6395a74661
commit 8de700620d
3 changed files with 520 additions and 151 deletions

View 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>
);
}

View 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>
);
}

View file

@ -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&apos;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); }}>