From 57ad1dafd51acf4a9c6b34534b63e9275ef1ae4c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 12 Jun 2026 15:27:03 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20feat(skills):=20redesign=20agen?= =?UTF-8?q?t=20skills=20page=20with=20unified=20inspector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - Rebuild the agent/project skills page around the approved D5 layout: installed/discover tabs, four scope groups (project/user/agent/system), a unified bordered container with an inline detail inspector, large file viewer dialog, and a ⌘K install dialog (ClawHub search + ZIP upload). - Add a row-based discover tab backed by a mock catalog (TODO(discover)). - Rework the inspector settings tab: ToggleGroup status, editable description + model-invocation switch, and an AlertDialog-confirmed delete. Why: - The old page did not surface skills by scope or expose install/discover flows; the new layout matches app master-detail conventions. How: - Mutually exclusive desktop inspector vs mobile Sheet (useIsMobile) so the Sheet backdrop no longer blocks desktop clicks. - Relative timestamps via lib/time; localized status labels; en+zh i18n keys. - Route search params gain a `tab` field for installed/discover. Refs: skills page redesign --- .../sessions/pages/SkillsDiscover.tsx | 130 ++ .../sessions/pages/SkillsListPage.tsx | 1770 +++++++---------- .../sessions/skills/skill-mock-discover.ts | 60 + web/src/lib/i18n/messages.ts | 107 + .../projects.$projectId/skills/index.tsx | 2 + .../_app/agents.$agentId/skills/index.tsx | 2 + 6 files changed, 1000 insertions(+), 1071 deletions(-) create mode 100644 web/src/features/sessions/pages/SkillsDiscover.tsx create mode 100644 web/src/features/sessions/skills/skill-mock-discover.ts diff --git a/web/src/features/sessions/pages/SkillsDiscover.tsx b/web/src/features/sessions/pages/SkillsDiscover.tsx new file mode 100644 index 000000000..38b7b88e3 --- /dev/null +++ b/web/src/features/sessions/pages/SkillsDiscover.tsx @@ -0,0 +1,130 @@ +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, Download, Info, Search } from "lucide-react"; +import { useToast, ToastContainer } from "@/hooks/use-toast"; +import { installAgentSkill } from "@/lib/api-client/sdk.gen"; +import { useI18n } from "@/lib/i18n"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + MOCK_DISCOVER_SKILLS, + type DiscoverSkill, +} from "@/features/sessions/skills/skill-mock-discover"; + +function formatInstalls(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n); +} + +export function SkillsDiscover({ + agentId, + installedNames, +}: { + agentId: string; + installedNames: Set; +}) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + const { toasts, showToast } = useToast(); + const [query, setQuery] = useState(""); + const [installingSlug, setInstallingSlug] = useState(null); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + return MOCK_DISCOVER_SKILLS.filter( + (s) => !q || s.name.toLowerCase().includes(q) || s.summary.toLowerCase().includes(q), + ); + }, [query]); + + async function install(skill: DiscoverSkill) { + setInstallingSlug(skill.slug); + try { + await installAgentSkill({ + path: { id: agentId }, + body: { source: `clawhub:${skill.slug}`, scope: "user" }, + throwOnError: true, + }); + showToast(t("sessions.discover.installSuccess"), "success"); + void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] }); + } catch (error) { + showToast(error instanceof Error ? error.message : t("common.error"), "error"); + } finally { + setInstallingSlug(null); + } + } + + return ( +
+
+ + {t("sessions.discover.previewNote")} +
+
+ + setQuery((e.target as HTMLInputElement).value)} + placeholder={t("sessions.discover.searchPlaceholder")} + className="pl-9" + /> +
+
+ {rows.map((skill) => { + const installed = installedNames.has(skill.name); + return ( +
+
+ {skill.name} + + v{skill.version} + +

+ {skill.summary} +

+
+

+ {skill.summary} +

+
+ + + {t("sessions.discover.installs", { n: formatInstalls(skill.installs) })} + + {installed ? ( + + + {t("sessions.discover.installed")} + + ) : ( + + )} +
+
+ ); + })} + {rows.length === 0 && ( +
+ {t("sessions.discover.noResults")} +
+ )} +
+ +
+ ); +} diff --git a/web/src/features/sessions/pages/SkillsListPage.tsx b/web/src/features/sessions/pages/SkillsListPage.tsx index 3de27ac80..ba2661a51 100644 --- a/web/src/features/sessions/pages/SkillsListPage.tsx +++ b/web/src/features/sessions/pages/SkillsListPage.tsx @@ -1,90 +1,71 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronRight, Copy, FileText, Lock, Plus, Search, Upload } from "lucide-react"; +import { useToast, ToastContainer } from "@/hooks/use-toast"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useAppShell } from "@/layouts/AppShell"; import { agentSkillsOptions } from "@/lib/queries/agents"; import { meQueryOptions } from "@/lib/queries/me"; -import { cn } from "@/lib/utils"; import { useI18n } from "@/lib/i18n"; -import { useAppShell } from "@/layouts/AppShell"; -import { Button } from "@/components/ui/button"; +import { formatTime } from "@/lib/time"; +import { cn } from "@/lib/utils"; +import type { Skill, SkillSearchResult } from "@/lib/types"; +import { + createAgentSkill, + deleteAgentSkill, + getAgentSkill, + getAgentSkillFile, + installAgentSkill, + searchSkills, + updateAgentSkill, + uploadAgentSkill, +} from "@/lib/api-client/sdk.gen"; +import { SkillFilePreview } from "@/features/sessions/SkillFilePreview"; +import { SkillsDiscover } from "@/features/sessions/pages/SkillsDiscover"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Switch } from "@/components/ui/switch"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Spinner } from "@/components/ui/spinner"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { SkillFilePreview } from "@/features/sessions/SkillFilePreview"; import { Dialog, - DialogPopup, - DialogTitle, - DialogHeader, DialogDescription, + DialogHeader, DialogPanel, + DialogPopup, + DialogTitle, } from "@/components/ui/dialog"; -import { - installAgentSkill, - searchSkills as sdkSearchSkills, - uploadAgentSkill, -} from "@/lib/api-client/sdk.gen"; -import { - createAgentSkill, - deleteAgentSkill, - getAgentSkill, - getAgentSkillFile, - updateAgentSkill, -} from "@/lib/api-client"; -import { ChevronRight, Plus, Code2, Cpu, Terminal, User, Bot, Upload, Search } from "lucide-react"; -import type { Skill, SkillSearchResult } from "@/lib/types"; +import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; +import { Sheet, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; + +type Scope = "project" | "user" | "agent" | "system"; +type Tab = "installed" | "discover"; +const SCOPES: Scope[] = ["project", "user", "agent", "system"]; +const WRITABLE = new Set(["user", "agent"]); -interface SkillSectionProps { - title: string; - description?: string; - count?: number; - defaultOpen?: boolean; - action?: React.ReactNode; - children: React.ReactNode; +function route(projectId?: string) { + return projectId ? "/agents/$agentId/projects/$projectId/skills" : "/agents/$agentId/skills"; } -function SkillSection({ - title, - description, - count, - defaultOpen = false, - action, - children, -}: SkillSectionProps) { - return ( - -
-
- - -
- - {title} - {count != null && count > 0 && ( - - ({count}) - - )} - - {description && ( - - — {description} - - )} -
-
- {action &&
{action}
} -
-
- -
{children}
-
-
- ); +function statusLabelKey(status?: string) { + if (status === "draft") return "sessions.skillsList.statusDraft" as const; + if (status === "deprecated") return "sessions.skillsList.statusDeprecated" as const; + return "sessions.skillsList.statusActive" as const; } export function SkillsListPage() { @@ -92,1087 +73,681 @@ export function SkillsListPage() { agentId: string; projectId?: string; }; - const navigate = useNavigate(); - const { t } = useI18n(); - const { setHeaderActions } = useAppShell(); - const { data: skills = [], isLoading, refetch } = useQuery(agentSkillsOptions(agentId)); - const search = useSearch({ strict: false }) as { - new?: boolean; + tab?: Tab; expand?: string; - scope?: string; + scope?: Scope; + new?: boolean; }; - - const skillsRoute = projectId - ? "/agents/$agentId/projects/$projectId/skills" - : "/agents/$agentId/skills"; - const routeParams = projectId ? { agentId, projectId } : { agentId }; - - const [isNewDialogOpen, setIsNewDialogOpen] = useState(search.new === true); - - // Expanded skill key is `scope:name` - const [expandedSkillId, setExpandedSkillId] = useState( - search.expand ? `${search.scope || "user"}:${search.expand}` : null, - ); - - // Sync state from query parameters if they change - useEffect(() => { - if (search.new) { - setIsNewDialogOpen(true); - } - }, [search.new]); - - useEffect(() => { - if (search.expand) { - setExpandedSkillId(`${search.scope || "user"}:${search.expand}`); - } - }, [search.expand, search.scope]); + const navigate = useNavigate(); + const { t } = useI18n(); + const isMobile = useIsMobile(); + const { setHeaderActions } = useAppShell(); + const { data: skills = [], isLoading } = useQuery(agentSkillsOptions(agentId)); + const [query, setQuery] = useState(""); + const [scopeFilter, setScopeFilter] = useState("all"); + const [installOpen, setInstallOpen] = useState(Boolean(search.new)); + const [createOpen, setCreateOpen] = useState(false); + const activeTab = search.tab === "discover" ? "discover" : "installed"; + const params = projectId ? { agentId, projectId } : { agentId }; + const selected = + search.expand && search.scope + ? skills.find((s) => s.name === search.expand && s.scope === search.scope) + : undefined; useEffect(() => { setHeaderActions(null); - return () => { - setHeaderActions(null); - }; + return () => setHeaderActions(null); }, [setHeaderActions]); - - const systemSkills = skills.filter((s) => s.scope === "system"); - const agentSkills = skills.filter((s) => s.scope === "agent"); - const userSkills = skills.filter((s) => s.scope === "user"); - - const handleToggleExpand = (key: string) => { - setExpandedSkillId((current) => { - const next = current === key ? null : key; - if (next === null) { - void navigate({ - to: skillsRoute, - params: routeParams, - search: {}, - replace: true, - }); - } else { - const [scope, skillId] = next.split(":"); - void navigate({ - to: skillsRoute, - params: routeParams, - search: { expand: skillId, scope }, - replace: true, - }); + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setInstallOpen(true); } - return next; - }); - }; - - const handleCloseDialog = () => { - setIsNewDialogOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return skills.filter( + (s) => + (scopeFilter === "all" || s.scope === scopeFilter) && + (!q || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q)), + ); + }, [skills, query, scopeFilter]); + const counts = Object.fromEntries( + SCOPES.map((scope) => [scope, skills.filter((s) => s.scope === scope).length]), + ) as Record; + const callable = skills.filter((s) => !s.disable_model_invocation).length; + const readonly = skills.filter((s) => !WRITABLE.has(s.scope as Scope)).length; + const installedNames = new Set(skills.map((s) => s.name)); + + function setTab(tab: string) { void navigate({ - to: skillsRoute, - params: routeParams, - search: {}, + to: route(projectId), + params, + search: tab === "discover" ? { tab: "discover" } : {}, replace: true, }); - }; - - const handleOpenDialog = () => { - setIsNewDialogOpen(true); + } + function selectSkill(skill?: Skill) { void navigate({ - to: skillsRoute, - params: routeParams, - search: { new: true }, + to: route(projectId), + params, + search: skill + ? { + tab: activeTab === "discover" ? "discover" : undefined, + expand: skill.name, + scope: skill.scope, + } + : activeTab === "discover" + ? { tab: "discover" } + : {}, replace: true, }); - }; - - const handleRefresh = () => { - void refetch(); - }; + } return (
- {isLoading ? ( -
-
-
- ) : ( -
- {/* User Skills Section */} - - - {t("sessions.skill.newSkill")} +
+ +
+ + + {t("sessions.skillsList.installedTab")} ({skills.length}) + + {t("sessions.skillsList.discoverTab")} + +
+ - } - > - {userSkills.length === 0 ? ( -
-

- {t("sessions.skillsList.noSkills")} -

+ +
+
+
+
+
+ + setQuery((e.target as HTMLInputElement).value)} + placeholder={t("sessions.skillsList.searchPlaceholder")} + className="pl-9" + /> +
+ {(["all", ...SCOPES] as const).map((scope) => ( + ))} +
+

+ {t("sessions.skillsList.stats", { total: skills.length, callable, readonly })} +

+
+ +
+
+ {isLoading ? ( +
+ +
+ ) : ( + SCOPES.map((scope) => ({ + scope, + items: filtered.filter((s) => s.scope === scope), + })) + .filter( + ({ scope, items }) => + (scopeFilter === "all" || scopeFilter === scope) && + (items.length > 0 || (scope === "user" && !query.trim())), + ) + .map(({ scope, items }) => ( + setCreateOpen(true) : undefined} + /> + )) + )}
- ) : ( -
- {userSkills.map((s) => { - const key = `user:${s.name}`; - return ( - handleToggleExpand(key)} - onSaved={handleRefresh} - onDeleted={handleRefresh} - /> - ); - })} -
- )} - - - {/* Agent Skills Section */} - - {agentSkills.length === 0 ? ( -

- No agent-specific skills installed. -

- ) : ( -
- {agentSkills.map((s) => { - const key = `agent:${s.name}`; - return ( - handleToggleExpand(key)} - onSaved={handleRefresh} - onDeleted={handleRefresh} - /> - ); - })} -
- )} -
+ {selected && !isMobile && ( +
+ selectSkill()} + /> +
+ )} +
+
+ + + + +
+ {selected && isMobile && ( + !open && selectSkill()}> + + selectSkill()} + /> + + + )} + + + +
+ ); +} - {/* System Skills Section */} - - {systemSkills.length === 0 ? ( -

No system skills.

- ) : ( -
- {systemSkills.map((s) => { - const key = `system:${s.name}`; - return ( - handleToggleExpand(key)} - onSaved={handleRefresh} - onDeleted={handleRefresh} - /> - ); - })} +function SkillGroup({ + scope, + skills, + selected, + defaultOpen, + onSelect, + onCreate, +}: { + scope: Scope; + skills: Skill[]; + selected?: Skill; + defaultOpen: boolean; + onSelect: (skill: Skill) => void; + onCreate?: () => void; +}) { + const { t } = useI18n(); + return ( + +
+ + + {t(`sessions.skillsList.${scope}`)} · {skills.length} + {!WRITABLE.has(scope) ? ` · ${t("sessions.skillsList.readonly")}` : ""} + + {onCreate && ( + + )} +
+ +
+ {skills.map((skill) => ( + + ))} + {skills.length === 0 && ( +

+ {t("sessions.skillsList.noSkills")} +

+ )}
- )} - - {/* Dialog for installing / creating skill */} - -
+ + ); } -function SkillDetailRow({ +function SkillInspector({ agentId, skill, - isExpanded, - onToggle, - onSaved, - onDeleted, + sheet, + onClose, }: { agentId: string; skill: Skill; - isExpanded: boolean; - onToggle: () => void; - onSaved: () => void; - onDeleted: () => void; + onClose?: () => void; + sheet?: boolean; }) { const { t } = useI18n(); const queryClient = useQueryClient(); - const [isEditing, setIsEditing] = useState(false); - const [activeFile, setActiveFile] = useState("SKILL.md"); - const [fileLoading, setFileLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [deleting, setDeleting] = useState(false); - - const [form, setForm] = useState({ - description: "", - status: "active" as "active" | "draft" | "deprecated", - disable_model_invocation: false, - content: "", + const { showToast } = useToast(); + const [tab, setTab] = useState("overview"); + const [description, setDescription] = useState(skill.description ?? ""); + const [status, setStatus] = useState(skill.status ?? "active"); + const [modelEnabled, setModelEnabled] = useState(!skill.disable_model_invocation); + const [viewer, setViewer] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const readOnly = !WRITABLE.has(skill.scope as Scope); + const detail = useQuery({ + queryKey: ["agent-skill", agentId, skill.scope, skill.name], + queryFn: async () => + ( + await getAgentSkill({ + path: { id: agentId, skillId: skill.name }, + query: { scope: skill.scope as Scope }, + throwOnError: true, + }) + ).data as Skill, }); - - const [savedForm, setSavedForm] = useState({ - description: "", - status: "active" as "active" | "draft" | "deprecated", - disable_model_invocation: false, - content: "", - }); - - const { data: detail, isLoading: detailLoading } = useQuery({ - queryKey: ["agent-skill-detail", agentId, skill.scope, skill.name], - queryFn: async () => { - const { data: sk } = await getAgentSkill({ - path: { id: agentId, skillId: skill.name }, - query: { scope: skill.scope as any }, - throwOnError: true, - }); - - const skillFiles = sk.files?.length ? sk.files : ["SKILL.md"]; - const initialFile = skillFiles.includes("SKILL.md") ? "SKILL.md" : skillFiles[0]; - - const res = await getAgentSkillFile({ - path: { id: agentId, skillId: skill.name }, - query: { path: initialFile, scope: skill.scope as any }, - throwOnError: true, - }).catch(() => null); - - const content = (res?.data as { content?: string })?.content ?? ""; - - const initialForm = { - description: sk.description ?? "", - status: (sk.status as any) ?? "active", - disable_model_invocation: sk.disable_model_invocation ?? false, - content, - }; - - setForm(initialForm); - setSavedForm(initialForm); - setActiveFile(initialFile); - - return { - skill: sk, - files: skillFiles, - content, - }; - }, - enabled: isExpanded, - }); - - const isReadOnly = skill.scope === "system"; - - const handleFileChange = async (path: string) => { - setActiveFile(path); - setFileLoading(true); - try { - const res = await getAgentSkillFile({ - path: { id: agentId, skillId: skill.name }, - query: { path, scope: skill.scope as any }, - throwOnError: true, - }); - const content = (res.data as { content?: string })?.content ?? ""; - setForm((f) => ({ ...f, content })); - setSavedForm((f) => ({ ...f, content })); - } catch (e) { - console.error(e); - } finally { - setFileLoading(false); - } - }; - - const handleSave = async () => { - setSaving(true); + const files = detail.data?.files ?? skill.files ?? []; + async function save() { try { await updateAgentSkill({ path: { id: agentId, skillId: skill.name }, - query: { scope: skill.scope as any }, - body: { - description: form.description, - status: form.status, - disable_model_invocation: form.disable_model_invocation, - files: { [activeFile]: form.content }, - }, + query: { scope: skill.scope as Scope }, + body: { description, status, disable_model_invocation: !modelEnabled }, throwOnError: true, }); - setSavedForm(form); - setIsEditing(false); - onSaved(); - void queryClient.invalidateQueries({ - queryKey: ["agent-skill-detail", agentId, skill.scope, skill.name], - }); - } catch (e) { - console.error(e); - } finally { - setSaving(false); + showToast(t("sessions.skillsList.saved"), "success"); + void queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] }); + } catch (error) { + showToast(error instanceof Error ? error.message : t("common.error"), "error"); } - }; - - const handleDelete = async () => { - if (!confirm(`Are you sure you want to delete the skill "${skill.name}"?`)) return; - setDeleting(true); + } + async function remove() { try { await deleteAgentSkill({ path: { id: agentId, skillId: skill.name }, - query: { scope: skill.scope as any }, + query: { scope: skill.scope as Scope }, throwOnError: true, }); - onDeleted(); - } catch (e) { - console.error(e); + showToast(t("common.delete"), "success"); + await queryClient.invalidateQueries({ queryKey: ["agent-skills", agentId] }); + onClose?.(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("common.error"), "error"); } finally { - setDeleting(false); + setConfirmOpen(false); } - }; - - const handleCancel = () => { - setForm(savedForm); - setIsEditing(false); - }; - - const isDirty = JSON.stringify(form) !== JSON.stringify(savedForm); - - return ( - -
-
-
- {skill.scope === "user" ? ( - - ) : skill.scope === "agent" ? ( - - ) : ( - - )} + } + const body = ( + <> + + + {t("sessions.skillsList.overview")} + {t("sessions.skillsList.files")} + {t("sessions.skillsList.settings")} + + +

{skill.description}

+
+ {t("sessions.skillsList.scope")} + {t(`sessions.skillsList.${skill.scope}`)} + {t("sessions.skillsList.status")} + {t(statusLabelKey(skill.status))} + {t("sessions.skillsList.modelInvocation")} + + {skill.disable_model_invocation + ? t("sessions.skillsList.manual") + : t("sessions.skillsList.auto")} + + {t("sessions.skillsList.fileCount")} + {files.length}
-
-
- + {files.map((file) => ( + + ))} +
+ + +
+ {files.map((file) => ( +
+
+ + {readOnly ? ( +

+ {t("sessions.skillsList.readonlyNote")} +

+ ) : ( + <> +
+ + value[0] && setStatus(value[0])} > - {skill.status} - - )} - - {skill.disable_model_invocation && ( - ( + + {t(statusLabelKey(s))} + + ))} + +
+
+ +