diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 2cd03651d..8d2daea6f 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1750,6 +1750,10 @@ export const translations: Record> = { "在应用 Skills 目录中添加 skill.json、SKILL.md 或 README.md 文件", "settings.skillsRescan": "重新扫描", "settings.skillsSearch": "搜索技能...", + "settings.skillsInstalledSortLabel": "已安装技能排序", + "settings.skillsInstalledSortNameAsc": "名称 A→Z", + "settings.skillsInstalledSortNameDesc": "名称 Z→A", + "settings.skillsInstalledSortNewest": "安装时间 新→旧", "settings.skillsNoMatch": "没有匹配「{filter}」的技能", "settings.skillsStoreSearch": "搜索 ClawHub 技能", "settings.skillsStoreSortMostDownloaded": "下载最多", @@ -3673,6 +3677,10 @@ export const translations: Record> = { "Add skill.json, SKILL.md, or README.md files to the Skills directory", "settings.skillsRescan": "Rescan", "settings.skillsSearch": "Search Skills...", + "settings.skillsInstalledSortLabel": "Sort installed skills", + "settings.skillsInstalledSortNameAsc": "Name A→Z", + "settings.skillsInstalledSortNameDesc": "Name Z→A", + "settings.skillsInstalledSortNewest": "Installation time: newest first", "settings.skillsNoMatch": 'No skills matching "{filter}"', "settings.skillsStoreSearch": "Search ClawHub Skills", "settings.skillsStoreSortMostDownloaded": "Most Downloaded", diff --git a/crates/agent-gateway/web/src/lib/skills/index.ts b/crates/agent-gateway/web/src/lib/skills/index.ts index 3e8cda8c5..6bd490953 100644 --- a/crates/agent-gateway/web/src/lib/skills/index.ts +++ b/crates/agent-gateway/web/src/lib/skills/index.ts @@ -24,6 +24,8 @@ export type SkillSummary = { baseDir: string; /** true only when the backend verified LiveAgent ownership metadata */ builtIn?: boolean; + /** skill directory creation/modification time in epoch milliseconds */ + installedAt?: number | null; /** full README.md content for fallback skills that do not declare metadata */ inlineContent?: string; inlineContentTruncated?: boolean; @@ -123,6 +125,7 @@ type SystemManageSkillResponse = { skillFile: string; baseDir: string; builtIn?: boolean; + installedAt?: number | null; source?: SkillSourceMetadata | null; }> | null; invalid?: Array<{ path: string; error: string }> | null; @@ -445,6 +448,10 @@ async function managedSkillListToDiscovery( skillFile, baseDir, builtIn: raw.builtIn === true, + installedAt: + typeof raw.installedAt === "number" && Number.isFinite(raw.installedAt) + ? raw.installedAt + : null, source: normalizeSkillSourceMetadata(raw.source), }), ); diff --git a/crates/agent-gateway/web/src/lib/skills/installedSort.ts b/crates/agent-gateway/web/src/lib/skills/installedSort.ts new file mode 100644 index 000000000..29f8f0115 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/skills/installedSort.ts @@ -0,0 +1,59 @@ +import { isAlwaysEnabledSkillName } from "./builtin"; +import type { SkillSummary } from "./index"; + +export type InstalledSkillSort = "name-asc" | "name-desc" | "installed-desc"; + +export const DEFAULT_INSTALLED_SKILL_SORT: InstalledSkillSort = "name-asc"; + +export function isInstalledSkillSort(value: unknown): value is InstalledSkillSort { + return value === "name-asc" || value === "name-desc" || value === "installed-desc"; +} + +function installedAtValue(skill: SkillSummary) { + return typeof skill.installedAt === "number" && Number.isFinite(skill.installedAt) + ? skill.installedAt + : null; +} + +export function sortInstalledSkillItems( + items: readonly T[], + sort: InstalledSkillSort, + selectedNames: ReadonlySet, + getSkill: (item: T) => SkillSummary, +): T[] { + return [...items].sort((left, right) => { + const leftSkill = getSkill(left); + const rightSkill = getSkill(right); + const leftRank = isAlwaysEnabledSkillName(leftSkill.name) + ? 0 + : selectedNames.has(leftSkill.name) + ? 1 + : 2; + const rightRank = isAlwaysEnabledSkillName(rightSkill.name) + ? 0 + : selectedNames.has(rightSkill.name) + ? 1 + : 2; + + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + if (sort === "installed-desc") { + const leftInstalledAt = installedAtValue(leftSkill); + const rightInstalledAt = installedAtValue(rightSkill); + if (leftInstalledAt !== null || rightInstalledAt !== null) { + if (leftInstalledAt === null) return 1; + if (rightInstalledAt === null) return -1; + if (leftInstalledAt !== rightInstalledAt) return rightInstalledAt - leftInstalledAt; + } + } + + const nameOrder = leftSkill.name.localeCompare(rightSkill.name); + if (nameOrder !== 0) { + return sort === "name-desc" ? -nameOrder : nameOrder; + } + + return leftSkill.baseDir.localeCompare(rightSkill.baseDir); + }); +} diff --git a/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx b/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx index ce17272c3..7687a43e5 100644 --- a/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx +++ b/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx @@ -1,4 +1,12 @@ -import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { createPortal } from "react-dom"; import { GlassPanel, HubBackdrop, HubHeader } from "../../components/hub/HubChrome"; import { @@ -60,6 +68,12 @@ import { resolveClawHubSkillOwner, searchClawHubSkills, } from "../../lib/skills/clawHub"; +import { + DEFAULT_INSTALLED_SKILL_SORT, + type InstalledSkillSort, + isInstalledSkillSort, + sortInstalledSkillItems, +} from "../../lib/skills/installedSort"; type SkillsHubView = "installed" | "store" | "import"; @@ -81,6 +95,11 @@ const STORE_SORT_OPTIONS: Array<{ value: ClawHubSort; labelKey: string }> = [ { value: "newest", labelKey: "settings.skillsStoreSortNewest" }, ]; +const INSTALLED_SORT_OPTIONS: Array<{ value: InstalledSkillSort; labelKey: string }> = [ + { value: "name-asc", labelKey: "settings.skillsInstalledSortNameAsc" }, + { value: "name-desc", labelKey: "settings.skillsInstalledSortNameDesc" }, + { value: "installed-desc", labelKey: "settings.skillsInstalledSortNewest" }, +]; type StoreSkillInstallState = { done: boolean; installing: boolean; @@ -326,6 +345,7 @@ function buildSkillDiscoverySignature(rootDir: string, skills: SkillSummary[]) { skill.skillFile, skill.source?.registry ?? "", skill.source?.slug ?? "", + skill.installedAt ?? "", skill.source?.version ?? "", ].join("\0"), ) @@ -333,6 +353,254 @@ function buildSkillDiscoverySignature(rootDir: string, skills: SkillSummary[]) { ].join("\n"); } +const INSTALLED_SORT_STORAGE_KEY = "skillsHub.installedSort"; +const FLIP_HERO_DURATION_MS = 380; +const FLIP_BATCH_HERO_DELAY_MS = 90; +const FLIP_BATCH_STAGGER_LIMIT = 8; +const FLIP_WAVE_DURATION_MS = 280; +const FLIP_WAVE_DELAY_MS = 30; +const FLIP_WAVE_MAX_DELAY_MS = 400; +const FLIP_HERO_TRANSITION = `translate ${FLIP_HERO_DURATION_MS}ms cubic-bezier(0.34, 1.3, 0.64, 1)`; +const FLIP_WAVE_TRANSITION = `translate ${FLIP_WAVE_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`; + +type FlipMode = "single" | "wave" | "batch"; +type FlipPosition = { left: number; top: number }; +type FlipRequest = { + mode: FlipMode; + heroKeys: ReadonlySet; + followKeys: ReadonlySet; +}; + +function readInstalledSortPreference(): InstalledSkillSort { + if (typeof window === "undefined") return DEFAULT_INSTALLED_SKILL_SORT; + try { + const stored = window.localStorage.getItem(INSTALLED_SORT_STORAGE_KEY); + return isInstalledSkillSort(stored) ? stored : DEFAULT_INSTALLED_SKILL_SORT; + } catch { + return DEFAULT_INSTALLED_SKILL_SORT; + } +} + +function resetFlipStyles(element: HTMLElement) { + element.style.transition = ""; + element.style.translate = ""; + element.style.willChange = ""; + element.style.zIndex = ""; +} + +function useFlipGrid() { + const gridRef = useRef(null); + const previousRectsRef = useRef>(new Map()); + const previousOrderRef = useRef([]); + const pendingRequestRef = useRef(null); + const frameRef = useRef(null); + const phaseTimerRef = useRef(null); + const cleanupTimerRef = useRef(null); + const activeElementsRef = useRef([]); + + const requestFlip = useCallback( + (mode: FlipMode, heroKeys: readonly string[], followKeys: readonly string[] = heroKeys) => { + pendingRequestRef.current = { + mode, + heroKeys: new Set(heroKeys), + followKeys: new Set(followKeys), + }; + }, + [], + ); + + const captureVisibleKey = useCallback(() => { + const grid = gridRef.current; + if (!grid) return null; + let scrollParent = grid.parentElement; + while (scrollParent) { + const overflowY = window.getComputedStyle(scrollParent).overflowY; + if (/auto|scroll|overlay/.test(overflowY)) break; + scrollParent = scrollParent.parentElement; + } + const viewport = scrollParent?.getBoundingClientRect(); + const viewportTop = viewport?.top ?? 0; + const viewportBottom = viewport?.bottom ?? window.innerHeight; + const elements = grid.querySelectorAll("[data-flip-key]"); + for (const element of elements) { + const rect = element.getBoundingClientRect(); + if (rect.bottom > viewportTop && rect.top < viewportBottom) { + return element.dataset.flipKey ?? null; + } + } + return null; + }, []); + + const clearAnimation = useCallback(() => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + if (phaseTimerRef.current !== null) { + window.clearTimeout(phaseTimerRef.current); + phaseTimerRef.current = null; + } + if (cleanupTimerRef.current !== null) { + window.clearTimeout(cleanupTimerRef.current); + cleanupTimerRef.current = null; + } + for (const element of activeElementsRef.current) { + resetFlipStyles(element); + } + activeElementsRef.current = []; + }, []); + + useLayoutEffect(() => { + clearAnimation(); + const grid = gridRef.current; + if (!grid) { + previousRectsRef.current.clear(); + previousOrderRef.current = []; + pendingRequestRef.current = null; + return; + } + + const elements = Array.from(grid.querySelectorAll("[data-flip-key]")); + const nextOrder = elements.map((element) => element.dataset.flipKey ?? ""); + const request = pendingRequestRef.current; + pendingRequestRef.current = null; + const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + const followElement = request + ? elements.find((element) => { + const key = element.dataset.flipKey; + return key ? request.followKeys.has(key) : false; + }) + : undefined; + + followElement?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: reducedMotion ? "auto" : "smooth", + }); + + const gridRect = grid.getBoundingClientRect(); + const nextRects = new Map(); + for (const element of elements) { + const key = element.dataset.flipKey; + if (!key) continue; + const rect = element.getBoundingClientRect(); + nextRects.set(key, { + left: rect.left - gridRect.left, + top: rect.top - gridRect.top, + }); + } + + const previousRects = previousRectsRef.current; + const previousOrder = previousOrderRef.current; + const orderChanged = + previousOrder.length !== nextOrder.length || + nextOrder.some((key, index) => key !== previousOrder[index]); + previousRectsRef.current = nextRects; + previousOrderRef.current = nextOrder; + + if (previousRects.size === 0 || previousOrder.length === 0 || !orderChanged || reducedMotion) { + return; + } + + const movedElements: Array<{ element: HTMLElement; hero: boolean }> = []; + for (const element of elements) { + const key = element.dataset.flipKey; + const previousRect = key ? previousRects.get(key) : undefined; + const nextRect = key ? nextRects.get(key) : undefined; + if (!previousRect || !nextRect) continue; + const deltaX = previousRect.left - nextRect.left; + const deltaY = previousRect.top - nextRect.top; + if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; + element.style.transition = "none"; + element.style.translate = `${deltaX}px ${deltaY}px`; + element.style.willChange = "translate"; + const hero = key ? (request?.heroKeys.has(key) ?? false) : false; + if (hero) element.style.zIndex = "30"; + movedElements.push({ element, hero }); + } + + if (movedElements.length === 0) return; + activeElementsRef.current = movedElements.map(({ element }) => element); + void grid.offsetWidth; + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + const mode = request?.mode ?? "wave"; + const heroElements = movedElements.filter(({ hero }) => hero); + const waveElements = movedElements.filter(({ hero }) => !hero); + const maxWaveDelay = Math.min( + Math.max(0, waveElements.length - 1) * FLIP_WAVE_DELAY_MS, + FLIP_WAVE_MAX_DELAY_MS, + ); + const startWave = () => { + waveElements.forEach(({ element }, index) => { + const delay = Math.min(index * FLIP_WAVE_DELAY_MS, FLIP_WAVE_MAX_DELAY_MS); + element.style.transition = `${FLIP_WAVE_TRANSITION} ${delay}ms`; + element.style.translate = "0 0"; + }); + }; + const scheduleCleanup = (delay: number) => { + cleanupTimerRef.current = window.setTimeout(() => { + for (const { element } of movedElements) { + resetFlipStyles(element); + } + activeElementsRef.current = []; + cleanupTimerRef.current = null; + }, delay + 40); + }; + + if (mode === "batch") { + const staggerHeroes = (request?.heroKeys.size ?? 0) <= FLIP_BATCH_STAGGER_LIMIT; + heroElements.forEach(({ element }, index) => { + const delay = staggerHeroes ? index * FLIP_BATCH_HERO_DELAY_MS : 0; + element.style.transition = `${FLIP_HERO_TRANSITION} ${delay}ms`; + element.style.translate = "0 0"; + }); + const lastHeroDelay = + staggerHeroes && heroElements.length > 0 + ? (heroElements.length - 1) * FLIP_BATCH_HERO_DELAY_MS + : 0; + const heroPhaseDuration = + heroElements.length > 0 ? lastHeroDelay + FLIP_HERO_DURATION_MS : 0; + if (waveElements.length > 0) { + if (heroPhaseDuration > 0) { + phaseTimerRef.current = window.setTimeout(() => { + phaseTimerRef.current = null; + startWave(); + }, heroPhaseDuration); + } else { + startWave(); + } + } + const wavePhaseDuration = + waveElements.length > 0 ? FLIP_WAVE_DURATION_MS + maxWaveDelay : 0; + scheduleCleanup(heroPhaseDuration + wavePhaseDuration); + return; + } + + heroElements.forEach(({ element }) => { + element.style.transition = FLIP_HERO_TRANSITION; + element.style.translate = "0 0"; + }); + startWave(); + const heroDuration = heroElements.length > 0 ? FLIP_HERO_DURATION_MS : 0; + const waveDuration = waveElements.length > 0 ? FLIP_WAVE_DURATION_MS + maxWaveDelay : 0; + scheduleCleanup(Math.max(heroDuration, waveDuration)); + }); + }); + + useLayoutEffect( + () => () => { + clearAnimation(); + previousRectsRef.current.clear(); + previousOrderRef.current = []; + pendingRequestRef.current = null; + }, + [clearAnimation], + ); + + return { captureVisibleKey, gridRef, requestFlip }; +} + type SkillsHubPageProps = { settings: AppSettings; setSettings: (updater: (prev: AppSettings) => AppSettings) => void; @@ -357,11 +625,19 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const lockedByChatMode = !isAgentMode; const [skills, setSkills] = useState(initialSkills ?? []); + const { + captureVisibleKey: captureInstalledFlipKey, + gridRef: installedGridRef, + requestFlip: requestInstalledFlip, + } = useFlipGrid(); const [rootDir, setRootDir] = useState(initialRootDir ?? ""); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [filter, setFilter] = useState(""); const [view, setView] = useState("installed"); + const [installedSort, setInstalledSort] = useState( + readInstalledSortPreference, + ); const [storeQuery, setStoreQuery] = useState(""); const [storeSort, setStoreSort] = useState("downloads"); const [storeItems, setStoreItems] = useState([]); @@ -464,8 +740,23 @@ export function SkillsHubPage(props: SkillsHubPageProps) { [settings.skills.selected], ); const selectableSkills = useMemo(() => skills.filter(isUserSelectableSkill), [skills]); + useEffect(() => { + try { + window.localStorage.setItem(INSTALLED_SORT_STORAGE_KEY, installedSort); + } catch { + // The preference is non-critical when storage is unavailable. + } + }, [installedSort]); const selectedCount = selectableSkills.filter((skill) => selected.has(skill.name)).length; const installedSkillNames = useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]); + const requestInstalledSkillFlip = useCallback( + (mode: FlipMode, names: readonly string[], followNames: readonly string[] = names) => { + const keys = names.map((name) => `${name}-${rootDir}`); + const followKeys = followNames.map((name) => `${name}-${rootDir}`); + requestInstalledFlip(mode, keys, followKeys); + }, + [requestInstalledFlip, rootDir], + ); const filtered = useMemo(() => { const text = filter.trim().toLowerCase(); @@ -476,9 +767,14 @@ export function SkillsHubPage(props: SkillsHubPageProps) { ); }, [filter, skills]); + const sortedFiltered = useMemo( + () => sortInstalledSkillItems(filtered, installedSort, selected, (skill) => skill), + [filtered, installedSort, selected], + ); const filteredSelectableInstalledNames = useMemo( - () => filtered.map((skill) => skill.name).filter((name) => !isAlwaysEnabledSkillName(name)), - [filtered], + () => + sortedFiltered.map((skill) => skill.name).filter((name) => !isAlwaysEnabledSkillName(name)), + [sortedFiltered], ); useEffect(() => { @@ -1040,6 +1336,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const next = new Set(settings.skills.selected); if (on) next.add(name); else next.delete(name); + requestInstalledSkillFlip("single", [name], on ? [name] : []); setSettings((prev) => updateSkills(prev, { selected: Array.from(next) })); } @@ -1131,11 +1428,13 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const before = settings.skills.selected; const current = new Set(before); - const changed = names.filter((name) => + const changedNames = names.filter((name) => target ? !current.has(name) : current.has(name), - ).length; + ); + const changed = changedNames.length; if (changed === 0) return; + requestInstalledSkillFlip("batch", changedNames, target ? changedNames : []); clearBulkUndoTimer(); setBulkUndo({ selected: before, count: changed }); bulkUndoTimerRef.current = window.setTimeout(() => { @@ -1156,17 +1455,36 @@ export function SkillsHubPage(props: SkillsHubPageProps) { }); }); }, - [bulkSelection, clearBulkUndoTimer, setSettings, settings.skills.selected], + [ + bulkSelection, + clearBulkUndoTimer, + requestInstalledSkillFlip, + setSettings, + settings.skills.selected, + ], ); const undoBulkSelection = useCallback(() => { clearBulkUndoTimer(); if (bulkUndo) { const restore = bulkUndo.selected; + const current = new Set(settings.skills.selected); + const restoreSet = new Set(restore); + const changedNames = [...new Set([...current, ...restoreSet])].filter( + (name) => !isAlwaysEnabledSkillName(name) && current.has(name) !== restoreSet.has(name), + ); + const followNames = changedNames.filter((name) => restoreSet.has(name) && !current.has(name)); + requestInstalledSkillFlip("batch", changedNames, followNames); setSettings((prev) => updateSkills(prev, { selected: restore })); } setBulkUndo(null); - }, [bulkUndo, clearBulkUndoTimer, setSettings]); + }, [ + bulkUndo, + clearBulkUndoTimer, + requestInstalledSkillFlip, + setSettings, + settings.skills.selected, + ]); async function deleteBulkSelectedInstalledSkills() { if (lockedByChatMode || deletingSkillName || !bulkMode) return; @@ -1480,8 +1798,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { -
-
+
+
{[ { value: "installed" as const, @@ -1510,7 +1828,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { type="button" onClick={() => setView(item.value)} className={cn( - "relative inline-flex h-9 items-center justify-center gap-2 rounded-xl px-4 text-[12.5px] font-medium transition-all", + "relative inline-flex h-9 items-center justify-center gap-2 rounded-xl px-4 text-[12.5px] font-medium transition-all max-sm:shrink-0", active ? "bg-background/85 text-foreground shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_4px_12px_-8px_rgba(15,23,42,0.18)] ring-1 ring-border/45 dark:bg-white/[0.08] dark:ring-white/[0.09] dark:shadow-[0_1px_0_rgba(255,255,255,0.07)_inset,0_4px_12px_-8px_rgba(0,0,0,0.55)]" : "text-muted-foreground hover:bg-background/70 hover:text-foreground", @@ -1551,7 +1869,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { : t("settings.skillsBulkImportHint") } className={cn( - "inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl border px-3.5 text-[12.5px] font-medium backdrop-blur-xl transition-all", + "inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl border px-3.5 text-[12.5px] font-medium backdrop-blur-xl transition-all max-sm:px-2.5", bulkMode ? "border-primary/50 bg-primary/10 text-foreground shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_4px_12px_-8px_rgba(15,23,42,0.18)] ring-1 ring-primary/30 dark:border-primary/40 dark:bg-primary/15" : "border-border/40 bg-background/60 text-muted-foreground hover:bg-background/80 hover:text-foreground dark:border-white/[0.06] dark:bg-white/[0.04]", @@ -1563,7 +1881,32 @@ export function SkillsHubPage(props: SkillsHubPageProps) { ) : null} -
+ {view === "installed" ? ( + + ) : null} +
@@ -1606,432 +1949,447 @@ export function SkillsHubPage(props: SkillsHubPageProps) {
- ) : view === "installed" ? ( -
-
- {loadError ? ( - -
- - {loadError} -
-
- ) : null} - - {!skillsEnabled ? ( - -
- - - {t("settings.skillsDisabledHint")} - -
-
- ) : null} + ) : ( + <> + {view === "installed" ? ( +
+
+ {loadError ? ( + +
+ + {loadError} +
+
+ ) : null} - {!loading && skills.length === 0 && !loadError ? ( - -
-
- -
-
-

- {t("settings.skillsNotFound")} -

-

- {t("settings.skillsNotFoundHint")} -

-
- -
-
- ) : null} + {!skillsEnabled ? ( + +
+ + + {t("settings.skillsDisabledHint")} + +
+
+ ) : null} - {loading && skills.length === 0 ? ( - <> -
-
- -
-
- {t("settings.skillsScanning")} + {!loading && skills.length === 0 && !loadError ? ( + +
+
+
-
- {t("settings.skillsHubScanning")} +
+

+ {t("settings.skillsNotFound")} +

+

+ {t("settings.skillsNotFoundHint")} +

+
-
-
-
+
+ ) : null} -
- {[1, 2, 3, 4, 5, 6].map((item) => ( -
-
-
-
-
-
+ {loading && skills.length === 0 ? ( + <> +
+
+ +
+
+ {t("settings.skillsScanning")} +
+
+ {t("settings.skillsHubScanning")} +
+
- ))} -
- - ) : null} - {filtered.length > 0 ? ( -
- {bulkMode ? ( -
- - {t("settings.skillsBulkHint")} -
- ) : null} -
- {filtered.map((skill) => { - const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); - const checked = alwaysEnabled || selected.has(skill.name); - const bulkSelected = bulkSelection.has(skill.name); - const deleting = deletingSkillName === skill.name; - const deleteDisabled = deletingSkillName !== null; - const card = ( - <> -
-
- +
+ {[1, 2, 3, 4, 5, 6].map((item) => ( +
+
+
+
+
+
+
+
+ ))} +
+ + ) : null} - {alwaysEnabled && !bulkMode ? ( -
- - {t("settings.skillsAlwaysOn")} -
- ) : bulkMode ? ( - alwaysEnabled ? ( + {sortedFiltered.length > 0 ? ( +
+ {bulkMode ? ( +
+ + {t("settings.skillsBulkHint")} +
+ ) : null} +
+ {sortedFiltered.map((skill) => { + const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); + const checked = alwaysEnabled || selected.has(skill.name); + const bulkSelected = bulkSelection.has(skill.name); + const deleting = deletingSkillName === skill.name; + const deleteDisabled = deletingSkillName !== null; + const card = ( + <> +
-
+ + {alwaysEnabled && !bulkMode ? ( +
- -
- ) : ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > -
+ ) : bulkMode ? ( + alwaysEnabled ? ( +
+ +
+ ) : ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + +
+ ) + ) : ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} > - event.stopPropagation()} - onChange={(event) => { + title={t("settings.skillsHubBulkSelect")} + onClick={(event) => { event.stopPropagation(); - toggleBulkSelectionName(skill.name); + enterBulkMode(skill.name); }} - className="peer sr-only" - /> - - -
- ) - ) : ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - -
- )} -
- -
-
-
- {skill.name} -
- {checked ? ( - - {t("settings.skillsHubEnabledBadge")} - - ) : null} -
- {skill.description ? ( -

- {skill.description} -

- ) : null} -
- -
- - {skill.skillFile} - {!alwaysEnabled && !bulkMode ? ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - void deleteSkill(skill)} - > - {(open) => ( + + - )} - +
+ )}
- ) : null} -
- - ); - const key = `${skill.name}-${rootDir}`; - if (alwaysEnabled) { - return ( - - ); - } +
+
+
+ {skill.name} +
+ {checked ? ( + + {t("settings.skillsHubEnabledBadge")} + + ) : null} +
+ {skill.description ? ( +

+ {skill.description} +

+ ) : null} +
- return ( - // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button. -
{ - if (bulkMode) { - handleBulkInstalledCardClick( - skill.name, - filtered.map((item) => item.name), - event.shiftKey, - ); - return; - } - openInstalledSkillPreview(skill); - }} - onMouseDown={(event) => { - if (bulkMode && event.shiftKey) event.preventDefault(); - }} - onKeyDown={(event) => { - if (bulkMode && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - handleBulkInstalledCardClick( - skill.name, - filtered.map((item) => item.name), - event.shiftKey, - ); - return; - } - handleInstalledSkillCardKeyDown(event, skill); - }} - className={cn( - "hub-skill-card skill-card-enter group relative flex h-full w-full flex-col rounded-2xl border p-3.5 text-left transition-all", - "cursor-pointer focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/15", - checked - ? "border-emerald-500/35 bg-emerald-50/90 shadow-[0_1px_0_rgba(255,255,255,0.55)_inset,0_4px_14px_-12px_rgba(16,185,129,0.28)] dark:border-emerald-400/30 dark:bg-emerald-500/12 dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_4px_14px_-12px_rgba(16,185,129,0.22)]" - : "border-border/40 bg-muted/45 text-muted-foreground shadow-none hover:-translate-y-0.5 hover:border-border/55 hover:bg-muted/55 dark:border-white/[0.06] dark:bg-white/[0.025] dark:hover:border-white/[0.10] dark:hover:bg-white/[0.04]", - bulkSelected - ? "ring-2 ring-primary/50 ring-offset-1 ring-offset-background" - : null, - )} - > - {card} -
- ); - })} -
-
- ) : null} +
+ + {skill.skillFile} + {!alwaysEnabled && !bulkMode ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + void deleteSkill(skill)} + > + {(open) => ( + + )} + +
+ ) : null} +
+ + ); + + const key = `${skill.name}-${rootDir}`; + if (alwaysEnabled) { + return ( + + ); + } - {filter.trim() && filtered.length === 0 && skills.length > 0 ? ( - -

- {t("settings.skillsNoMatch").replace("{filter}", filter)} -

-
- ) : null} -
-
- ) : view === "store" ? ( - void loadMoreStore()} - onInstall={(skill) => void installStoreSkill(skill)} - /> - ) : ( - { - if (importToastTimerRef.current !== null) { - window.clearTimeout(importToastTimerRef.current); - importToastTimerRef.current = null; - } - setImportToast(null); - }} - bulkMode={bulkMode} - onToggle={toggleExternalSkill} - onBatchToggle={setExternalSkillsSelected} - onRescan={() => void rescanExternalSkills()} - onImport={() => void importSelectedExternalSkills()} - /> + return ( + // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button. +
{ + if (bulkMode) { + handleBulkInstalledCardClick( + skill.name, + sortedFiltered.map((item) => item.name), + event.shiftKey, + ); + return; + } + openInstalledSkillPreview(skill); + }} + onMouseDown={(event) => { + if (bulkMode && event.shiftKey) event.preventDefault(); + }} + onKeyDown={(event) => { + if ( + bulkMode && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + handleBulkInstalledCardClick( + skill.name, + sortedFiltered.map((item) => item.name), + event.shiftKey, + ); + return; + } + handleInstalledSkillCardKeyDown(event, skill); + }} + className={cn( + "hub-skill-card skill-card-enter group relative flex h-full w-full flex-col rounded-2xl border p-3.5 text-left transition-all", + "cursor-pointer focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/15", + checked + ? "border-emerald-500/35 bg-emerald-50/90 shadow-[0_1px_0_rgba(255,255,255,0.55)_inset,0_4px_14px_-12px_rgba(16,185,129,0.28)] dark:border-emerald-400/30 dark:bg-emerald-500/12 dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_4px_14px_-12px_rgba(16,185,129,0.22)]" + : "border-border/40 bg-muted/45 text-muted-foreground shadow-none hover:-translate-y-0.5 hover:border-border/55 hover:bg-muted/55 dark:border-white/[0.06] dark:bg-white/[0.025] dark:hover:border-white/[0.10] dark:hover:bg-white/[0.04]", + bulkSelected + ? "ring-2 ring-primary/50 ring-offset-1 ring-offset-background" + : null, + )} + > + {card} +
+ ); + })} +
+
+ ) : null} + + {filter.trim() && sortedFiltered.length === 0 && skills.length > 0 ? ( + +

+ {t("settings.skillsNoMatch").replace("{filter}", filter)} +

+
+ ) : null} +
+
+ ) : view === "store" ? ( + void loadMoreStore()} + onInstall={(skill) => void installStoreSkill(skill)} + /> + ) : ( + { + if (importToastTimerRef.current !== null) { + window.clearTimeout(importToastTimerRef.current); + importToastTimerRef.current = null; + } + setImportToast(null); + }} + bulkMode={bulkMode} + onToggle={toggleExternalSkill} + onBatchToggle={setExternalSkillsSelected} + onRescan={() => void rescanExternalSkills()} + onImport={() => void importSelectedExternalSkills()} + /> + )} + )}
@@ -2054,8 +2412,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { view === "installed" && !lockedByChatMode && (!bulkUndo || bulkSelection.size > 0) ? ( -
-
+
+
{bulkSelection.size > 0 ? ( <> @@ -2067,7 +2425,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { ? ` ${t("settings.skillsBulkNotInFilter").replace("{count}", String(bulkSelectedHiddenCount))}` : ""} -
-
-
+
+
{[ { value: "installed" as const, @@ -1597,7 +1918,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { type="button" onClick={() => setView(item.value)} className={cn( - "relative inline-flex h-9 items-center justify-center gap-2 rounded-xl px-4 text-[12.5px] font-medium transition-all", + "relative inline-flex h-9 items-center justify-center gap-2 rounded-xl px-4 text-[12.5px] font-medium transition-all max-sm:shrink-0", active ? "bg-background/85 text-foreground shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_4px_12px_-8px_rgba(15,23,42,0.18)] ring-1 ring-border/45 dark:bg-white/[0.08] dark:ring-white/[0.09] dark:shadow-[0_1px_0_rgba(255,255,255,0.07)_inset,0_4px_12px_-8px_rgba(0,0,0,0.55)]" : "text-muted-foreground hover:bg-background/70 hover:text-foreground", @@ -1638,7 +1959,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { : t("settings.skillsBulkImportHint") } className={cn( - "inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl border px-3.5 text-[12.5px] font-medium backdrop-blur-xl transition-all", + "inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl border px-3.5 text-[12.5px] font-medium backdrop-blur-xl transition-all max-sm:px-2.5", bulkMode ? "border-primary/50 bg-primary/10 text-foreground shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_4px_12px_-8px_rgba(15,23,42,0.18)] ring-1 ring-primary/30 dark:border-primary/40 dark:bg-primary/15" : "border-border/40 bg-background/60 text-muted-foreground hover:bg-background/80 hover:text-foreground dark:border-white/[0.06] dark:bg-white/[0.04]", @@ -1650,7 +1971,32 @@ export function SkillsHubPage(props: SkillsHubPageProps) { ) : null} -
+ {view === "installed" ? ( + + ) : null} +
@@ -1693,455 +2039,467 @@ export function SkillsHubPage(props: SkillsHubPageProps) {
- ) : view === "installed" ? ( -
-
- {skills.length > 0 ? ( - - ) : null} - - {bulkMode ? ( -
- - {t("settings.skillsBulkHint")} -
- ) : null} + ) : ( + <> + {view === "installed" ? ( +
+
+ {skills.length > 0 ? ( + + ) : null} - {loadError ? ( - -
- - {loadError} -
-
- ) : null} + {bulkMode ? ( +
+ + {t("settings.skillsBulkHint")} +
+ ) : null} - {!skillsEnabled ? ( - -
- - - {t("settings.skillsDisabledHint")} - -
-
- ) : null} + {loadError ? ( + +
+ + {loadError} +
+
+ ) : null} - {!loading && skills.length === 0 && !loadError ? ( - -
-
- -
-
-

- {t("settings.skillsNotFound")} -

-

- {t("settings.skillsNotFoundHint")} -

-
- -
-
- ) : null} + {!skillsEnabled ? ( + +
+ + + {t("settings.skillsDisabledHint")} + +
+
+ ) : null} - {loading && skills.length === 0 ? ( - <> -
-
- -
-
- {t("settings.skillsScanning")} + {!loading && skills.length === 0 && !loadError ? ( + +
+
+
-
- {t("settings.skillsHubScanning")} +
+

+ {t("settings.skillsNotFound")} +

+

+ {t("settings.skillsNotFoundHint")} +

+
-
-
-
+
+ ) : null} -
- {[1, 2, 3, 4, 5, 6].map((item) => ( -
-
-
-
-
-
+ {loading && skills.length === 0 ? ( + <> +
+
+ +
+
+ {t("settings.skillsScanning")} +
+
+ {t("settings.skillsHubScanning")} +
+
- ))} -
- - ) : null} - {filtered.length > 0 ? ( -
- {filtered.map(({ skill, categories }) => { - const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); - const checked = alwaysEnabled || selected.has(skill.name); - const bulkSelected = bulkSelection.has(skill.name); - const deleting = deletingSkillName === skill.name; - const deleteDisabled = deletingSkillName !== null; - const PrimaryCategoryIcon = - STORE_CATEGORY_ICONS[categories[0] ?? "other"]; - const card = ( - <> -
+
+ {[1, 2, 3, 4, 5, 6].map((item) => (
- +
+
+
+
+
+
+
+ ))} +
+ + ) : null} - {alwaysEnabled && !bulkMode ? ( -
- - {t("settings.skillsAlwaysOn")} -
- ) : bulkMode ? ( - alwaysEnabled ? ( + {sortedFiltered.length > 0 ? ( +
+ {sortedFiltered.map(({ skill, categories }) => { + const alwaysEnabled = isAlwaysEnabledSkillName(skill.name); + const checked = alwaysEnabled || selected.has(skill.name); + const bulkSelected = bulkSelection.has(skill.name); + const deleting = deletingSkillName === skill.name; + const deleteDisabled = deletingSkillName !== null; + const PrimaryCategoryIcon = + STORE_CATEGORY_ICONS[categories[0] ?? "other"]; + const card = ( + <> +
-
+ + {alwaysEnabled && !bulkMode ? ( +
- -
- ) : ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > -
+ ) : bulkMode ? ( + alwaysEnabled ? ( +
+ +
+ ) : ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + +
+ ) + ) : ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} > - event.stopPropagation()} - onChange={(event) => { + title={t("settings.skillsHubBulkSelect")} + onClick={(event) => { event.stopPropagation(); - toggleBulkSelectionName(skill.name); + enterBulkMode(skill.name); }} - className="peer sr-only" - /> - - -
- ) - ) : ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - -
- )} -
- -
-
-
- {skill.name} -
- {checked ? ( - - {t("settings.skillsHubEnabledBadge")} - - ) : null} -
- {skill.description ? ( -

- {skill.description} -

- ) : null} - {!alwaysEnabled ? ( -
- -
- ) : null} -
- -
- - {skill.skillFile} - {!alwaysEnabled && !bulkMode ? ( -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - void deleteSkill(skill)} - > - {(open) => ( + + - )} - +
+ )}
- ) : null} -
- - ); - - const key = `${skill.name}-${rootDir}`; - if (alwaysEnabled) { - return ( - - ); - } - - return ( - // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button. -
{ - if (bulkMode) { - handleBulkInstalledCardClick( - skill.name, - filtered.map((entry) => entry.skill.name), - event.shiftKey, - ); - return; - } - openInstalledSkillPreview(skill); - }} - onMouseDown={(event) => { - // Shift+点击做区间选择时避免浏览器拖出文本选区 - if (bulkMode && event.shiftKey) event.preventDefault(); - }} - onKeyDown={(event) => { - if (bulkMode && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - handleBulkInstalledCardClick( - skill.name, - filtered.map((entry) => entry.skill.name), - event.shiftKey, - ); - return; - } - handleInstalledSkillCardKeyDown(event, skill); - }} - className={cn( - "hub-skill-card skill-card-enter group relative flex h-full w-full flex-col rounded-2xl border p-3.5 text-left transition-all", - "cursor-pointer focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/15", - // Enabled style always visible; bulk selection overlays primary ring. - checked - ? "border-emerald-500/35 bg-emerald-50/90 shadow-[0_1px_0_rgba(255,255,255,0.55)_inset,0_4px_14px_-12px_rgba(16,185,129,0.28)] dark:border-emerald-400/30 dark:bg-emerald-500/12 dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_4px_14px_-12px_rgba(16,185,129,0.22)]" - : "border-border/40 bg-muted/45 text-muted-foreground shadow-none hover:-translate-y-0.5 hover:border-border/55 hover:bg-muted/55 dark:border-white/[0.06] dark:bg-white/[0.025] dark:hover:border-white/[0.10] dark:hover:bg-white/[0.04]", - bulkSelected - ? "ring-2 ring-primary/50 ring-offset-1 ring-offset-background" - : null, - )} - > - {card} -
- ); - })} -
- ) : null} - {(filter.trim() || installedCategory !== "all") && - filtered.length === 0 && - skills.length > 0 ? ( - -

- {filter.trim() - ? t("settings.skillsNoMatch").replace("{filter}", filter) - : t("settings.skillsStoreEmptyTitle")} -

-
- ) : null} -
-
- ) : view === "store" ? ( - void loadMoreStore()} - onInstall={(skill) => void installStoreSkill(skill)} - /> - ) : ( - { - if (importToastTimerRef.current !== null) { - window.clearTimeout(importToastTimerRef.current); - importToastTimerRef.current = null; - } - setImportToast(null); - }} - bulkMode={bulkMode} - onToggle={toggleExternalSkill} - onBatchToggle={batchToggleExternalSkills} - onRescan={() => void rescanExternalSkills()} - onImport={() => void importSelectedExternalSkills()} - /> +
+
+
+ {skill.name} +
+ {checked ? ( + + {t("settings.skillsHubEnabledBadge")} + + ) : null} +
+ {skill.description ? ( +

+ {skill.description} +

+ ) : null} + {!alwaysEnabled ? ( +
+ +
+ ) : null} +
+ +
+ + {skill.skillFile} + {!alwaysEnabled && !bulkMode ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + void deleteSkill(skill)} + > + {(open) => ( + + )} + +
+ ) : null} +
+ + ); + + const key = `${skill.name}-${rootDir}`; + if (alwaysEnabled) { + return ( + + ); + } + + return ( + // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button. +
{ + if (bulkMode) { + handleBulkInstalledCardClick( + skill.name, + sortedFiltered.map((entry) => entry.skill.name), + event.shiftKey, + ); + return; + } + openInstalledSkillPreview(skill); + }} + onMouseDown={(event) => { + // Shift+点击做区间选择时避免浏览器拖出文本选区 + if (bulkMode && event.shiftKey) event.preventDefault(); + }} + onKeyDown={(event) => { + if (bulkMode && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + handleBulkInstalledCardClick( + skill.name, + sortedFiltered.map((entry) => entry.skill.name), + event.shiftKey, + ); + return; + } + handleInstalledSkillCardKeyDown(event, skill); + }} + className={cn( + "hub-skill-card skill-card-enter group relative flex h-full w-full flex-col rounded-2xl border p-3.5 text-left transition-all", + "cursor-pointer focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/15", + // Enabled style always visible; bulk selection overlays primary ring. + checked + ? "border-emerald-500/35 bg-emerald-50/90 shadow-[0_1px_0_rgba(255,255,255,0.55)_inset,0_4px_14px_-12px_rgba(16,185,129,0.28)] dark:border-emerald-400/30 dark:bg-emerald-500/12 dark:shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_4px_14px_-12px_rgba(16,185,129,0.22)]" + : "border-border/40 bg-muted/45 text-muted-foreground shadow-none hover:-translate-y-0.5 hover:border-border/55 hover:bg-muted/55 dark:border-white/[0.06] dark:bg-white/[0.025] dark:hover:border-white/[0.10] dark:hover:bg-white/[0.04]", + bulkSelected + ? "ring-2 ring-primary/50 ring-offset-1 ring-offset-background" + : null, + )} + > + {card} +
+ ); + })} +
+ ) : null} + + {(filter.trim() || installedCategory !== "all") && + sortedFiltered.length === 0 && + skills.length > 0 ? ( + +

+ {filter.trim() + ? t("settings.skillsNoMatch").replace("{filter}", filter) + : t("settings.skillsStoreEmptyTitle")} +

+
+ ) : null} +
+
+ ) : view === "store" ? ( + void loadMoreStore()} + onInstall={(skill) => void installStoreSkill(skill)} + /> + ) : ( + { + if (importToastTimerRef.current !== null) { + window.clearTimeout(importToastTimerRef.current); + importToastTimerRef.current = null; + } + setImportToast(null); + }} + bulkMode={bulkMode} + onToggle={toggleExternalSkill} + onBatchToggle={batchToggleExternalSkills} + onRescan={() => void rescanExternalSkills()} + onImport={() => void importSelectedExternalSkills()} + /> + )} + )}
@@ -2164,8 +2522,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { view === "installed" && !lockedByChatMode && (!bulkUndo || bulkSelection.size > 0) ? ( -
-
+
+
{bulkSelection.size > 0 ? ( <> @@ -2177,7 +2535,7 @@ export function SkillsHubPage(props: SkillsHubPageProps) { ? ` ${t("settings.skillsBulkNotInFilter").replace("{count}", String(bulkSelectedHiddenCount))}` : ""} -