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:
thisTom
2026-06-23 10:07:14 +08:00
committed by GitHub
parent 2d478876fa
commit 2781d40e82
8 changed files with 366 additions and 56 deletions
+29 -22
View File
@@ -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" && (
+5 -5
View File
@@ -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>
+55 -10
View File
@@ -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", {
+6 -1
View File
@@ -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);
});
});
+184 -4
View File
@@ -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"]);
});
});