+
+ {skill.name}
+ {!canEdit && (
+
+
+ }
+ />
+
+ Read-only — only creator or admin can edit
+
+
+ )}
+
+
+ {totalFileCount(skill)}
+
+
+
+ {skill.description || "No description"}
+
-
{skill.name}
- {skill.description && (
-
- {skill.description}
-
+
+
+
+ {timeAgo(skill.updated_at)}
+
+
+
+ );
+}
+
+function ListColumnHeader() {
+ return (
+
+ Name
+ Used by
+ Source · Added by
+ Updated
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Scope tab — matches Issues/MyIssues header pattern
+// ---------------------------------------------------------------------------
+
+const SCOPES: { value: FilterKey; label: string; description: string }[] = [
+ { value: "all", label: "All", description: "All skills in this workspace" },
+ { value: "used", label: "In use", description: "Skills assigned to at least one agent" },
+ { value: "unused", label: "Unused", description: "Skills not assigned to any agent" },
+ { value: "mine", label: "Created by me", description: "Skills you created" },
+];
+
+// ---------------------------------------------------------------------------
+// Hero header
+// ---------------------------------------------------------------------------
+
+function HeroHeader({ totalCount }: { totalCount: number }) {
+ return (
+
+
+
+ Skills
+
+ {totalCount > 0 && (
+
+ {totalCount}
+
)}
- {(skill.files?.length ?? 0) > 0 && (
-
- {skill.files.length} file{skill.files.length !== 1 ? "s" : ""}
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// 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
{
- 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,
-}: {
- existingPaths: string[];
- onClose: () => void;
- onAdd: (path: string) => void;
-}) {
- const [path, setPath] = useState("");
- const duplicate = existingPaths.includes(path.trim());
-
- return (
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Skill Detail — file-browser layout
-// ---------------------------------------------------------------------------
-
-function SkillDetail({
- skill,
- onUpdate,
- onDelete,
-}: {
- skill: Skill;
- onUpdate: (id: string, data: UpdateSkillRequest) => Promise;
- onDelete: (id: string) => Promise;
-}) {
- const qc = useQueryClient();
- const wsId = useWorkspaceId();
- 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) => {
- qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
- 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, qc, wsId]);
-
- // 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 (
-
- {/* Header */}
-
-
-
- {isDirty && (
-
- )}
-
- setConfirmDelete(true)}
- className="text-muted-foreground hover:text-destructive"
- >
-
-
- }
- />
- Delete skill
-
-
+
+ Reusable instruction packs that agents load at runtime — your
+ workspace’s shared knowledge for every agent run.
+
+
+
+ Shared with your workspace.
+ {" "}
+ Anyone can create a skill, import one from a URL, or copy one from
+ their local runtime — and every agent can use it.{" "}
+
+ Local runtime skills stay private until you copy one here.
+
+
+ );
+}
- {/* File browser: tree + viewer */}
-
- {/* File tree */}
-
-
-
- Files
-
-
-
- setShowAddFile(true)}
- className="text-muted-foreground"
- >
-
-
- }
- />
- Add file
-
- {selectedPath !== SKILL_MD && (
-
-
-
-
- }
- />
- Delete file
-
- )}
-
-
-
- {loadingFiles ? (
-
-
-
-
-
- ) : (
-
- )}
-
-
+// ---------------------------------------------------------------------------
+// Empty state
+// ---------------------------------------------------------------------------
- {/* File viewer */}
-
- {loadingFiles ? (
-
-
-
-
-
-
-
- ) : (
-
- )}
-
+function EmptyState({ onCreate }: { onCreate: () => void }) {
+ return (
+
+
+
-
- {/* Add file dialog */}
- {showAddFile && (
-
setShowAddFile(false)}
- onAdd={handleAddFile}
- />
- )}
-
- {/* Delete Confirmation */}
- {confirmDelete && (
-
- )}
+ No skills yet
+
+ Create your first skill, import one from a URL, or copy one from a
+ connected runtime — and every agent in the workspace can use it.
+
+
);
}
@@ -641,204 +299,250 @@ function SkillDetail({
// ---------------------------------------------------------------------------
export default function SkillsPage() {
- const qc = useQueryClient();
const wsId = useWorkspaceId();
- const { data: skills = [], isLoading } = useQuery(skillListOptions(wsId));
- const [selectedId, setSelectedId] = useState
("");
- const [showCreate, setShowCreate] = useState(false);
- const { defaultLayout, onLayoutChanged } = useDefaultLayout({
- id: "multica_skills_layout",
- });
+ const paths = useWorkspacePaths();
+ const navigation = useNavigation();
+ const currentUserId = useAuthStore((s) => s.user?.id ?? null);
- useEffect(() => {
- if (skills.length > 0 && !selectedId) {
- setSelectedId(skills[0]!.id);
- }
- }, [skills, selectedId]);
+ const {
+ data: skills = [],
+ isLoading,
+ error: listError,
+ refetch: refetchList,
+ } = useQuery(skillListOptions(wsId));
+ const { data: agents = [], error: agentsError } = useQuery(
+ agentListOptions(wsId),
+ );
+ const { data: members = [], error: membersError } = useQuery(
+ memberListOptions(wsId),
+ );
+ const { data: runtimes = [], error: runtimesError } = useQuery(
+ runtimeListOptions(wsId),
+ );
- const handleCreate = async (data: CreateSkillRequest) => {
- const skill = await api.createSkill(data);
- qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
- setSelectedId(skill.id);
- toast.success("Skill created");
- };
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState("all");
+ const [createOpen, setCreateOpen] = useState(false);
- const handleImport = async (url: string) => {
- const skill = await api.importSkill({ url });
- qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
- setSelectedId(skill.id);
- toast.success("Skill imported");
- };
+ // Derive assignments ONCE per agents-identity. Stable reference across
+ // unrelated agent refetches — see selectSkillAssignments' doc.
+ const assignments = useMemo(
+ () => selectSkillAssignments(agents),
+ [agents],
+ );
- const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
- try {
- await api.updateSkill(id, data);
- qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
- toast.success("Skill saved");
- } catch (e) {
- toast.error(e instanceof Error ? e.message : "Failed to save skill");
- throw e;
- }
- };
+ const membersById = useMemo(() => {
+ const map = new Map();
+ for (const m of members) map.set(m.user_id, m);
+ return map;
+ }, [members]);
- 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 ?? "");
+ const runtimesById = useMemo(() => {
+ const map = new Map();
+ for (const r of runtimes) map.set(r.id, r);
+ return map;
+ }, [runtimes]);
+
+ const myRole =
+ members.find((m) => m.user_id === currentUserId)?.role ?? null;
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ const byAssignment = (s: Skill) =>
+ (assignments.get(s.id)?.length ?? 0) > 0;
+
+ return skills.filter((s) => {
+ if (
+ q &&
+ !s.name.toLowerCase().includes(q) &&
+ !s.description.toLowerCase().includes(q)
+ ) {
+ return false;
}
- qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
- toast.success("Skill deleted");
- } catch (e) {
- toast.error(e instanceof Error ? e.message : "Failed to delete skill");
- }
+ if (filter === "used" && !byAssignment(s)) return false;
+ if (filter === "unused" && byAssignment(s)) return false;
+ if (filter === "mine" && s.created_by !== currentUserId) return false;
+ return true;
+ });
+ }, [skills, assignments, search, filter, currentUserId]);
+
+ const handleCreated = (skill: Skill) => {
+ navigation.push(paths.skillDetail(skill.id));
};
- const selected = skills.find((s) => s.id === selectedId) ?? null;
-
+ // --- Loading ---
if (isLoading) {
return (
-
- {/* List skeleton */}
-
-
-
-
-
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
+
+
+
+
+
+
- {/* Detail skeleton */}
-
-
-
-
-
-
-
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
);
}
+ // --- List request error ---
+ if (listError) {
+ return (
+
+
+
+
+
+
Couldn’t load skills
+
+ {listError instanceof Error
+ ? listError.message
+ : "Something went wrong fetching the skill list."}
+
+
+
+
+
+ );
+ }
+
+ const totalCount = skills.length;
+ const showEmpty = totalCount === 0;
+ const supportingQueryDown =
+ !!agentsError || !!membersError || !!runtimesError;
+
return (
-
-
- {/* Left column — skill list */}
-
-
- Skills
-
+
+
+
+ {/* Non-blocking banner when supporting queries fail — list still renders
+ but creator/runtime/permission attribution is incomplete. */}
+ {supportingQueryDown && (
+
+
+
+ Some workspace data failed to load. Creator attribution, runtime
+ names, or edit permissions may appear incomplete.
+
+
+ )}
+
+ {/* Toolbar */}
+ {!showEmpty && (
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search skills…"
+ className="h-8 w-64 pl-8 text-sm"
+ />
+
+ {SCOPES.map((s) => (
+
setShowCreate(true)}
+ variant="outline"
+ size="sm"
+ className={
+ filter === s.value
+ ? "bg-accent text-accent-foreground hover:bg-accent/80"
+ : "text-muted-foreground"
+ }
+ onClick={() => setFilter(s.value)}
>
-
+ {s.label}
}
/>
- Add skill
+ {s.description}
-
- {skills.length === 0 ? (
-
-
-
No workspace skills yet
-
- Workspace skills are shared across your team and injected into agent runs. Skills already installed in your local runtime are used automatically.
-
-
-
- ) : (
-
- {skills.map((skill) => (
-
+
+
+
+ )}
+
+ {/* Body */}
+ {showEmpty ? (
+
setCreateOpen(true)} />
+ ) : filtered.length === 0 ? (
+
+
+
No matches
+
+ {search
+ ? `No skills match "${search}"${filter !== "all" ? " in this filter" : ""}.`
+ : "No skills match this filter."}{" "}
+ Try a different query.
+
+
+ ) : (
+
+
+
+ {filtered.map((skill) => {
+ const origin = readOrigin(skill);
+ const runtime =
+ origin.type === "runtime_local" && origin.runtime_id
+ ? runtimesById.get(origin.runtime_id) ?? null
+ : null;
+ return (
+ setSelectedId(skill.id)}
+ agents={assignments.get(skill.id) ?? []}
+ creator={
+ skill.created_by
+ ? membersById.get(skill.created_by) ?? null
+ : null
+ }
+ runtime={runtime}
+ canEdit={canEditSkill(skill, {
+ userId: currentUserId,
+ role: myRole,
+ })}
+ href={paths.skillDetail(skill.id)}
/>
- ))}
-
- )}
+ );
+ })}
+
-
-
-
-
-
- {/* Right column — skill detail */}
-
- {selected ? (
-
- ) : (
-
-
-
Select a skill to view details
-
- Workspace skills supplement your local skills and are shared across the team.
-
-
-
- )}
-
-
-
- {showCreate && (
- setShowCreate(false)}
- onCreate={handleCreate}
- onImport={handleImport}
- onRuntimeImported={(skill) => setSelectedId(skill.id)}
- />
)}
-
+
+ setCreateOpen(false)}
+ onCreated={handleCreated}
+ />
+
);
}
diff --git a/packages/views/skills/hooks/use-can-edit-skill.test.ts b/packages/views/skills/hooks/use-can-edit-skill.test.ts
new file mode 100644
index 000000000..adbe79df1
--- /dev/null
+++ b/packages/views/skills/hooks/use-can-edit-skill.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from "vitest";
+import type { Skill } from "@multica/core/types";
+import { canEditSkill } from "./use-can-edit-skill";
+
+function makeSkill(createdBy: string | null): Skill {
+ return {
+ id: "skl_x",
+ workspace_id: "ws_1",
+ name: "x",
+ description: "",
+ content: "",
+ config: {},
+ files: [],
+ created_by: createdBy,
+ created_at: "2026-04-01T00:00:00Z",
+ updated_at: "2026-04-01T00:00:00Z",
+ };
+}
+
+describe("canEditSkill", () => {
+ const skill = makeSkill("user-alice");
+
+ it("allows workspace owners to edit any skill", () => {
+ expect(
+ canEditSkill(skill, { userId: "user-bob", role: "owner" }),
+ ).toBe(true);
+ });
+
+ it("allows workspace admins to edit any skill", () => {
+ expect(
+ canEditSkill(skill, { userId: "user-bob", role: "admin" }),
+ ).toBe(true);
+ });
+
+ it("allows the creator to edit their own skill", () => {
+ expect(
+ canEditSkill(skill, { userId: "user-alice", role: "member" }),
+ ).toBe(true);
+ });
+
+ it("denies non-creator members", () => {
+ expect(
+ canEditSkill(skill, { userId: "user-bob", role: "member" }),
+ ).toBe(false);
+ });
+
+ it("denies unknown-role users even if they match created_by", () => {
+ // role=null models a member list that hasn't loaded yet or a user who
+ // isn't a member at all; we still honor created_by identity.
+ expect(
+ canEditSkill(skill, { userId: "user-alice", role: null }),
+ ).toBe(true);
+ });
+
+ it("denies when created_by is null (legacy / system-created)", () => {
+ expect(
+ canEditSkill(makeSkill(null), { userId: "user-alice", role: "member" }),
+ ).toBe(false);
+ });
+
+ it("denies when userId is null (logged-out edge case)", () => {
+ expect(
+ canEditSkill(skill, { userId: null, role: "member" }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/views/skills/hooks/use-can-edit-skill.ts b/packages/views/skills/hooks/use-can-edit-skill.ts
new file mode 100644
index 000000000..c07f44565
--- /dev/null
+++ b/packages/views/skills/hooks/use-can-edit-skill.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import type { MemberRole, Skill } from "@multica/core/types";
+import { useAuthStore } from "@multica/core/auth";
+import { memberListOptions } from "@multica/core/workspace/queries";
+
+/**
+ * Whether the current user may edit/delete the given skill.
+ *
+ * Rule: workspace admins & owners can edit any skill; everyone else can only
+ * edit skills they created. Server enforces this independently; the hook
+ * mirrors it so the UI can hide/disable actions instead of waiting for a 403.
+ *
+ * `wsId` is explicit (not read from `WorkspaceIdProvider`) so this hook stays
+ * usable in components that render before workspace context is wired, and so
+ * the scope of the permission check is always obvious to the caller. Matches
+ * the repo rule for workspace-aware hooks.
+ */
+export function useCanEditSkill(
+ skill: Skill | null | undefined,
+ wsId: string,
+): boolean {
+ const userId = useAuthStore((s) => s.user?.id ?? null);
+ const { data: members = [] } = useQuery(memberListOptions(wsId));
+
+ if (!skill) return false;
+ const myRole = members.find((m) => m.user_id === userId)?.role ?? null;
+ return canEditSkill(skill, { userId, role: myRole });
+}
+
+/**
+ * Non-hook variant for places that already have the role + userId at hand
+ * (e.g. list rows that compute role once for the whole page).
+ */
+export function canEditSkill(
+ skill: Skill,
+ opts: { userId: string | null; role: MemberRole | null },
+): boolean {
+ if (opts.role === "admin" || opts.role === "owner") return true;
+ return skill.created_by === opts.userId;
+}
diff --git a/packages/views/skills/index.ts b/packages/views/skills/index.ts
index 3ee589c07..51342b519 100644
--- a/packages/views/skills/index.ts
+++ b/packages/views/skills/index.ts
@@ -1 +1 @@
-export { SkillsPage } from "./components";
+export { SkillsPage, SkillDetailPage } from "./components";
diff --git a/packages/views/skills/lib/origin.ts b/packages/views/skills/lib/origin.ts
new file mode 100644
index 000000000..f0273ae88
--- /dev/null
+++ b/packages/views/skills/lib/origin.ts
@@ -0,0 +1,35 @@
+import type { Skill } from "@multica/core/types";
+
+/**
+ * Discriminated view over `Skill.config.origin` — the JSONB blob the backend
+ * writes when a skill was imported from outside (local runtime, ClawHub,
+ * Skills.sh). Manual creates have no origin, so we synthesize `{ type:
+ * "manual" }` for them to keep the consumer code uniform.
+ *
+ * NOTE: the backend currently only writes `runtime_local` origins. URL
+ * imports leave `config.origin` empty, so `clawhub`/`skills_sh` variants are
+ * declared here for forward compatibility but should never be rendered in
+ * the UI until the server fills them in.
+ */
+export type OriginInfo = {
+ type: "runtime_local" | "clawhub" | "skills_sh" | "manual";
+ provider?: string;
+ runtime_id?: string;
+ source_path?: string;
+ source_url?: string;
+};
+
+export function readOrigin(skill: Skill): OriginInfo {
+ const raw = (skill.config?.origin ?? null) as
+ | (OriginInfo & Record)
+ | null;
+ if (raw?.type === "runtime_local") return raw;
+ if (raw?.type === "clawhub") return raw;
+ if (raw?.type === "skills_sh") return raw;
+ return { type: "manual" };
+}
+
+/** SKILL.md is always present plus any additional attached files. */
+export function totalFileCount(skill: Skill): number {
+ return (skill.files?.length ?? 0) + 1;
+}