Improve workspace skill filtering

This commit is contained in:
Jude Wang
2026-04-07 02:23:58 +08:00
parent fab14e7d6c
commit 17a04d06d2
3 changed files with 159 additions and 72 deletions
+146 -71
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
@@ -18,6 +18,8 @@ interface AgentInfo {
emoji: string;
}
type SkillFilter = "all" | "builtin" | "extension" | "custom" | "workspace";
function normalizeSkill(raw: unknown): Skill | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
@@ -59,13 +61,19 @@ function normalizeAgents(raw: unknown): Record<string, AgentInfo> {
return Object.fromEntries(entries);
}
function getWorkspaceIdFromSource(source: string): string | null {
if (!source.startsWith("workspace:")) return null;
return source.slice("workspace:".length) || null;
}
export default function SkillsPage() {
const { t } = useI18n();
const [skills, setSkills] = useState<Skill[]>([]);
const [agents, setAgents] = useState<Record<string, AgentInfo>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
const [filter, setFilter] = useState<SkillFilter>("all");
const [workspaceFilter, setWorkspaceFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [skillContent, setSkillContent] = useState<Record<string, string>>({});
@@ -170,30 +178,63 @@ export default function SkillsPage() {
return () => window.removeEventListener("keydown", onKeyDown);
}, [selectedSkill]);
const workspaceOptions = useMemo(() => {
const ids = Array.from(
new Set(
skills
.map((skill) => getWorkspaceIdFromSource(skill.source))
.filter((workspaceId): workspaceId is string => Boolean(workspaceId))
)
).sort((a, b) => a.localeCompare(b));
return ids.map((id) => ({
id,
label: agents[id]?.name || (id === "main" ? t("skills.workspace.main") : id),
emoji: agents[id]?.emoji || (id === "main" ? "🏠" : "📁"),
}));
}, [agents, skills, t]);
const filtered = skills.filter((skill) => {
if (filter === "builtin" && skill.source !== "builtin") return false;
if (filter === "extension" && !skill.source.startsWith("extension:")) return false;
if (filter === "custom" && skill.source !== "custom") return false;
if (filter === "workspace" && !skill.source.startsWith("workspace:")) return false;
if (workspaceFilter !== "all") {
const workspaceId = getWorkspaceIdFromSource(skill.source);
if (workspaceId !== workspaceFilter) return false;
}
if (!search) return true;
const query = search.toLowerCase();
const workspaceId = getWorkspaceIdFromSource(skill.source) || "";
const workspaceName = workspaceId ? (agents[workspaceId]?.name || workspaceId).toLowerCase() : "";
return (
skill.name.toLowerCase().includes(query) ||
skill.description.toLowerCase().includes(query) ||
skill.id.toLowerCase().includes(query)
skill.id.toLowerCase().includes(query) ||
workspaceName.includes(query) ||
workspaceId.toLowerCase().includes(query)
);
});
const sourceLabel = (source: string) => {
if (source === "builtin") return t("skills.source.builtin");
if (source.startsWith("extension:")) return source.replace("extension:", `${t("skills.extension")}:`);
if (source.startsWith("workspace:")) {
const workspaceId = getWorkspaceIdFromSource(source);
if (!workspaceId) return t("skills.source.workspace");
if (workspaceId === "main") return `${t("skills.source.workspace")}: ${t("skills.workspace.main")}`;
return `${t("skills.source.workspace")}: ${agents[workspaceId]?.name || workspaceId}`;
}
return t("skills.source.custom");
};
const sourceBadgeClass = (source: string) => {
if (source === "builtin") return "bg-blue-500/20 text-blue-400";
if (source.startsWith("extension:")) return "bg-purple-500/20 text-purple-400";
if (source.startsWith("workspace:")) return "bg-amber-500/20 text-amber-300";
return "bg-green-500/20 text-green-400";
};
@@ -219,6 +260,7 @@ export default function SkillsPage() {
const builtinCount = skills.filter((skill) => skill.source === "builtin").length;
const extensionCount = skills.filter((skill) => skill.source.startsWith("extension:")).length;
const customCount = skills.filter((skill) => skill.source === "custom").length;
const workspaceCount = skills.filter((skill) => skill.source.startsWith("workspace:")).length;
return (
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
@@ -226,7 +268,7 @@ export default function SkillsPage() {
<div>
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
<p className="text-[var(--text-muted)] text-sm mt-1">
{skills.length} {t("skills.count")}{t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount}
{skills.length} {t("skills.count")}{t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extensionCount} / {t("skills.custom")} {customCount} / {t("skills.workspace")} {workspaceCount}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
@@ -239,38 +281,58 @@ export default function SkillsPage() {
</div>
</div>
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center">
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
{(["all", "builtin", "extension", "custom"] as const).map((nextFilter) => (
<button
key={nextFilter}
onClick={() => setFilter(nextFilter)}
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
filter === nextFilter
? "bg-[var(--accent)] text-[var(--bg)]"
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
}`}
<div className="flex flex-col gap-3 mb-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
{(["all", "builtin", "extension", "custom", "workspace"] as const).map((nextFilter) => (
<button
key={nextFilter}
onClick={() => setFilter(nextFilter)}
className={`px-3 py-1.5 text-xs font-medium transition cursor-pointer ${
filter === nextFilter
? "bg-[var(--accent)] text-[var(--bg)]"
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
}`}
>
{nextFilter === "all"
? t("skills.all")
: nextFilter === "builtin"
? t("skills.builtin")
: nextFilter === "extension"
? t("skills.extension")
: nextFilter === "custom"
? t("skills.custom")
: t("skills.workspace")}
</button>
))}
</div>
{workspaceOptions.length > 0 && (
<select
value={workspaceFilter}
onChange={(event) => setWorkspaceFilter(event.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-auto"
>
{nextFilter === "all"
? t("skills.all")
: nextFilter === "builtin"
? t("skills.builtin")
: nextFilter === "extension"
? t("skills.extension")
: t("skills.custom")}
</button>
))}
<option value="all">{t("skills.workspaceFilter.all")}</option>
{workspaceOptions.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.emoji} {workspace.label}
</option>
))}
</select>
)}
<input
type="text"
placeholder={t("skills.search")}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
/>
<span className="text-xs text-[var(--text-muted)]">
{t("skills.showing")} {filtered.length} {t("skills.unit")}
</span>
</div>
<input
type="text"
placeholder={t("skills.search")}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
/>
<span className="text-xs text-[var(--text-muted)]">
{t("skills.showing")} {filtered.length} {t("skills.unit")}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -279,45 +341,57 @@ export default function SkillsPage() {
{t("common.noData")}
</div>
) : (
filtered.map((skill) => (
<button
key={`${skill.source}-${skill.id}`}
type="button"
onClick={() => setSelectedSkill(skill)}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer"
>
<div className="flex items-start justify-between mb-2 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl shrink-0">{skill.emoji}</span>
<span className="font-semibold text-sm truncate">{skill.name}</span>
filtered.map((skill) => {
const workspaceId = getWorkspaceIdFromSource(skill.source);
const workspaceAgent = workspaceId ? agents[workspaceId] : null;
return (
<button
key={`${skill.source}-${skill.id}`}
type="button"
onClick={() => setSelectedSkill(skill)}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer"
>
<div className="flex items-start justify-between mb-2 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl shrink-0">{skill.emoji}</span>
<span className="font-semibold text-sm truncate">{skill.name}</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${sourceBadgeClass(skill.source)}`}>
{sourceLabel(skill.source)}
</span>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0 ${sourceBadgeClass(skill.source)}`}>
{sourceLabel(skill.source)}
</span>
</div>
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
<div className="mb-3 text-[10px] text-[var(--accent)]">
{t("skills.viewSource")}
</div>
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
const agent = agents[agentId];
return (
<span
key={agentId}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
>
{agent?.emoji || "🤖"} {agent?.name || agentId}
</span>
);
})}
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
{skill.description || t("skills.noDesc")}
</p>
{workspaceId && (
<div className="mb-3 text-[11px] text-[var(--text-muted)] flex flex-wrap items-center gap-2">
<span className="px-2 py-0.5 rounded bg-[var(--bg)] border border-[var(--border)]">
{(workspaceAgent?.emoji || (workspaceId === "main" ? "🏠" : "📁"))} {workspaceAgent?.name || (workspaceId === "main" ? t("skills.workspace.main") : workspaceId)}
</span>
<span className="font-mono text-[10px] text-[var(--text-muted)]/80">{skill.source}</span>
</div>
)}
<div className="mb-3 text-[10px] text-[var(--accent)]">
{t("skills.viewSource")}
</div>
)}
</button>
))
{skill.usedBy.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.usedBy.map((agentId) => {
const agent = agents[agentId];
return (
<span
key={agentId}
className="px-1.5 py-0.5 rounded bg-[var(--bg)] text-[10px] font-medium"
>
{agent?.emoji || "🤖"} {agent?.name || agentId}
</span>
);
})}
</div>
)}
</button>
);
})
)}
</div>
@@ -338,6 +412,7 @@ export default function SkillsPage() {
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{selectedSkill.emoji} {selectedSkill.name}</div>
<div className="text-xs text-[var(--text-muted)]">{t("skills.contentTitle")}</div>
<div className="text-[10px] text-[var(--text-muted)] mt-1">{sourceLabel(selectedSkill.source)}</div>
</div>
<button
type="button"
+12
View File
@@ -233,6 +233,7 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.builtin": "內建",
"skills.extension": "擴充",
"skills.custom": "自訂",
"skills.workspace": "工作區",
"skills.all": "全部",
"skills.search": "搜尋技能...",
"skills.showing": "顯示",
@@ -240,6 +241,9 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.noDesc": "無描述",
"skills.source.builtin": "內建",
"skills.source.custom": "自訂",
"skills.source.workspace": "工作區",
"skills.workspace.main": "主工作區",
"skills.workspaceFilter.all": "所有工作區",
"skills.viewSource": "查看 SKILL.md",
"skills.contentTitle": "SKILL.md 內容",
"skills.loadingContent": "正在載入技能內容...",
@@ -521,6 +525,7 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.builtin": "内置",
"skills.extension": "扩展",
"skills.custom": "自定义",
"skills.workspace": "工作区",
"skills.all": "全部",
"skills.search": "搜索技能...",
"skills.showing": "显示",
@@ -528,6 +533,9 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.noDesc": "无描述",
"skills.source.builtin": "内置",
"skills.source.custom": "自定义",
"skills.source.workspace": "工作区",
"skills.workspace.main": "主工作区",
"skills.workspaceFilter.all": "所有工作区",
"skills.viewSource": "查看 SKILL.md",
"skills.contentTitle": "SKILL.md 内容",
"skills.loadingContent": "正在加载技能内容...",
@@ -809,6 +817,7 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.builtin": "Built-in",
"skills.extension": "Extension",
"skills.custom": "Custom",
"skills.workspace": "Workspace",
"skills.all": "All",
"skills.search": "Search skills...",
"skills.showing": "Showing",
@@ -816,6 +825,9 @@ const translations: Record<Locale, Record<string, string>> = {
"skills.noDesc": "No description",
"skills.source.builtin": "Built-in",
"skills.source.custom": "Custom",
"skills.source.workspace": "Workspace",
"skills.workspace.main": "Main workspace",
"skills.workspaceFilter.all": "All workspaces",
"skills.viewSource": "View SKILL.md",
"skills.contentTitle": "SKILL.md",
"skills.loadingContent": "Loading skill content...",
+1 -1
View File
@@ -81,7 +81,7 @@ function getConfiguredAgentWorkspaces(): Array<{ id: string; workspace?: string
.filter((agent: unknown): agent is { id: string; workspace?: string } => {
return Boolean(agent && typeof agent === "object" && typeof (agent as { id?: string }).id === "string");
})
.map((agent) => ({ id: agent.id, workspace: agent.workspace }));
.map((agent: { id: string; workspace?: string }) => ({ id: agent.id, workspace: agent.workspace }));
} catch {
return [];
}