style(webui): refine skills mobile experience

This commit is contained in:
Xubin Ren 2026-07-27 21:31:34 +08:00
parent 655cd80651
commit 9b5a5c5302
4 changed files with 105 additions and 25 deletions

View File

@ -146,7 +146,12 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/>
</div>
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1">
<div
className={cn(
"flex max-w-full items-center gap-1 overflow-x-auto rounded-[10px] bg-muted/65 p-1",
"scrollbar-thin scrollbar-track-transparent sm:w-auto",
)}
>
{([
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
[
@ -165,7 +170,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
type="button"
onClick={() => setInstalledFilter(filter)}
className={cn(
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
"shrink-0 whitespace-nowrap rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
@ -314,6 +319,7 @@ function SkillDetailSheet({
const [actionError, setActionError] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
useEffect(() => {
if (!open || !skill) return;
@ -323,6 +329,7 @@ function SkillDetailSheet({
setLoadFailed(false);
setActionError("");
setDeleteOpen(false);
setDescriptionExpanded(false);
fetchSkillDetail(token, skill.name)
.then((payload) => {
if (!cancelled) setDetail(payload);
@ -349,6 +356,7 @@ function SkillDetailSheet({
: activeSkill.available
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
const descriptionExpandable = activeSkill.description.length > 240;
const toggleEnabled = async () => {
setActionBusy(true);
@ -399,12 +407,25 @@ function SkillDetailSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
closeButtonClassName={cn(
"right-2 top-2 inline-flex h-10 w-10 items-center justify-center rounded-full opacity-100",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"sm:right-3 sm:top-3",
)}
className={cn(
"w-full max-w-none gap-0 overflow-hidden border-l-0 p-0",
"sm:w-[min(34rem,calc(100vw-1rem))] sm:max-w-none sm:border-l",
)}
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<div className="flex items-start gap-3 pr-8">
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
<SheetTitle className="truncate text-[19px] font-semibold sm:text-[20px]">
{activeSkill.name}
</SheetTitle>
<SheetDescription className="sr-only">
@ -427,9 +448,26 @@ function SkillDetailSheet({
{statusLabel}
</Pill>
</div>
<p className="mt-3 text-[13px] leading-5 text-muted-foreground">
<p
className={cn(
"mt-3 text-[13px] leading-5 text-muted-foreground",
descriptionExpandable && !descriptionExpanded && "line-clamp-5",
)}
>
{activeSkill.description}
</p>
{descriptionExpandable ? (
<button
type="button"
aria-expanded={descriptionExpanded}
onClick={() => setDescriptionExpanded((value) => !value)}
className="mt-1.5 min-h-8 rounded-full text-[12px] font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{descriptionExpanded
? t("settings.skills.showLess", { defaultValue: "Show less" })
: t("settings.skills.showMore", { defaultValue: "Show more" })}
</button>
) : null}
</div>
</div>
@ -444,7 +482,7 @@ function SkillDetailSheet({
</div>
) : (
<div className="mt-6 space-y-5">
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5">
<div className="flex min-h-16 items-start justify-between gap-3 border-y border-border/45 px-1 py-3.5">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
@ -467,15 +505,20 @@ function SkillDetailSheet({
disabled={actionBusy}
onClick={() => void toggleEnabled()}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
"relative -mr-1 h-10 w-14 shrink-0 rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-wait disabled:opacity-60",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
"absolute left-1.5 top-2 h-6 w-11 rounded-full transition-colors",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)}
/>
<span
className={cn(
"absolute left-2 top-2.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0",
)}
/>
@ -498,7 +541,7 @@ function SkillDetailSheet({
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
{deletable ? (
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5">
<div className="flex flex-col items-stretch gap-3 border-t border-border/45 pt-5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<p className="text-[13px] font-medium text-foreground">
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
@ -511,11 +554,14 @@ function SkillDetailSheet({
</div>
<Button
type="button"
variant="destructive"
variant="outline"
size="sm"
disabled={actionBusy}
onClick={() => setDeleteOpen(true)}
className="shrink-0 rounded-full"
className={cn(
"h-10 w-full shrink-0 rounded-full border-destructive/20 text-destructive",
"hover:bg-destructive/8 hover:text-destructive sm:h-9 sm:w-auto",
)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.deleteAction", { defaultValue: "Delete" })}
@ -571,7 +617,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<summary className="flex min-h-11 cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
</span>
@ -657,7 +703,7 @@ function RequirementsSection({
})}
title={option.label}
onClick={() => void copyCommand(option.command)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
>
{copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
@ -689,7 +735,7 @@ function RequirementsSection({
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]"
className="mt-3 h-9 rounded-full bg-background/60 px-3 text-[11px]"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.checkAgain", { defaultValue: "Check again" })}

View File

@ -198,7 +198,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
{query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4">
<div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div>
<h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", {
@ -371,9 +371,9 @@ function MarketplaceSkillRow({
const { t } = useTranslation();
return (
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5">
<div className="flex min-w-0 items-center gap-2 px-1 py-3.5 sm:gap-3 sm:px-2">
{skill.rank ? (
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65">
<span className="w-6 shrink-0 text-right font-mono text-[11px] tabular-nums text-muted-foreground/65 sm:w-7 sm:text-[12px]">
#{skill.rank}
</span>
) : null}
@ -390,7 +390,7 @@ function MarketplaceSkillRow({
name: skill.name,
defaultValue: "Open {{name}} on skills.sh",
})}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a>
@ -418,7 +418,10 @@ function MarketplaceSkillRow({
variant={installed ? "secondary" : "default"}
disabled={installed || installBusy || installSupported === false}
onClick={() => onSelect(skill)}
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")}
className={cn(
"min-w-[82px] rounded-full px-2.5 sm:min-w-[92px] sm:px-3",
installed && "text-emerald-700",
)}
title={
installSupported === false
? t("settings.skills.marketplaceNpxRequired", {
@ -512,7 +515,7 @@ function TrendingSkeleton() {
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
</div>
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" />
<div className="h-8 w-[82px] animate-pulse rounded-full bg-muted sm:w-[92px]" />
</div>
))}
</div>

View File

@ -57,13 +57,24 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {
closeButtonClassName?: string;
showCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => (
>((
{
side = "right",
className,
children,
closeButtonClassName,
showCloseButton = true,
...props
},
ref,
) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
@ -78,7 +89,14 @@ const SheetContent = React.forwardRef<
>
{children}
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity",
"hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:pointer-events-none",
closeButtonClassName,
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@ -378,6 +378,12 @@ describe("App layout", () => {
});
it("opens Skills from the main sidebar", async () => {
const longSkillDescription = [
"Work with GitHub repositories, issues, pull requests, releases, workflows,",
"and code search through the GitHub CLI.",
"Use this skill for repository maintenance, review automation, release preparation,",
"and other GitHub workflows that need authenticated command-line access.",
].join(" ");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
@ -413,7 +419,7 @@ describe("App layout", () => {
},
"/api/webui/skills/github": {
name: "github",
description: "Work with GitHub.",
description: longSkillDescription,
source: "builtin",
enabled: true,
deletable: false,
@ -501,6 +507,13 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
const showMore = await screen.findByRole("button", { name: "Show more" });
expect(showMore).toHaveAttribute("aria-expanded", "false");
fireEvent.click(showMore);
expect(screen.getByRole("button", { name: "Show less" })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByText("Setup required")).toBeInTheDocument();
expect(screen.getByText("brew install gh")).toBeInTheDocument();
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();