From 06ed55f18291884584a09ce996848cb616e1ee56 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:33:48 +0800 Subject: [PATCH] feat(skills): rework detail page into overview/files tabs - tabs directly under the breadcrumb header: overview (default) and files - overview: identity block + rendered SKILL.md as the main column, right rail with metadata card (source/creator/updated, inline name+description edit toggle) and used-by panel with bind/unbind - files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here - header kebab menu (copy skill ID, delete); page-level save bar shared by both tabs; tab state persisted in ?tab= - file tree: ARIA tree roles + roving-tabindex keyboard navigation - drop the old right sidebar (metadata dl, permissions paragraph) Co-Authored-By: Claude Fable 5 --- .../views/skills/components/file-tree.tsx | 149 ++- .../skills/components/skill-detail-page.tsx | 889 +++++++++++------- 2 files changed, 663 insertions(+), 375 deletions(-) diff --git a/packages/views/skills/components/file-tree.tsx b/packages/views/skills/components/file-tree.tsx index 79f2b988c..737334106 100644 --- a/packages/views/skills/components/file-tree.tsx +++ b/packages/views/skills/components/file-tree.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { ChevronRight, ChevronDown, @@ -78,21 +78,31 @@ function getFileIcon(name: string) { // Tree node renderer // --------------------------------------------------------------------------- +interface TreeItemContext { + selectedPath: string; + focusPath: string; + collapsed: ReadonlySet; + onSelect: (path: string) => void; + onToggleDir: (path: string) => void; + onFocusItem: (path: string) => void; + registerItem: (path: string, el: HTMLButtonElement | null) => void; +} + function TreeNodeItem({ node, - selectedPath, - onSelect, + ctx, depth = 0, }: { node: FileTreeNode; - selectedPath: string; - onSelect: (path: string) => void; + ctx: TreeItemContext; depth?: number; }) { - const [expanded, setExpanded] = useState(true); - const isSelected = node.path === selectedPath; + const isSelected = node.path === ctx.selectedPath; + // Roving tabindex: exactly one item in the tree is tabbable. + const tabIndex = node.path === ctx.focusPath ? 0 : -1; if (node.isDirectory) { + const expanded = !ctx.collapsed.has(node.path); const FolderIcon = expanded ? FolderOpen : Folder; const ChevronIcon = expanded ? ChevronDown : ChevronRight; @@ -100,7 +110,13 @@ function TreeNodeItem({
{expanded && ( -
+
{node.children.map((child) => ( ))} @@ -130,7 +145,12 @@ function TreeNodeItem({ return ( + } + /> + {t(($) => $.actions.add_to_agent)} + + } + /> + {agents.length === 0 ? ( +
+ {t(($) => $.detail.overview.used_by_empty)}
+ ) : ( +
    + {agents.map((a) => ( +
  • + +
    +
    {a.name}
    + {a.description && ( +
    + {a.description} +
    + )} +
    + {canManage(a) && ( + + )} +
  • + ))} +
)} - {origin.source_path && ( -
- {origin.source_path} -
- )} - {origin.source_url && ( -
- {origin.source_url} -
- )} - {origin.provider && ( -
- {t(($) => $.detail.origin_card.provider, { provider: origin.provider })} -
- )} -
+ + ); } @@ -249,6 +336,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { const qc = useQueryClient(); const paths = useWorkspacePaths(); const navigation = useNavigation(); + const currentUserId = useAuthStore((s) => s.user?.id ?? null); const { data: skill, @@ -283,6 +371,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { const [confirmDelete, setConfirmDelete] = useState(false); const [addingFile, setAddingFile] = useState(false); const [conflictPending, setConflictPending] = useState(false); + const [editingMeta, setEditingMeta] = useState(false); const draftRef = useRef({ name, description, content, files }); draftRef.current = { name, description, content, files }; @@ -340,11 +429,23 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { [members, skill?.created_by], ); + const myRole = useMemo( + () => + members.find((m) => m.user_id === currentUserId)?.role ?? null, + [members, currentUserId], + ); + const isAdmin = myRole === "owner" || myRole === "admin"; + + const actionsCtx = useMemo( + () => ({ wsId, agents, currentUserId, isAdmin }), + [wsId, agents, currentUserId, isAdmin], + ); + const origin = useMemo( () => (skill ? readOrigin(skill) : null), [skill], ); - const originRuntime = useMemo(() => { + const originRuntime = useMemo(() => { if (!origin || origin.type !== "runtime_local" || !origin.runtime_id) return null; return runtimes.find((r) => r.id === origin.runtime_id) ?? null; @@ -385,6 +486,13 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { ); }, [skill, name, description, content, files]); + // Preview renders the draft body so what you see is what will be saved; + // frontmatter stays a Files-tab concern. + const previewBody = useMemo( + () => parseFrontmatter(content).body.trim(), + [content], + ); + const seedFromSkill = (s: Skill) => { setName(s.name); setDescription(s.description); @@ -418,6 +526,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { seedFromSkill(updated); seededKeyRef.current = `${wsId}:${updated.id}@${updated.updated_at}`; setConflictPending(false); + setEditingMeta(false); qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId), exact: true, @@ -436,6 +545,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { seedFromSkill(skill); seededKeyRef.current = `${wsId}:${skill.id}@${skill.updated_at}`; setConflictPending(false); + setEditingMeta(false); }; const handleDelete = async () => { @@ -459,6 +569,16 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { } }; + const handleCopyId = async () => { + if (!skill) return; + try { + await navigator.clipboard.writeText(skill.id); + toast.success(t(($) => $.detail.menu.copied_toast)); + } catch { + toast.error(t(($) => $.detail.menu.copy_failed_toast)); + } + }; + const handleAddFile = (path: string) => { setFiles((prev) => [...prev, { path, content: "" }]); setSelectedPath(path); @@ -484,6 +604,21 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { } }; + // Tab state lives in the URL (like settings) so links land on a specific + // tab and switches survive reload; replace keeps history clean. + const rawTab = navigation.searchParams.get(TAB_QUERY_KEY); + const activeTab: DetailTab = rawTab === "files" ? "files" : "overview"; + const handleTabChange = (next: string) => { + const params = new URLSearchParams(navigation.searchParams); + params.set(TAB_QUERY_KEY, next); + navigation.replace(`${navigation.pathname}?${params.toString()}`); + }; + + const handleEditContent = () => { + setSelectedPath(SKILL_MD); + handleTabChange("files"); + }; + const supportingQueryDown = !!agentsError || !!membersError || !!runtimesError; @@ -535,7 +670,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { ); } - // --- Sub-line metadata for the header --- + // --- Source row content --- const originLabel = (() => { if (!origin) return null; if (origin.type === "runtime_local") { @@ -550,6 +685,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { if (origin.type === "github") return t(($) => $.detail.subline.origin_github); return t(($) => $.detail.subline.origin_workspace); })(); + const originDetail = origin?.source_url ?? origin?.source_path ?? null; return (
@@ -568,24 +704,38 @@ export function SkillDetailPage({ skillId }: { skillId: string }) { {t(($) => $.detail.read_only)} )} - {canEdit && ( - - + $.detail.menu.aria)} + > + + + } + /> + + + + {t(($) => $.detail.menu.copy_id)} + + {canEdit && ( + <> + + setConfirmDelete(true)} - className="text-muted-foreground hover:text-destructive" - aria-label={t(($) => $.detail.delete_aria)} > - - - } - /> - {t(($) => $.detail.delete_tooltip)} - - )} + + {t(($) => $.actions.delete)} + + + )} + + } /> @@ -610,289 +760,318 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
)} - {/* Body: file tree | editor | sidebar */} -
- {/* File tree */} - +
+ {t(($) => $.detail.conflict_banner.body)} +
+
+
+ )} - {/* Editor */} -
- {/* Name + description + subline */} -
- setName(e.target.value)} - placeholder={t(($) => $.detail.name_placeholder)} - className="h-9 border-0 bg-transparent px-0 text-lg font-semibold shadow-none focus-visible:ring-0 read-only:cursor-default dark:bg-transparent" - aria-label={t(($) => $.detail.name_aria)} - /> -
- -