mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(ui): 修复 Skills 管理与模型配置交互展示问题 (#4323)
* fix(ui): improve skills and provider interactions * fix(skills): keep repo manager available on skills.sh * test(skills): cover repo switch after refresh * fix(skills): keep refresh available for empty repo results --------- Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com>
This commit is contained in:
+29
-22
@@ -15,7 +15,6 @@ import {
|
||||
Book,
|
||||
Brain,
|
||||
Wrench,
|
||||
RefreshCw,
|
||||
History,
|
||||
BarChart2,
|
||||
Download,
|
||||
@@ -71,7 +70,11 @@ import { FailoverToggle } from "@/components/proxy/FailoverToggle";
|
||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import {
|
||||
SkillsPage,
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageSource,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
|
||||
@@ -169,6 +172,8 @@ function App() {
|
||||
const sharedFeatureApp: AppId =
|
||||
activeApp === "claude-desktop" ? "claude" : activeApp;
|
||||
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
||||
const [skillsDiscoverySource, setSkillsDiscoverySource] =
|
||||
useState<SkillsPageSource>("repos");
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
||||
@@ -857,6 +862,11 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSkillsDiscovery = () => {
|
||||
setSkillsDiscoverySource("repos");
|
||||
setCurrentView("skillsDiscovery");
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
const content = (() => {
|
||||
switch (currentView) {
|
||||
@@ -884,7 +894,7 @@ function App() {
|
||||
return (
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
onOpenDiscovery={handleOpenSkillsDiscovery}
|
||||
currentApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
@@ -897,6 +907,7 @@ function App() {
|
||||
initialApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
onSourceChange={setSkillsDiscoverySource}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -1312,7 +1323,7 @@ function App() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
onClick={handleOpenSkillsDiscovery}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
@@ -1322,24 +1333,20 @@ function App() {
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
{getSkillsPageHeaderActions(skillsDiscoverySource).map(
|
||||
({ key, labelKey, Icon, execute }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => execute(skillsPageRef.current)}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{t(labelKey)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
|
||||
@@ -320,7 +320,7 @@ export function ProviderCard({
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -335,7 +335,7 @@ export function ProviderCard({
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
||||
<ProviderIcon
|
||||
icon={provider.icon}
|
||||
name={provider.name}
|
||||
@@ -344,7 +344,7 @@ export function ProviderCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2 min-h-7">
|
||||
<h3 className="text-base font-semibold leading-none">
|
||||
{provider.name}
|
||||
@@ -451,7 +451,7 @@ export function ProviderCard({
|
||||
type="button"
|
||||
onClick={handleOpenWebsite}
|
||||
className={cn(
|
||||
"inline-flex items-center text-sm max-w-[280px]",
|
||||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||||
isClickableUrl
|
||||
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
|
||||
: "text-muted-foreground cursor-default",
|
||||
@@ -459,7 +459,7 @@ export function ProviderCard({
|
||||
title={displayUrl}
|
||||
disabled={!isClickableUrl}
|
||||
>
|
||||
<span className="truncate">{displayUrl}</span>
|
||||
<span className="min-w-0 truncate">{displayUrl}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
@@ -471,6 +470,19 @@ export function OmoFormFields({
|
||||
const firstIsUnavailable =
|
||||
Boolean(currentVariant) &&
|
||||
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
|
||||
const defaultVariantLabel = t("omo.defaultWrapped", {
|
||||
defaultValue: "(Default)",
|
||||
});
|
||||
const getVariantLabel = (variant: string, index: number) =>
|
||||
firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant;
|
||||
const selectedVariantLabel = currentVariant
|
||||
? getVariantLabel(currentVariant, 0)
|
||||
: defaultVariantLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -479,25 +491,21 @@ export function OmoFormFields({
|
||||
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
|
||||
<SelectValue
|
||||
placeholder={t("omo.variantPlaceholder", {
|
||||
defaultValue: "variant",
|
||||
})}
|
||||
/>
|
||||
<SelectTrigger
|
||||
className="w-28 min-w-0 h-8 overflow-hidden text-xs shrink-0"
|
||||
title={selectedVariantLabel}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedVariantLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={EMPTY_VARIANT_VALUE}>
|
||||
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
|
||||
{defaultVariantLabel}
|
||||
</SelectItem>
|
||||
{variantOptions.map((variant, index) => (
|
||||
<SelectItem key={`${variant}-${index}`} value={variant}>
|
||||
{firstIsUnavailable && index === 0
|
||||
? t("omo.currentValueUnavailable", {
|
||||
value: variant,
|
||||
defaultValue: "{{value}} (current value, unavailable)",
|
||||
})
|
||||
: variant}
|
||||
{getVariantLabel(variant, index)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RefreshCw, Search, Loader2 } from "lucide-react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Loader2,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
@@ -36,8 +42,11 @@ import type {
|
||||
} from "@/lib/api/skills";
|
||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||
|
||||
export type SkillsPageSource = "repos" | "skillssh";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppId;
|
||||
onSourceChange?: (source: SkillsPageSource) => void;
|
||||
}
|
||||
|
||||
export interface SkillsPageHandle {
|
||||
@@ -45,7 +54,35 @@ export interface SkillsPageHandle {
|
||||
openRepoManager: () => void;
|
||||
}
|
||||
|
||||
type SearchSource = "repos" | "skillssh";
|
||||
type SkillsPageHeaderAction = {
|
||||
key: string;
|
||||
sources: readonly SkillsPageSource[];
|
||||
labelKey: string;
|
||||
Icon: LucideIcon;
|
||||
execute: (page: SkillsPageHandle | null) => void;
|
||||
};
|
||||
|
||||
const SKILLS_PAGE_HEADER_ACTIONS: readonly SkillsPageHeaderAction[] = [
|
||||
{
|
||||
key: "refresh-repos",
|
||||
sources: ["repos"],
|
||||
labelKey: "skills.refresh",
|
||||
Icon: RefreshCw,
|
||||
execute: (page) => page?.refresh(),
|
||||
},
|
||||
{
|
||||
key: "manage-repos",
|
||||
sources: ["repos", "skillssh"],
|
||||
labelKey: "skills.repoManager",
|
||||
Icon: Settings,
|
||||
execute: (page) => page?.openRepoManager(),
|
||||
},
|
||||
];
|
||||
|
||||
export const getSkillsPageHeaderActions = (source: SkillsPageSource) =>
|
||||
SKILLS_PAGE_HEADER_ACTIONS.filter((action) =>
|
||||
action.sources.includes(source),
|
||||
);
|
||||
|
||||
const SKILLSSH_PAGE_SIZE = 20;
|
||||
|
||||
@@ -54,7 +91,7 @@ const SKILLSSH_PAGE_SIZE = 20;
|
||||
* 用于浏览和安装来自仓库或 skills.sh 的 Skills
|
||||
*/
|
||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
({ initialApp = "claude" }, ref) => {
|
||||
({ initialApp = "claude", onSourceChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -64,7 +101,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
>("all");
|
||||
|
||||
// skills.sh 搜索状态
|
||||
const [searchSource, setSearchSource] = useState<SearchSource>("repos");
|
||||
const [searchSource, setSearchSource] = useState<SkillsPageSource>("repos");
|
||||
const [skillsShInput, setSkillsShInput] = useState("");
|
||||
const [skillsShQuery, setSkillsShQuery] = useState("");
|
||||
const [skillsShOffset, setSkillsShOffset] = useState(0);
|
||||
@@ -90,23 +127,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
data: skillsShResult,
|
||||
isLoading: loadingSkillsSh,
|
||||
isFetching: fetchingSkillsSh,
|
||||
isPlaceholderData: placeholderSkillsSh,
|
||||
} = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset);
|
||||
|
||||
// 当搜索结果返回时累积
|
||||
useEffect(() => {
|
||||
if (skillsShResult) {
|
||||
if (skillsShResult && !placeholderSkillsSh) {
|
||||
if (skillsShOffset === 0) {
|
||||
setAccumulatedResults(skillsShResult.skills);
|
||||
} else {
|
||||
setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]);
|
||||
}
|
||||
}
|
||||
}, [skillsShResult, skillsShOffset]);
|
||||
}, [skillsShResult, skillsShOffset, placeholderSkillsSh]);
|
||||
|
||||
// 手动提交搜索
|
||||
const handleSkillsShSearch = () => {
|
||||
const trimmed = skillsShInput.trim();
|
||||
if (trimmed.length < 2) return;
|
||||
if (trimmed === skillsShQuery && skillsShOffset === 0) return;
|
||||
setSkillsShOffset(0);
|
||||
setAccumulatedResults([]);
|
||||
setSkillsShQuery(trimmed);
|
||||
@@ -314,13 +353,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
// 是否有更多 skills.sh 结果
|
||||
const hasMoreSkillsSh =
|
||||
skillsShResult && accumulatedResults.length < skillsShResult.totalCount;
|
||||
const searchingSkillsSh =
|
||||
(loadingSkillsSh || fetchingSkillsSh) && accumulatedResults.length === 0;
|
||||
|
||||
// 无仓库时默认切换到 skills.sh
|
||||
// 无仓库配置时默认切换到 skills.sh;仓库发现结果为空时仍保留仓库视图,方便手动刷新重试。
|
||||
const effectiveSource =
|
||||
searchSource === "repos" && skills.length === 0 && !loading
|
||||
searchSource === "repos" && repos.length === 0 && !loading
|
||||
? "skillssh"
|
||||
: searchSource;
|
||||
|
||||
useEffect(() => {
|
||||
onSourceChange?.(effectiveSource);
|
||||
}, [effectiveSource, onSourceChange]);
|
||||
|
||||
return (
|
||||
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
|
||||
{/* 技能网格(可滚动详情区域) */}
|
||||
@@ -528,7 +573,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
) : (
|
||||
/* ===== skills.sh 模式 ===== */
|
||||
<>
|
||||
{loadingSkillsSh && accumulatedResults.length === 0 ? (
|
||||
{searchingSkillsSh ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
@@ -542,7 +587,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
{t("skills.skillssh.searchPlaceholder")}
|
||||
</p>
|
||||
</div>
|
||||
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
|
||||
) : accumulatedResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{t("skills.skillssh.noResults", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
@@ -87,6 +87,11 @@ const SelectItem = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PROVIDER_CARD_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"providers",
|
||||
"ProviderCard.tsx",
|
||||
);
|
||||
|
||||
describe("ProviderCard layout", () => {
|
||||
const source = fs.readFileSync(PROVIDER_CARD_TSX, "utf8");
|
||||
|
||||
it("lets website links use available card width before truncating", () => {
|
||||
expect(source).not.toContain("max-w-[280px]");
|
||||
expect(source).toContain("flex min-w-0 flex-1 items-center gap-2");
|
||||
expect(source).toContain("min-w-0 flex-1 space-y-1");
|
||||
expect(source).toContain(
|
||||
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SELECT_TSX = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"components",
|
||||
"ui",
|
||||
"select.tsx",
|
||||
);
|
||||
|
||||
const stripComments = (source: string) =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
|
||||
describe("SelectItem indicator structure", () => {
|
||||
const source = stripComments(fs.readFileSync(SELECT_TSX, "utf8"));
|
||||
|
||||
it("renders an item indicator before item text", () => {
|
||||
const indicatorIndex = source.indexOf("SelectPrimitive.ItemIndicator");
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
|
||||
expect(indicatorIndex).toBeGreaterThan(-1);
|
||||
expect(itemTextIndex).toBeGreaterThan(-1);
|
||||
expect(indicatorIndex).toBeLessThan(itemTextIndex);
|
||||
});
|
||||
|
||||
it("wraps the indicator in the first direct span", () => {
|
||||
const itemTextIndex = source.indexOf("SelectPrimitive.ItemText");
|
||||
const beforeItemText = source.slice(0, itemTextIndex);
|
||||
const lastSpanOpen = beforeItemText.lastIndexOf("<span");
|
||||
const lastIndicator = beforeItemText.lastIndexOf("ItemIndicator");
|
||||
|
||||
expect(lastSpanOpen).toBeGreaterThan(-1);
|
||||
expect(lastSpanOpen).toBeLessThan(lastIndicator);
|
||||
});
|
||||
});
|
||||
@@ -5,33 +5,49 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
SkillsPage,
|
||||
getSkillsPageHeaderActions,
|
||||
type SkillsPageHandle,
|
||||
} from "@/components/skills/SkillsPage";
|
||||
import type {
|
||||
DiscoverableSkill,
|
||||
SkillRepo,
|
||||
SkillsShDiscoverableSkill,
|
||||
SkillsShSearchResult,
|
||||
} from "@/lib/api/skills";
|
||||
|
||||
const installMutateAsyncMock = vi.fn();
|
||||
let discoverableSkillsMock: DiscoverableSkill[] = [];
|
||||
let skillReposMock: SkillRepo[] = [];
|
||||
const refetchDiscoverableMock = vi.fn();
|
||||
|
||||
// Stable cache so repeated renders see referentially-equal data.
|
||||
// SkillsPage has `useEffect([skillsShResult, ...])` that calls setState — a
|
||||
// fresh object every render would loop forever.
|
||||
const searchCache = new Map<
|
||||
string,
|
||||
{ data: SkillsShSearchResult | undefined; isLoading: boolean; isFetching: boolean }
|
||||
{
|
||||
data: SkillsShSearchResult | undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData?: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const setSearchResult = (
|
||||
query: string,
|
||||
offset: number,
|
||||
result: SkillsShSearchResult | undefined,
|
||||
state: Partial<{
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isPlaceholderData: boolean;
|
||||
}> = {},
|
||||
) => {
|
||||
searchCache.set(`${query}:${offset}`, {
|
||||
data: result,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -45,10 +61,10 @@ vi.mock("sonner", () => ({
|
||||
|
||||
vi.mock("@/hooks/useSkills", () => ({
|
||||
useDiscoverableSkills: () => ({
|
||||
data: [] as DiscoverableSkill[],
|
||||
data: discoverableSkillsMock,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
refetch: refetchDiscoverableMock,
|
||||
}),
|
||||
useInstalledSkills: () => ({
|
||||
data: [],
|
||||
@@ -58,7 +74,7 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
mutateAsync: installMutateAsyncMock,
|
||||
}),
|
||||
useSkillRepos: () => ({
|
||||
data: [],
|
||||
data: skillReposMock,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useAddSkillRepo: () => ({
|
||||
@@ -88,10 +104,35 @@ const makeSkillsShSkill = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeDiscoverableSkill = (
|
||||
overrides: Partial<DiscoverableSkill> = {},
|
||||
): DiscoverableSkill => ({
|
||||
key: "repo-skill:owner-a:repo-a",
|
||||
name: "Repo Skill",
|
||||
description: "Skill from a configured repository",
|
||||
directory: "repo-skill",
|
||||
readmeUrl: "https://example.com/repo-skill",
|
||||
repoOwner: "owner-a",
|
||||
repoName: "repo-a",
|
||||
repoBranch: "main",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSkillRepo = (overrides: Partial<SkillRepo> = {}): SkillRepo => ({
|
||||
owner: "owner-a",
|
||||
name: "repo-a",
|
||||
branch: "main",
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
beforeEach(() => {
|
||||
installMutateAsyncMock.mockReset();
|
||||
installMutateAsyncMock.mockResolvedValue({});
|
||||
discoverableSkillsMock = [];
|
||||
skillReposMock = [];
|
||||
refetchDiscoverableMock.mockReset();
|
||||
searchCache.clear();
|
||||
});
|
||||
|
||||
@@ -156,4 +197,143 @@ describe("SkillsPage - skills.sh install (regression)", () => {
|
||||
expect(callArgs.skill.repoName).toBe("repo-b");
|
||||
expect(callArgs.skill.name).toBe("Agent Browser B");
|
||||
});
|
||||
|
||||
it("keeps skills.sh results when submitting the same query again", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the skills.sh loading state while a new query is fetching", async () => {
|
||||
const figmaSkill = makeSkillsShSkill({
|
||||
key: "figma-use:figma:mcp-server-guide",
|
||||
name: "figma-use",
|
||||
directory: "figma-use",
|
||||
repoOwner: "figma",
|
||||
repoName: "mcp-server-guide",
|
||||
});
|
||||
|
||||
setSearchResult("figma", 0, {
|
||||
skills: [figmaSkill],
|
||||
totalCount: 1,
|
||||
query: "figma",
|
||||
});
|
||||
setSearchResult("react", 0, undefined, { isFetching: true });
|
||||
|
||||
render(<SkillsPage initialApp="claude" />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
const input = screen.getByPlaceholderText(
|
||||
"skills.skillssh.searchPlaceholder",
|
||||
);
|
||||
await user.type(input, "figma");
|
||||
|
||||
const searchButton = screen.getByRole("button", {
|
||||
name: "skills.search",
|
||||
});
|
||||
await user.click(searchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("figma-use")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "react");
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(screen.getByText("skills.skillssh.loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the effective skills.sh source to parent chrome", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the repository source when configured repositories return no discoverable skills", async () => {
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
const onSourceChange = vi.fn();
|
||||
|
||||
render(<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
expect(
|
||||
screen.getByPlaceholderText("skills.searchPlaceholder"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("can switch back to repository results after discoverable skills refresh", async () => {
|
||||
const onSourceChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSourceChange).toHaveBeenCalledWith("skillssh");
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skills\.sh/i }));
|
||||
|
||||
discoverableSkillsMock = [makeDiscoverableSkill()];
|
||||
skillReposMock = [makeSkillRepo()];
|
||||
rerender(
|
||||
<SkillsPage initialApp="claude" onSourceChange={onSourceChange} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "skills.searchSource.repos" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Repo Skill")).toBeInTheDocument();
|
||||
expect(onSourceChange).toHaveBeenCalledWith("repos");
|
||||
});
|
||||
|
||||
it("exposes repository-only header actions for the parent chrome", () => {
|
||||
expect(
|
||||
getSkillsPageHeaderActions("repos").map((action) => action.key),
|
||||
).toEqual(["refresh-repos", "manage-repos"]);
|
||||
expect(
|
||||
getSkillsPageHeaderActions("skillssh").map((action) => action.key),
|
||||
).toEqual(["manage-repos"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user