diff --git a/apps/web/features/skills/components/file-tree.tsx b/apps/web/features/skills/components/file-tree.tsx
new file mode 100644
index 00000000..a9576b71
--- /dev/null
+++ b/apps/web/features/skills/components/file-tree.tsx
@@ -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 (
+
+
+ {expanded && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ const Icon = getFileIcon(node.name);
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+ );
+ }
+
+ return (
+
+ {tree.map((node) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/features/skills/components/file-viewer.tsx b/apps/web/features/skills/components/file-viewer.tsx
new file mode 100644
index 00000000..226ad583
--- /dev/null
+++ b/apps/web/features/skills/components/file-viewer.tsx
@@ -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 (
+
+
+ {Object.entries(data).map(([key, value]) => (
+
+
+ {key}
+
+ {value}
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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 (
+
+ {/* File header */}
+
+
+ {path}
+
+
+ {isMd && (
+
+ setEditing(!editing)}
+ className="text-muted-foreground"
+ >
+ {editing ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ {editing ? "Preview" : "Edit"}
+
+
+ )}
+
+
+
+ {/* File content */}
+
+ {isMd && !editing ? (
+
+ {frontmatter && }
+
+ {body || "*No content yet*"}
+
+
+ ) : (
+
+
+ );
+}
diff --git a/apps/web/features/skills/components/skills-page.tsx b/apps/web/features/skills/components/skills-page.tsx
index af8eb54f..f6acbe8b 100644
--- a/apps/web/features/skills/components/skills-page.tsx
+++ b/apps/web/features/skills/components/skills-page.tsx
@@ -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 {
+ const map = new Map();
+ 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(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 (
-
-
+
-
- {files.length === 0 ? (
-
-
-
No supporting files
-
- ) : (
-
- {files.map((file, index) => (
-
-
-
- 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"
- />
-
-
- setEditingIndex(editingIndex === index ? null : index)
- }
- className="shrink-0 text-muted-foreground"
- >
-
-
- }
- />
- Edit content
-
-
- removeFile(index)}
- className="shrink-0 text-muted-foreground hover:text-destructive"
- >
-
-
- }
- />
- Remove file
-
-
- {editingIndex === index && (
-
- ))}
-
- )}
-
+
+
+
+
+
+
);
}
// ---------------------------------------------------------------------------
-// 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 (
{/* Header */}
-
-
-
+
+
+
-
-
+
{isDirty && (
- {/* Content */}
-
- {/* Basic Info */}
-
-
-
-
setName(e.target.value)}
- className="mt-1"
- />
+ {/* File browser: tree + viewer */}
+
+ {/* File tree */}
+
+
+
+ Files
+
+
+
+ setShowAddFile(true)}
+ className="text-muted-foreground"
+ >
+
+
+ }
+ />
+ Add file
+
+ {selectedPath !== SKILL_MD && (
+
+
+
+
+ }
+ />
+ Delete file
+
+ )}
+
-
-
-
setDescription(e.target.value)}
- placeholder="Brief description"
- className="mt-1"
+
+
- {/* Content Editor */}
-
-
-
- Main skill instructions in Markdown. This becomes the SKILL.md file in the agent's execution environment.
-
-
+ {/* Add file dialog */}
+ {showAddFile && (
+
setShowAddFile(false)}
+ onAdd={handleAddFile}
+ />
+ )}
+
{/* Delete Confirmation */}
{confirmDelete && (