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
+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>
));